From ff782e0f1e9a2a005fcf7b32128d9ca7795dc45d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Jul 2026 14:46:47 +0530 Subject: [PATCH 001/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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/844] 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 280/844] 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 281/844] 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 282/844] 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 283/844] 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 284/844] 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 285/844] 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 286/844] 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 287/844] 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 288/844] 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 289/844] 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 290/844] 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 291/844] 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 292/844] 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 293/844] 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 294/844] 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 295/844] 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 296/844] 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 297/844] 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 298/844] 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 299/844] 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 300/844] 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 301/844] 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 302/844] 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 303/844] 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 304/844] 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 311/844] 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 312/844] 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 313/844] 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 314/844] 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 315/844] 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 316/844] 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 317/844] 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 318/844] 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 319/844] 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 320/844] 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 321/844] 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 322/844] 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 323/844] 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 324/844] 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 325/844] 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 326/844] 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 327/844] 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 328/844] 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 329/844] 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 330/844] 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 331/844] 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 332/844] 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 333/844] 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 334/844] 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 335/844] 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 336/844] 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 337/844] 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 338/844] 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 339/844] 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 340/844] 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 341/844] 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 342/844] 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 343/844] 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 344/844] 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 345/844] 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 346/844] 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 347/844] 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 348/844] 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 349/844] 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 350/844] 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 351/844] 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 352/844] 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 353/844] 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 354/844] 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 355/844] 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 356/844] 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 357/844] 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 358/844] 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 359/844] 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 360/844] 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 361/844] 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 362/844] 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 363/844] 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 364/844] 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 365/844] 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 366/844] 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 367/844] 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 368/844] 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 369/844] 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 370/844] 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 371/844] 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 372/844] 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 373/844] 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 374/844] 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 375/844] 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 376/844] 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 377/844] 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 378/844] 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 379/844] 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 380/844] 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 381/844] 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 382/844] 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 383/844] 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 384/844] 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 385/844] 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 386/844] 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 387/844] 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 388/844] 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 389/844] 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 390/844] 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 391/844] 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 392/844] 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 393/844] 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 394/844] 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 395/844] 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 396/844] 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 397/844] 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 398/844] 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 399/844] 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 400/844] 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 401/844] 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 402/844] 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 ``/`
'; - const publisherContainer = document.getElementById('publisher-owned')!; - const publisherFrame = publisherContainer.querySelector('iframe')!; - const render = attempt(); + function directApsHarness( + renderOptions: Partial[0]> = {} + ) { + document.body.innerHTML = '
placeholder
'; + const render = attempt(owner(), renderOptions); 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(), + const bootstrapNonce = indexedBootstrapNonce(2); + const rendererNonce = indexedRendererNonce(2); + let captureListener: ((event: MessageEvent) => void) | undefined; + const target = { + addEventListener: vi.fn( + (_type: 'message', listener: (event: MessageEvent) => void, _capture: true) => { + captureListener = listener; + } + ), removeEventListener: vi.fn(), - MessageChannel: class { - readonly port1 = retainedRaw; - readonly port2 = transferredRaw; - }, + }; + const messaging = createBrowserMessagingAdapter(target); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: bootstrapNonce }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: rendererNonce }), }); - const nonces = createRendererNonceRegistry(); + const rawRendererPort = browserMessagePort(); + const container = document.getElementById('fictional-slot')!; + const mounted = renderDirectApsAttempt({ + attempt: render, + bootstrapNonces: bootstraps, + container, + messaging, + nonces: renderers, + publisherOrigin: window.location.origin, + }); + const frame = container.querySelector('iframe'); + const sourceWindow = frame?.contentWindow; + + const emitGlobal = ( + data: string, + ports: readonly unknown[] = [], + source: unknown = sourceWindow, + origin = 'null' + ): void => { + captureListener?.({ data, origin, ports, source } as unknown as MessageEvent); + }; + const bootstrapReady = ( + ports: readonly unknown[] = [], + source: unknown = sourceWindow, + origin = 'null' + ): void => { + emitGlobal( + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + ports, + source, + origin + ); + }; + const containerReady = ( + ports: readonly unknown[] = [rawRendererPort], + source: unknown = sourceWindow, + origin = 'null' + ): void => { + emitGlobal( + JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, + }), + ports, + source, + origin + ); + }; + const cleanup = (): void => { + if (render.snapshot().outcome === undefined) render.cancel('caller_aborted'); + bootstraps.dispose(); + renderers.dispose(); + document.body.innerHTML = ''; + }; + return { + bootstrapNonce, + bootstrapReady, + bootstraps, + captureListener, + cleanup, + container, + containerReady, + emitGlobal, + frame, + messaging, + mounted, + rawRendererPort, + render, + rendererNonce, + renderers, + sourceWindow, + target, + }; + } + + function completeDirectApsHandshake(harness: ReturnType): void { + harness.bootstrapReady(); + harness.containerReady(); + } + it('rejects noncanonical, wrong-source, wrong-origin, and wrong-port bootstrap readiness', () => { + vi.useFakeTimers(); + const ignored = directApsHarness(); 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); + expect(ignored.mounted).toBe(true); + ignored.emitGlobal( + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: ignored.bootstrapNonce, + }), + [], + Object.freeze({}) + ); + ignored.emitGlobal( + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: ignored.bootstrapNonce, + }), + [], + ignored.sourceWindow, + window.location.origin + ); + ignored.emitGlobal( + ' {"message":"TS APS Bootstrap Ready","version":1,"bootstrapNonce":"' + + ignored.bootstrapNonce + + '"}', + [] + ); + expect(ignored.frame?.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); + expect(ignored.render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_document', + }); } finally { - createElement.mockRestore(); - nonces.dispose(); - document.body.innerHTML = ''; + ignored.cleanup(); } - }); - - 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) }), - }); + const rejected = directApsHarness(); + const unexpectedPort = browserMessagePort(); 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); + rejected.bootstrapReady([unexpectedPort]); + expect(unexpectedPort.close).toHaveBeenCalledOnce(); + expect(rejected.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(rejected.container.querySelector('iframe')).toBeNull(); } finally { - createElement.mockRestore(); - nonces.dispose(); - document.body.innerHTML = ''; + rejected.cleanup(); + vi.useRealTimers(); } }); - 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 }); + it('shares one three-second deadline through bootstrap, navigation, channel, and acceptance', () => { + vi.useFakeTimers(); + const noBootstrap = directApsHarness(); + try { + expect(noBootstrap.mounted).toBe(true); + vi.advanceTimersByTime(2_999); + expect(noBootstrap.render.snapshot().outcome).toBeUndefined(); + vi.advanceTimersByTime(1); + expect(noBootstrap.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(noBootstrap.container.querySelector('iframe')).toBeNull(); + } finally { + noBootstrap.cleanup(); + } - 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 = ''; + const noAcceptance = directApsHarness(); + try { + completeDirectApsHandshake(noAcceptance); + vi.advanceTimersByTime(2_999); + expect(noAcceptance.render.snapshot().outcome).toBeUndefined(); + vi.advanceTimersByTime(1); + expect(noAcceptance.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(noAcceptance.rawRendererPort.close).toHaveBeenCalledOnce(); + } finally { + noAcceptance.cleanup(); + vi.useRealTimers(); + } }); - it('binds the inserted renderer window and accepts only exact document-port completion', () => { + it('starts the sole ten-second completion deadline at document acceptance', () => { 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')!; - + const harness = directApsHarness(); 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({ + completeDirectApsHandshake(harness); + harness.rawRendererPort.emit({ message: 'TS APS Document Accepted', version: 1, - nonce: indexedRendererNonce(2), + nonce: harness.rendererNonce, }); - 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', + harness.rawRendererPort.emit({ + message: 'TS APS Runner Loaded', version: 1, - nonce, + nonce: harness.rendererNonce, + }); + vi.advanceTimersByTime(9_999); + expect(harness.render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_aps_completion', + }); + vi.advanceTimersByTime(1); + expect(harness.render.snapshot().outcome).toEqual({ + outcome: 'failed', reason: 'runner_failed', }); - expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); - expect(iframe.isConnected).toBe(true); - expect(retainedRaw.close).toHaveBeenCalledOnce(); - expect(transferredRaw.close).not.toHaveBeenCalled(); + expect(harness.container.querySelector('iframe')).toBeNull(); } finally { - artifacts.dispose(); - nonces.dispose(); - document.body.innerHTML = ''; + harness.cleanup(); vi.useRealTimers(); } }); - it('maps document and APS completion deadlines through the attempt-owned timers', () => { + it.each([ + ['descriptor_invalid', 'winner_not_renderable'], + ['runner_no_load', 'runner_no_load'], + ['runner_failed', 'runner_failed'], + ] as const)('maps renderer failure %s to %s', (wireReason, expectedReason) => { 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; - }, + const harness = directApsHarness(); + try { + completeDirectApsHandshake(harness); + harness.rawRendererPort.emit({ + message: 'TS APS Render Failed', + version: 1, + nonce: harness.rendererNonce, + reason: wireReason, }); - 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({ + expect(harness.render.snapshot().outcome).toEqual({ outcome: 'failed', - reason: 'runner_failed', + reason: expectedReason, }); - expect(runnerFrame.isConnected).toBe(false); + expect(harness.container.querySelector('iframe')).toBeNull(); } finally { - nonces.dispose(); - document.body.innerHTML = ''; + harness.cleanup(); 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) => { + it('makes frame mutation, port errors, cancellation, and late replays terminal and inert', () => { 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 }), - }); + const mutated = directApsHarness(); 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({ + mutated.frame?.setAttribute( + 'src', + window.location.origin + APS_RENDERER_V1_PATH + '#b1_9999999999999999999999' + ); + mutated.bootstrapReady(); + expect(mutated.render.snapshot().outcome).toEqual({ outcome: 'failed', reason: 'renderer_document_no_load', }); - expect(transferredRaw.close).toHaveBeenCalledOnce(); + expect(mutated.container.querySelector('iframe')).toBeNull(); } finally { - nonces.dispose(); - document.body.innerHTML = ''; - vi.useRealTimers(); + mutated.cleanup(); } - }); - - 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')!; + const errored = directApsHarness(); 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({ + completeDirectApsHandshake(errored); + errored.rawRendererPort.emitError(); + expect(errored.render.snapshot().outcome).toEqual({ outcome: 'failed', reason: 'renderer_document_no_load', }); + expect(errored.rawRendererPort.close).toHaveBeenCalledOnce(); } finally { - nonces.dispose(); - document.body.innerHTML = ''; - vi.useRealTimers(); + errored.cleanup(); } - }); - - 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; - }, - }); + const cancelled = directApsHarness(); 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', + completeDirectApsHandshake(cancelled); + expect(cancelled.render.cancel('caller_aborted')).toBe(true); + expect(cancelled.container.querySelector('iframe')).toBeNull(); + expect(cancelled.rawRendererPort.close).toHaveBeenCalledOnce(); + cancelled.rawRendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: cancelled.rendererNonce, + }); + cancelled.rawRendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: cancelled.rendererNonce, + }); + expect(cancelled.render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', }); + expect(cancelled.target.removeEventListener).toHaveBeenCalledOnce(); } finally { - nonces.dispose(); - document.body.innerHTML = ''; + cancelled.cleanup(); vi.useRealTimers(); } }); - it('does not remove DOM installed reentrantly by accepted-settlement observers', () => { + it('removes only insertion-time predecessors after completion', () => { 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')!; + const harness = directApsHarness(); + const duringRender = document.createElement('div'); + duringRender.id = 'during-render'; + let reentrant: HTMLDivElement | undefined; expect( - render.onSettled((outcome) => { + harness.render.onSettled((outcome) => { if (outcome.outcome !== 'accepted') return; - const successor = document.createElement('div'); - successor.id = 'reentrant-successor'; - container.appendChild(successor); + reentrant = document.createElement('div'); + reentrant.id = 'reentrant'; + harness.container.appendChild(reentrant); }) ).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(); + completeDirectApsHandshake(harness); + harness.rawRendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: harness.rendererNonce, + }); + harness.container.appendChild(duringRender); + harness.rawRendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: harness.rendererNonce, + }); + expect(harness.render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(harness.container.querySelector('span')).toBeNull(); + expect(duringRender.parentNode).toBe(harness.container); + expect(reentrant?.parentNode).toBe(harness.container); + expect(harness.frame?.parentNode).toBe(harness.container); } finally { - nonces.dispose(); - document.body.innerHTML = ''; + harness.cleanup(); 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', () => { + it('rejects invalid descriptors before nonce allocation, listener installation, or DOM mutation', () => { document.body.innerHTML = '
'; const render = attempt(); expect(render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT)).toBe(true); - const channelConstructor = vi.fn(); - const messaging = createBrowserMessagingAdapter({ + const target = { addEventListener: vi.fn(), removeEventListener: vi.fn(), - MessageChannel: channelConstructor as never, - }); - const nonces = createRendererNonceRegistry(); + }; + const bootstraps = createBootstrapNonceRegistry(); + const renderers = createRendererNonceRegistry(); const container = document.getElementById('fictional-slot')!; expect( renderDirectApsAttempt({ attempt: render, + bootstrapNonces: bootstraps, container, - messaging, - nonces, + messaging: createBrowserMessagingAdapter(target), + nonces: renderers, 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(target.addEventListener).not.toHaveBeenCalled(); + expect(bootstraps.snapshotForTest().bindings).toBe(0); + expect(renderers.snapshotForTest().bindings).toBe(0); expect(container.children).toHaveLength(0); - expect(render.snapshot().outcome).toEqual({ - outcome: 'failed', - reason: 'winner_not_renderable', - }); - nonces.dispose(); + bootstraps.dispose(); + renderers.dispose(); document.body.innerHTML = ''; }); diff --git a/scripts/generate-aps-renderer-contract.mjs b/scripts/generate-aps-renderer-contract.mjs index f3c82ad38..ba65830bb 100644 --- a/scripts/generate-aps-renderer-contract.mjs +++ b/scripts/generate-aps-renderer-contract.mjs @@ -308,7 +308,7 @@ const typescriptOutput = const documentValidatorOutput = generatedHeader + - "export const APS_RENDERER_VALIDATOR_ES5_V1 = " + + "export const APS_RENDERER_VALIDATOR_ES5_V1 =\n " + JSON.stringify(es5Output) + ";\n"; From 23015fd955254fce65164d476d8cb5a5e416b94e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:45:11 -0700 Subject: [PATCH 522/844] Move PUC APS rendering to top-page mounts --- .../browser/helpers/gam-test-network.ts | 2 +- .../tests/shared/aps-puc-lifecycle.spec.ts | 109 ++- .../lib/src/adapters/messaging.ts | 63 +- .../lib/src/composition/browser_test.ts | 18 +- .../src/first_display/adapters/googletag.ts | 74 +- .../src/first_display/adm_render_bridge.ts | 1 + .../lib/src/first_display/driver.ts | 1 + .../lib/src/first_display/render_bridge.ts | 177 +++-- .../lib/src/integrations/aps/module.ts | 19 +- .../lib/src/integrations/aps/render.ts | 154 +++- .../lib/src/integrations/gpt/module.ts | 17 +- .../src/kernel/contracts/message_protocol.ts | 4 +- .../src/kernel/contracts/puc_dynamic_owner.ts | 212 +---- .../lib/src/services/puc_bridge.ts | 399 ++-------- .../lib/src/services/slots.ts | 155 +++- .../lib/test/adapters/messaging.test.ts | 96 +-- .../lib/test/composition/browser.test.ts | 19 +- .../first_display/adm_render_bridge.test.ts | 1 + .../lib/test/first_display/agent.test.ts | 1 + .../lib/test/first_display/driver.test.ts | 1 + .../test/first_display/gpt_adapter.test.ts | 65 ++ .../test/first_display/render_bridge.test.ts | 136 +++- .../lib/test/integrations/aps/module.test.ts | 4 + .../lib/test/services/puc_bridge.test.ts | 736 ++++++------------ .../lib/test/services/render.test.ts | 152 ++++ .../lib/test/services/slots.test.ts | 106 +++ 26 files changed, 1401 insertions(+), 1321 deletions(-) diff --git a/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts b/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts index 5867d2b4b..3ba8f4173 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts @@ -29,7 +29,7 @@ const PROTOCOL_MESSAGES = [ "TS Render Owner Register", "TS Render Owner Registered", "TS Render Owner Refused", - "TS APS Start", + "TS APS Top Mount Started", "TS ADM Start", "TS Owner Inserted", "TS Owner Settled", diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts index 9a37378f5..878bdf3c2 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts @@ -1,14 +1,51 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { expect, test, type Page } from "@playwright/test"; import { installGptStub } from "../../helpers/gpt-stub.js"; +import { runtimeUrl } from "../../helpers/state.js"; import { loadRuntimeTsjsFixture, runtimeTsjsFixture, } from "../../helpers/tsjs-fixture.js"; -const GPT_FIXTURE = runtimeTsjsFixture(["render_runtime", "gpt"]); +const GPT_FIXTURE = runtimeTsjsFixture(["render_runtime", "aps", "gpt"]); const SLOT = "puc-lifecycle-slot"; const RESERVATION_ID = "r1_AAAAAAAAAAAAAAAAAAAAAA"; +const APS_RENDERER_URL = "https://puc.test/integrations/aps/renderer/v1"; +const APS_RUNNER_URL = "https://puc.test/integrations/aps/runner.js"; +const FICTIONAL_APS_RUNNER = readFileSync( + resolve(__dirname, "../../fixtures/fictional-aps-runner.js"), + "utf8", +); + +function apsDescriptor() { + const bid = { + id: "duplicate-puc-top-mount-bid", + price: 1.25, + w: 300, + h: 250, + ext: { + creativeurl: "https://creative.example/render", + tagtype: "iframe", + }, + }; + return { + type: "aps", + version: 1, + accountId: "fictional-account", + bidId: bid.id, + creativeId: "fictional-creative", + tagType: bid.ext.tagtype, + creativeUrl: bid.ext.creativeurl, + width: bid.w, + height: bid.h, + aaxResponse: Buffer.from( + JSON.stringify({ seatbid: [{ bid: [bid] }] }), + "utf8", + ).toString("base64"), + }; +} function boot() { return { @@ -43,13 +80,7 @@ function boot() { currency: "USD", targeting: { hb_bidder: "fictional" }, rendererReservationId: RESERVATION_ID, - renderSource: { - type: "adm", - version: 1, - adm: '
fictional creative
', - width: 300, - height: 250, - }, + renderSource: apsDescriptor(), }, ], }, @@ -72,6 +103,31 @@ async function openLifecyclePage( nonemptyCompletionOnDisplay = false, ): Promise { await installGptStub(page); + const rendererResponse = await page.request.get( + runtimeUrl("/integrations/aps/renderer/v1"), + ); + expect(rendererResponse.status()).toBe(200); + const rendererDocument = await rendererResponse.text(); + await page.route(APS_RENDERER_URL, (route) => + route.fulfill({ + status: 200, + body: rendererDocument, + headers: { + "content-type": "text/html; charset=utf-8", + "content-security-policy": + rendererResponse.headers()["content-security-policy"] ?? "", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + }, + }), + ); + await page.route(APS_RUNNER_URL, (route) => + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: FICTIONAL_APS_RUNNER, + }), + ); await page.route("https://puc.test/fixture", (route) => route.fulfill({ status: 200, @@ -96,7 +152,7 @@ async function openLifecyclePage( browserWindow.tsjs = { boot: initialBoot, que: [], - _integrationConfig: { gpt: {} }, + _integrationConfig: { aps: {}, gpt: {} }, }; }, { @@ -180,8 +236,8 @@ async function expectAcceptedLifecycle(page: Page): Promise { .toEqual({ lifecycle: ["accepted"], pucFrames: 1, - ownerFrames: [1], - ownerSources: [expect.stringContaining("fictional creative")], + ownerFrames: [0], + ownerSources: [""], emissions: [ { name: "slotRequested", listeners: 1, physical: true }, { name: "slotRenderEnded", listeners: 1, physical: true }, @@ -194,6 +250,12 @@ async function expectAcceptedLifecycle(page: Page): Promise { refreshes: [], slotChildren: [ { tag: "IFRAME", fictionalPuc: true, title: null, adm: false }, + { + tag: "IFRAME", + fictionalPuc: false, + title: "Ad content", + adm: false, + }, ], }); @@ -203,8 +265,8 @@ async function expectAcceptedLifecycle(page: Page): Promise { const puc = root?.querySelector( "iframe[data-fictional-puc]", ); - const creative = puc?.contentDocument?.querySelector( - 'iframe[title="Ad content"]', + const creative = root?.querySelector( + ':scope > iframe[title="Ad content"]', ); return { hbAdId: ( @@ -215,12 +277,15 @@ async function expectAcceptedLifecycle(page: Page): Promise { } ).__gptDiagnosticsStub.targeting(slot, "hb_adid"), pucCount: root?.querySelectorAll("iframe[data-fictional-puc]").length, - creativeCount: puc?.contentDocument?.querySelectorAll( + ownerCreativeCount: puc?.contentDocument?.querySelectorAll( 'iframe[title="Ad content"]', ).length, + creativeCount: root?.querySelectorAll( + ':scope > iframe[title="Ad content"]', + ).length, creativeWidth: creative?.getAttribute("width"), creativeHeight: creative?.getAttribute("height"), - creativeSource: creative?.srcdoc, + creativeVisibility: creative?.style.visibility, }; }, SLOT), ).toEqual({ @@ -229,7 +294,8 @@ async function expectAcceptedLifecycle(page: Page): Promise { creativeCount: 1, creativeWidth: "300", creativeHeight: "250", - creativeSource: expect.stringContaining("fictional creative"), + creativeVisibility: "visible", + ownerCreativeCount: 0, }); } @@ -303,7 +369,7 @@ test.describe("APS/PUC lifecycle over the hard-cutover runtime", () => { ); return { pucFrames: pucFrames.length, - creativeFrames: pucFrames.reduce( + ownerCreativeFrames: pucFrames.reduce( (total, frame) => total + (frame.contentDocument?.querySelectorAll( @@ -311,8 +377,15 @@ test.describe("APS/PUC lifecycle over the hard-cutover runtime", () => { ).length ?? 0), 0, ), + topCreativeFrames: + root?.querySelectorAll(':scope > iframe[title="Ad content"]') + .length ?? 0, }; }, SLOT), - ).toEqual({ pucFrames: 2, creativeFrames: 1 }); + ).toEqual({ + pucFrames: 2, + ownerCreativeFrames: 0, + topCreativeFrames: 1, + }); }); }); diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index 2640370b3..be4e8b4d9 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -63,11 +63,11 @@ export const PROTOCOL_MESSAGE_SCHEMAS_V1 = Object.freeze({ prebidResponse: schema( 'structured', ['message', 'adId', 'renderer', 'rendererVersion', 'tsOwner'], - { message: TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse, rendererVersion: '3' } + { message: TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse, rendererVersion: '4' } ), prebidResponseRefused: schema('structured', ['message', 'adId', 'rendererVersion', 'tsOwner'], { message: TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse, - rendererVersion: '3', + rendererVersion: '4', }), tsOwnerReady: schema('structured', ['version', 'status', 'kind', 'lifecycleTicket'], { version: 1, @@ -85,11 +85,10 @@ export const PROTOCOL_MESSAGE_SCHEMAS_V1 = Object.freeze({ message: TSJS_MESSAGE_PROTOCOL_V1.message.ownerRefused, version: 1, }), - apsStart: schema( - 'structured', - ['message', 'version', 'lifecycleTicket', 'rendererUrl', 'envelope'], - { message: TSJS_MESSAGE_PROTOCOL_V1.message.apsStart, version: 1 } - ), + apsTopMountStarted: schema('structured', ['message', 'version', 'lifecycleTicket'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsTopMountStarted, + version: 1, + }), apsEnvelope: schema('structured', ['version', 'nonce', 'publisherOrigin', 'renderer'], { version: 1, }), @@ -379,29 +378,6 @@ function exactHttpOrigin(value: unknown): value is string { } } -function rendererUrl(value: unknown, expected?: string): value is string { - const valid = (candidate: unknown): candidate is string => { - if (!boundedString(candidate, 2_048)) return false; - try { - const parsed = new URL(candidate); - return ( - (parsed.protocol === 'http:' || parsed.protocol === 'https:') && - parsed.hostname !== '' && - parsed.username === '' && - parsed.password === '' && - parsed.pathname === '/integrations/aps/renderer/v1' && - parsed.search === '' && - parsed.hash === '' - ); - } catch { - return false; - } - }; - if (!valid(value)) return false; - if (expected !== undefined) return valid(expected) && value === expected; - return true; -} - function apsContainerUrl(value: unknown, bootstrapNonce: unknown): value is string { if ( !capability(bootstrapNonce, 'bootstrapNonce') || @@ -640,7 +616,7 @@ function parseTsOwner(candidate: unknown): Readonly> | u function validProtocolFields( kind: ProtocolMessageKind, record: Readonly>, - options: MessagingValidationOptions + _options: MessagingValidationOptions ): boolean { const ticket = (): boolean => capability(record['lifecycleTicket'], 'ticket'); const nonce = (): boolean => capability(record['nonce'], 'nonce'); @@ -678,12 +654,8 @@ function validProtocolFields( return capability(record['adId'], 'reservation') && ticket(); case 'ownerRefused': return capability(record['adId'], 'reservation'); - case 'apsStart': - return ( - ticket() && - options.expectedRendererUrl !== undefined && - rendererUrl(record['rendererUrl'], options.expectedRendererUrl) - ); + case 'apsTopMountStarted': + return ticket(); case 'apsEnvelope': return true; case 'admStart': @@ -758,22 +730,7 @@ function canonicalProtocolRecord( const owner = parseTsOwner(record['tsOwner']); return owner ? replaceNested(record, keys, { tsOwner: owner }) : undefined; } - if (kind === 'apsStart' || kind === 'apsEnvelope') { - if ( - kind === 'apsStart' && - (!capability(record['lifecycleTicket'], 'ticket') || - options.expectedRendererUrl === undefined || - !rendererUrl(record['rendererUrl'], options.expectedRendererUrl)) - ) { - return undefined; - } - const candidate = kind === 'apsStart' ? record['envelope'] : record; - const canonicalEnvelope = canonicalApsEnvelope(candidate, options); - if (!canonicalEnvelope) return undefined; - return kind === 'apsStart' - ? replaceNested(record, keys, { envelope: canonicalEnvelope }) - : canonicalEnvelope; - } + if (kind === 'apsEnvelope') return canonicalApsEnvelope(record, options); if (kind === 'admStart') { const source = exactRecord(record['source'], ['type', 'version', 'adm', 'width', 'height']); return source ? replaceNested(record, keys, { source }) : undefined; diff --git a/crates/trusted-server-js/lib/src/composition/browser_test.ts b/crates/trusted-server-js/lib/src/composition/browser_test.ts index fe80dab1b..c4daea959 100644 --- a/crates/trusted-server-js/lib/src/composition/browser_test.ts +++ b/crates/trusted-server-js/lib/src/composition/browser_test.ts @@ -47,7 +47,11 @@ import { serializeAuctionRequestBody, } from '../core/registry'; import { prepareAdmIframe } from '../core/render'; -import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; +import { + APS_RENDERER_V1_PATH, + renderDirectApsAttempt, + renderPucApsAttempt, +} from '../integrations/aps/render'; import { installClickGuard } from '../integrations/creative/click'; import { installDynamicIframeProxy } from '../integrations/creative/iframe'; import { installDynamicImageProxy } from '../integrations/creative/image'; @@ -1792,14 +1796,20 @@ export function createTestBrowserRuntimeComposition( const pucBridge = createPucBridge({ messaging: composition.adapters.messaging, mintLifecycleTicket: mintBrowserLifecycleTicket, - publisherOrigin: prepared.publisherOrigin, + mountAps: (input) => + renderPucApsAttempt({ + ...input, + bootstrapNonces: prepared.services.bootstrapNonces, + messaging: composition.adapters.messaging, + nonces: prepared.services.rendererNonces, + publisherOrigin: prepared.publisherOrigin, + }), ...(compositionOptions.pucSchedulerForTest ? { scheduler: compositionOptions.pucSchedulerForTest } : {}), - rendererNonces: prepared.services.rendererNonces, - rendererUrl: prepared.rendererUrl, reservations: prepared.services.reservations, resizeCollapsedShell: resizeCollapsedPucShell, + slots: prepared.services.slots, }); context.onDispose(() => pucBridge.dispose()); browserServices = Object.freeze({ ...prepared.services, pucBridge }); diff --git a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts index a19c17e11..ba6086198 100644 --- a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts @@ -42,6 +42,8 @@ export type FirstDisplayGptFailureReason = export interface FirstDisplayGptBoundCycleV1 { readonly bid: FirstDisplayProjectionBidV1; readonly element: HTMLElement; + /** Closure-private physical-cycle validity; false after a later GPT request takes the slot. */ + readonly isCurrent: () => boolean; readonly ownership: 'publisher' | 'trusted_server'; readonly physicalSlot: object; readonly placement: FirstDisplayProjectionSlotV1; @@ -116,6 +118,7 @@ interface FirstDisplayDiagnosticCycleRecord { } interface ActiveCycle extends FirstDisplayGptBoundCycleV1 { + readonly bindingState: { current: boolean }; readonly diagnosticRecords: FirstDisplayDiagnosticCycleRecord[]; readonly elementId: string; readonly operations: readonly ('display' | 'refresh')[]; @@ -333,6 +336,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze({ bid: cycle.bid, element: cycle.element, + isCurrent: cycle.isCurrent, ownership: cycle.ownership, physicalSlot: cycle.physicalSlot, placement: cycle.placement, @@ -539,11 +543,14 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } const traceToken = `gt1_${traceTokenOrdinal.toString(36)}`; this.nextTraceTokenOrdinal += 1; + const bindingState = { current: true }; const cycle: ActiveCycle = { bid: row.bid, + bindingState, diagnosticRecords: [], element, elementId: element.id, + isCurrent: () => bindingState.current, operations: plan.operations, ownership, physicalSlot: slot, @@ -563,6 +570,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze({ bid: cycle.bid, element: cycle.element, + isCurrent: cycle.isCurrent, ownership: cycle.ownership, physicalSlot: cycle.physicalSlot, placement: cycle.placement, @@ -615,8 +623,12 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { this.notifyNativeMutation(); const slot = physicalSlot(member(event, 'slot')); const cycle = slot ? this.cycles.get(slot) : undefined; - if (!cycle) return; + if (!cycle) { + if (slot) this.invalidateCyclesForElement(this.readPhysicalElementId(slot), slot); + return; + } if (cycle.settled) { + cycle.bindingState.current = false; this.captureDiagnosticFact('slotRequested', cycle, event); return; } @@ -655,6 +667,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze({ bid: cycle.bid, element: cycle.element, + isCurrent: cycle.isCurrent, ownership: cycle.ownership, physicalSlot: cycle.physicalSlot, placement: cycle.placement, @@ -790,9 +803,14 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (typeof original !== 'function') continue; const prior = Object.getOwnPropertyDescriptor(receiver, key); const notify = (): void => this.notifyNativeMutation(); + const invalidate = (arguments_: readonly unknown[], result: unknown): void => + this.invalidateCyclesForPublisherCall(key, arguments_, result); const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { const result = Reflect.apply(original, this, arguments_); - if (this === receiver) notify(); + if (this === receiver) { + invalidate(arguments_, result); + notify(); + } return result; }; if ( @@ -819,6 +837,58 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { this.publisherCallRestorers.push(...installed); } + private readPhysicalElementId(slot: object): string | undefined { + try { + const read = member(slot, 'getSlotElementId'); + if (typeof read !== 'function') return undefined; + const value = Reflect.apply(read, slot, []); + return typeof value === 'string' && value.length > 0 ? value : undefined; + } catch { + return undefined; + } + } + + private invalidateCyclesForElement(elementId: string | undefined, replacement: object): void { + if (!elementId) return; + for (const cycle of this.cycles.values()) { + if (cycle.physicalSlot !== replacement && cycle.elementId === elementId) { + cycle.bindingState.current = false; + } + } + } + + private invalidateCyclesForPublisherCall( + key: string, + arguments_: readonly unknown[], + result: unknown + ): void { + try { + if (key === 'destroySlots' && result === true) { + const targets = arguments_[0]; + if (targets === undefined) { + for (const cycle of this.cycles.values()) cycle.bindingState.current = false; + return; + } + if (!Array.isArray(targets)) return; + for (let index = 0; index < targets.length; index += 1) { + const target = physicalSlot(targets[index]); + const cycle = target ? this.cycles.get(target) : undefined; + if (cycle) cycle.bindingState.current = false; + } + return; + } + if (key === 'defineSlot') { + const replacement = physicalSlot(result); + const elementId = arguments_[2]; + if (replacement && typeof elementId === 'string') { + this.invalidateCyclesForElement(elementId, replacement); + } + } + } catch { + // The publisher call already completed; observation cannot alter its result. + } + } + private restorePublisherCalls(): void { for (const restore of this.publisherCallRestorers.reverse()) { try { diff --git a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts index a82b585b1..1660fac23 100644 --- a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts @@ -34,6 +34,7 @@ function validAdmCycle(cycle: FirstDisplayGptBoundCycleV1): boolean { try { return ( cycle.bid.renderSource.type === 'adm' && + cycle.isCurrent() && cycle.slotId === cycle.bid.slot && cycle.slotId === cycle.placement.slot && cycle.element.ownerDocument.getElementById(cycle.element.id) === cycle.element && diff --git a/crates/trusted-server-js/lib/src/first_display/driver.ts b/crates/trusted-server-js/lib/src/first_display/driver.ts index 4b76cbe80..bcf9b1463 100644 --- a/crates/trusted-server-js/lib/src/first_display/driver.ts +++ b/crates/trusted-server-js/lib/src/first_display/driver.ts @@ -63,6 +63,7 @@ function sameCycle( candidate.slotId === expected.slotId && candidate.bid === expected.bid && candidate.element === expected.element && + candidate.isCurrent === expected.isCurrent && candidate.placement === expected.placement && candidate.physicalSlot === expected.physicalSlot && candidate.ownership === expected.ownership && diff --git a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts index 9d88c9459..03c88e783 100644 --- a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts @@ -79,9 +79,13 @@ interface Attempt { documentTimer: unknown; documentTransferred: PortLike | undefined; gam: FirstDisplayGptRenderResult | undefined; + hostPositionOwned: boolean; inserted: boolean; insertionTimer: unknown; ownerTicket: string | undefined; + overlay: boolean; + previousHostPosition: string; + previousHostPositionPriority: string; rendererNonce: string | undefined; ownerSource: object | undefined; pendingDocumentTerminal: @@ -457,11 +461,21 @@ function encodeOpaque(bytes: Uint8Array): string { function validCycle(cycle: FirstDisplayGptBoundCycleV1): boolean { try { const source = cycle.bid.renderSource; + const document = cycle.element.ownerDocument; + let exactElementMatches = 0; + const elements = document.getElementsByTagName('*'); + for (let index = 0; index < elements.length; index += 1) { + const candidate = elements.item(index); + if (candidate?.id !== cycle.element.id) continue; + if (candidate !== cycle.element) return false; + exactElementMatches += 1; + } return ( + cycle.isCurrent() && cycle.slotId === cycle.bid.slot && cycle.slotId === cycle.placement.slot && - cycle.element.ownerDocument !== null && - cycle.element.ownerDocument.getElementById(cycle.element.id) === cycle.element && + exactElementMatches === 1 && + document.getElementById(cycle.element.id) === cycle.element && RESERVATION_ID.test(cycle.bid.rendererReservationId) && (source.type === 'adm' || source.type === 'aps') ); @@ -474,7 +488,7 @@ function refusedResponse(adId: string): string { return JSON.stringify({ message: 'Prebid Response', adId, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'refused' }, }); } @@ -735,6 +749,27 @@ export function createFirstDisplayRenderBridge( // The exact frame is no longer authoritative after terminal settlement. } } + if (removeFrame && attempt.hostPositionOwned) { + attempt.hostPositionOwned = false; + try { + const style = attempt.cycle.element.style; + if ( + style.getPropertyValue('position') === 'relative' && + style.getPropertyPriority('position') === '' + ) { + if (attempt.previousHostPosition === '') style.removeProperty('position'); + else { + style.setProperty( + 'position', + attempt.previousHostPosition, + attempt.previousHostPositionPriority + ); + } + } + } catch { + // Compare-owned publisher style restoration is best-effort. + } + } attempt.directFrame = undefined; }; @@ -806,7 +841,7 @@ export function createFirstDisplayRenderBridge( return nonce; }; - const exactDirectApsFrame = (attempt: Attempt, permanent: boolean): boolean => { + const exactApsFrame = (attempt: Attempt, permanent: boolean): boolean => { const aps = apsProtocol(); const frame = attempt.directFrame; const bootstrapNonce = attempt.bootstrapNonce; @@ -814,7 +849,9 @@ export function createFirstDisplayRenderBridge( try { return ( attempt.active && - attempt.phaseValue === 'rendering_direct' && + (attempt.phaseValue === 'rendering_direct' || + (attempt.overlay && attempt.phaseValue === 'waiting_for_insertion')) && + validCycle(attempt.cycle) && attempt.inserted && frame.isConnected && frame.parentNode === attempt.cycle.element && @@ -873,7 +910,7 @@ export function createFirstDisplayRenderBridge( return true; } const aps = apsProtocol(); - if (!aps || attempt.bootstrapNavigated || !exactDirectApsFrame(attempt, false)) { + if (!aps || attempt.bootstrapNavigated || !exactApsFrame(attempt, false)) { fail(attempt, 'renderer_document_no_load'); return true; } @@ -894,7 +931,7 @@ export function createFirstDisplayRenderBridge( bootstrapNonce, containerUrl, }); - if (!exactDirectApsFrame(attempt, true) || !postWindow(source, navigation)) { + if (!exactApsFrame(attempt, true) || !postWindow(source, navigation)) { fail(attempt, 'renderer_document_no_load'); return true; } @@ -938,7 +975,7 @@ export function createFirstDisplayRenderBridge( !port || !attempt.bootstrapNavigated || attempt.documentPort || - !exactDirectApsFrame(attempt, true) + !exactApsFrame(attempt, true) ) { for (const candidate of inspection?.ports ?? []) closePort(candidate); fail(attempt, 'renderer_document_no_load'); @@ -964,7 +1001,7 @@ export function createFirstDisplayRenderBridge( publisherOrigin: aps.publisherOrigin, renderer: attempt.cycle.bid.renderSource, }) || - !exactDirectApsFrame(attempt, true) + !exactApsFrame(attempt, true) ) { fail(attempt, 'renderer_document_no_load'); } @@ -974,7 +1011,7 @@ export function createFirstDisplayRenderBridge( function handleApsDocument(attempt: Attempt, event: unknown): void { const aps = apsProtocol(); if (!attempt.active || !attempt.rendererNonce || !aps) return; - if (attempt.phaseValue === 'rendering_direct' && !exactDirectApsFrame(attempt, true)) { + if (!exactApsFrame(attempt, true)) { fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); return; } @@ -1006,7 +1043,7 @@ export function createFirstDisplayRenderBridge( ); const pending = attempt.pendingDocumentTerminal; attempt.pendingDocumentTerminal = undefined; - if (pending === 'completed') settle(attempt, 'accepted'); + if (pending === 'completed') completeAps(attempt); else if (pending) fail(attempt, pending); }; if (parsed.kind === 'document_accepted') { @@ -1019,7 +1056,7 @@ export function createFirstDisplayRenderBridge( return; } if (parsed.kind === 'render_completed') { - if (attempt.documentAccepted) settle(attempt, 'accepted'); + if (attempt.documentAccepted) completeAps(attempt); else if (attempt.documentAcceptancePending && !attempt.pendingDocumentTerminal) { attempt.pendingDocumentTerminal = 'completed'; } else fail(attempt, 'renderer_document_no_load'); @@ -1033,6 +1070,31 @@ export function createFirstDisplayRenderBridge( } else fail(attempt, 'renderer_document_no_load'); } + function completeAps(attempt: Attempt): void { + if (!attempt.active || !exactApsFrame(attempt, true)) { + fail(attempt, 'slot_unresolved'); + return; + } + if (attempt.overlay) { + try { + const frame = attempt.directFrame; + if (!frame) { + fail(attempt, 'slot_unresolved'); + return; + } + frame.style.setProperty('visibility', 'visible'); + if (frame.style.getPropertyValue('visibility') !== 'visible') { + fail(attempt, 'internal_error'); + return; + } + } catch { + fail(attempt, 'internal_error'); + return; + } + } + settle(attempt, 'accepted'); + } + const ownerInserted = (attempt: Attempt): void => { if (!attempt.active || attempt.inserted) return; attempt.inserted = true; @@ -1071,6 +1133,7 @@ export function createFirstDisplayRenderBridge( const ticket = attempt.ownerTicket; const inserted = exactRecord(data, ['message', 'version', 'lifecycleTicket']); if ( + attempt.cycle.bid.renderSource.type === 'adm' && inserted?.message === 'TS Owner Inserted' && inserted.version === 1 && inserted.lifecycleTicket === ticket @@ -1121,41 +1184,15 @@ export function createFirstDisplayRenderBridge( }); } if (!aps) return fail(attempt, 'winner_not_renderable'); - const nonce = issueRendererNonce(attempt); - if (!nonce) return fail(attempt, 'capability_registry_full'); - let channel: ChannelLike; - try { - channel = options.createChannel(); - } catch { - return fail(attempt, 'internal_error'); - } - attempt.documentPort = channel.port1; - attempt.documentTransferred = channel.port2; - attempt.documentRelease = listen( - channel.port1, - (event) => handleApsDocument(attempt, event), - () => fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load') - ); - if (!attempt.documentRelease) return fail(attempt, 'internal_error'); - const started = post( - controlPort, - { - message: 'TS APS Start', + attempt.overlay = true; + if (!renderAps(attempt)) return false; + return ( + post(controlPort, { + message: 'TS APS Top Mount Started', version: 1, lifecycleTicket: ticket, - rendererUrl: aps.rendererUrl, - envelope: { - version: 1, - nonce, - publisherOrigin: aps.publisherOrigin, - renderer: attempt.cycle.bid.renderSource, - }, - }, - [channel.port2] + }) || fail(attempt, 'internal_error') ); - closePort(channel.port2); - attempt.documentTransferred = undefined; - return started || fail(attempt, 'internal_error'); }; const handleOwnerRegistration = ( @@ -1302,7 +1339,7 @@ export function createFirstDisplayRenderBridge( message: 'Prebid Response', adId: attempt.reservationId, renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: owner, }); attempt.claim = undefined; @@ -1324,7 +1361,8 @@ export function createFirstDisplayRenderBridge( frame: HTMLIFrameElement, width: number, height: number, - sandbox: string + sandbox: string, + overlay = false ): void => { frame.setAttribute('sandbox', sandbox); frame.setAttribute('referrerpolicy', 'no-referrer'); @@ -1338,10 +1376,32 @@ export function createFirstDisplayRenderBridge( frame.setAttribute('aria-label', 'Advertisement'); frame.setAttribute( 'style', - `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + overlay + ? `border: 0; display: block; height: ${height}px; inset: 0; margin: 0; overflow: hidden; position: absolute; visibility: hidden; width: ${width}px; z-index: 2147483647;` + : `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` ); }; + const acquireOverlayPosition = (attempt: Attempt): boolean => { + if (!attempt.overlay) return true; + try { + if (!validCycle(attempt.cycle)) return false; + const host = attempt.cycle.element; + const browser = host.ownerDocument.defaultView; + if (!browser) return false; + if (browser.getComputedStyle(host).position !== 'static') return true; + attempt.previousHostPosition = host.style.getPropertyValue('position'); + attempt.previousHostPositionPriority = host.style.getPropertyPriority('position'); + host.style.setProperty('position', 'relative'); + attempt.hostPositionOwned = + host.style.getPropertyValue('position') === 'relative' && + host.style.getPropertyPriority('position') === ''; + return attempt.hostPositionOwned && validCycle(attempt.cycle); + } catch { + return false; + } + }; + const renderDirectAdm = (attempt: Attempt): boolean => { const source = attempt.cycle.bid.renderSource; if (source.type !== 'adm') return false; @@ -1374,14 +1434,16 @@ export function createFirstDisplayRenderBridge( } }; - const renderDirectAps = (attempt: Attempt): boolean => { + const renderAps = (attempt: Attempt): boolean => { const source = attempt.cycle.bid.renderSource; const aps = apsProtocol(); if (source.type !== 'aps' || !aps) return false; - attempt.insertionTimer = arm( - () => fail(attempt, 'owner_insertion_timeout'), - aps.deadlines.insertionMs - ); + if (attempt.insertionTimer === undefined) { + attempt.insertionTimer = arm( + () => fail(attempt, 'owner_insertion_timeout'), + aps.deadlines.insertionMs + ); + } if (attempt.insertionTimer === undefined) return fail(attempt, 'internal_error'); const bootstrapNonce = issueBootstrapNonce(attempt); const rendererNonce = issueRendererNonce(attempt); @@ -1403,18 +1465,19 @@ export function createFirstDisplayRenderBridge( } try { const frame = options.document.createElement('iframe'); - configureFrame(frame, source.width, source.height, aps.sandbox); + configureFrame(frame, source.width, source.height, aps.sandbox, attempt.overlay); const intended = aps.rendererUrl + '#' + bootstrapNonce; frame.onerror = () => fail(attempt, 'renderer_document_no_load'); frame.src = intended; attempt.apsContainerUrl = documents.outerUrl; attempt.directFrame = frame; + if (!acquireOverlayPosition(attempt)) return fail(attempt, 'slot_unresolved'); attempt.cycle.element.appendChild(frame); const intendedWindow = frame.contentWindow; if (!intendedWindow) return fail(attempt, 'renderer_document_no_load'); attempt.bootstrapSource = intendedWindow; ownerInserted(attempt); - return exactDirectApsFrame(attempt, false) || fail(attempt, 'renderer_document_no_load'); + return exactApsFrame(attempt, false) || fail(attempt, 'renderer_document_no_load'); } catch { return fail(attempt, 'renderer_document_no_load'); } @@ -1431,7 +1494,7 @@ export function createFirstDisplayRenderBridge( } return attempt.cycle.bid.renderSource.type === 'adm' ? renderDirectAdm(attempt) - : renderDirectAps(attempt); + : renderAps(attempt); }; const dispatch = (event: unknown): void => { @@ -1535,9 +1598,13 @@ export function createFirstDisplayRenderBridge( documentTimer: undefined, documentTransferred: undefined, gam: undefined, + hostPositionOwned: false, inserted: false, insertionTimer: undefined, ownerTicket: undefined, + overlay: false, + previousHostPosition: '', + previousHostPositionPriority: '', rendererNonce: undefined, onTerminal, ownerSource: undefined, diff --git a/crates/trusted-server-js/lib/src/integrations/aps/module.ts b/crates/trusted-server-js/lib/src/integrations/aps/module.ts index be9b30336..dfaaeefb5 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/module.ts @@ -11,8 +11,14 @@ import type { RenderAttempt, } from '../../services/render'; import { validatePersistentFirstDisplaySliceAdoptionV1 } from '../../shared/takeover'; +import type { PucApsMountInput } from '../../services/puc_bridge'; -import { renderDirectApsAttempt, resolveApsRendererV1Url, validateApsRenderer } from './render'; +import { + renderDirectApsAttempt, + renderPucApsAttempt, + resolveApsRendererV1Url, + validateApsRenderer, +} from './render'; interface RenderCapability { readonly bootstrapNonces: BootstrapNonceRegistry; @@ -76,7 +82,16 @@ export function createApsIntegrationRegistration(releaseId: string): Integration nonces: render.rendererNonces, publisherOrigin: render.publisherOrigin, }); - const apsCapability = Object.freeze({ render: renderer }); + const renderPuc = (input: PucApsMountInput): boolean => + active && + renderPucApsAttempt({ + ...input, + bootstrapNonces: render.bootstrapNonces, + messaging: messages.messaging, + nonces: render.rendererNonces, + publisherOrigin: render.publisherOrigin, + }); + const apsCapability = Object.freeze({ render: renderer, renderPuc }); const validation = Object.freeze({ expectedPublisherOrigin: render.publisherOrigin, expectedRendererUrl: rendererUrl, 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 37559d53f..4f0caf97b 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -106,6 +106,16 @@ export interface DirectApsAttemptOptions { readonly publisherOrigin: string; } +export interface PucApsAttemptOptions extends DirectApsAttemptOptions { + readonly baseArtifact: CommittedRenderArtifact; + readonly isBindingCurrent: () => boolean; + readonly onArtifactTransferred: () => void; +} + +type ApsTopPageAttemptOptions = + | (DirectApsAttemptOptions & Readonly<{ mode: 'direct' }>) + | (PucApsAttemptOptions & Readonly<{ mode: 'puc_overlay' }>); + function freeze(value: Value): Readonly { return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; } @@ -208,8 +218,8 @@ function readNonceIssueResult( } } -/** Drive one direct APS attempt through the three-phase top-page mount protocol. */ -export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolean { +/** Drive one APS attempt through the shared three-phase top-page mount protocol. */ +export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boolean { if ( !directRenderDocument || !directIframePrototype || @@ -247,6 +257,10 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea let attemptGeneration: object; let navigationGeneration: object; let ownerDocument: Document; + let mode: ApsTopPageAttemptOptions['mode']; + let baseArtifact: CommittedRenderArtifact | undefined; + let isBindingCurrent: () => boolean; + let onArtifactTransferred: () => void; try { attempt = options.attempt; bootstrapNonces = options.bootstrapNonces; @@ -260,6 +274,11 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea attemptGeneration = attempt.generation; navigationGeneration = attempt.navigationGeneration; ownerDocument = Reflect.apply(nodeOwnerDocumentGetter, container, []) as Document; + mode = options.mode; + baseArtifact = options.mode === 'puc_overlay' ? options.baseArtifact : undefined; + isBindingCurrent = options.mode === 'puc_overlay' ? options.isBindingCurrent : () => true; + onArtifactTransferred = + options.mode === 'puc_overlay' ? options.onArtifactTransferred : () => undefined; } catch { return false; } @@ -327,13 +346,15 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea typeof issueRendererMethod !== 'function' || typeof bindRendererMethod !== 'function' || typeof consumeRendererMethod !== 'function' || - typeof beginDirectMethod !== 'function' || typeof beginDocumentMethod !== 'function' || typeof documentAcceptedMethod !== 'function' || typeof acceptMethod !== 'function' || typeof failMethod !== 'function' || typeof snapshotMethod !== 'function' || - Reflect.apply(beginDirectMethod, attempt, []) !== true + (mode === 'direct' + ? typeof beginDirectMethod !== 'function' || + Reflect.apply(beginDirectMethod, attempt, []) !== true + : attempt.snapshot().state !== 'waiting_for_insertion' || !isBindingCurrent()) ) { return false; } @@ -362,7 +383,8 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea attempt.id === attemptId && attempt.slot === attemptSlot && attempt.generation === attemptGeneration && - attempt.navigationGeneration === navigationGeneration + attempt.navigationGeneration === navigationGeneration && + (mode === 'direct' || isBindingCurrent()) ); } catch { return false; @@ -428,11 +450,17 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea ['sandbox', APS_RENDERER_SANDBOX], [ 'style', - 'border: 0; display: block; height: ' + - String(renderer.height) + - 'px; margin: 0; overflow: hidden; width: ' + - String(renderer.width) + - 'px', + mode === 'puc_overlay' + ? 'border: 0; display: block; height: ' + + String(renderer.height) + + 'px; inset: 0; margin: 0; overflow: hidden; position: absolute; visibility: hidden; width: ' + + String(renderer.width) + + 'px; z-index: 2147483647' + : 'border: 0; display: block; height: ' + + String(renderer.height) + + 'px; margin: 0; overflow: hidden; width: ' + + String(renderer.width) + + 'px', ], ] as const; for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex += 1) { @@ -459,8 +487,41 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea let documentPort: MessagingPort | undefined; let releaseGlobalListener: (() => void) | undefined; let insertionPredecessors: readonly Element[] = []; + let baseArtifactDisposed = false; + let baseArtifactTransferred = false; + let hostPositionOwned = false; + let previousHostPosition = ''; + let previousHostPositionPriority = ''; const expectedFrameSource = rendererUrl + '#' + bootstrapNonce; + const disposeBaseArtifact = (): void => { + if (mode !== 'puc_overlay' || !baseArtifactTransferred || baseArtifactDisposed) return; + baseArtifactDisposed = true; + try { + baseArtifact?.dispose(); + } catch { + // The combined artifact remains terminal even when prior cleanup is hostile. + } + }; + + const restoreHostPosition = (): void => { + if (!hostPositionOwned) return; + hostPositionOwned = false; + try { + const style = container.style; + if ( + style.getPropertyValue('position') !== 'relative' || + style.getPropertyPriority('position') !== '' + ) { + return; + } + if (previousHostPosition === '') style.removeProperty('position'); + else style.setProperty('position', previousHostPosition, previousHostPositionPriority); + } catch { + // Compare-owned style restoration cannot expand into publisher styles. + } + }; + const bootstrapListenerResource = freeze({ close: (): void => { const release = releaseGlobalListener; @@ -483,7 +544,7 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea }; const artifact: CommittedRenderArtifact = freeze({ - kind: 'direct_iframe' as const, + kind: mode === 'direct' ? ('direct_iframe' as const) : ('puc' as const), attemptId, slot: attemptSlot, navigationGeneration, @@ -502,6 +563,8 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea } catch { // DOM removal remains best-effort under a hostile publisher realm. } + restoreHostPosition(); + disposeBaseArtifact(); }, }); @@ -544,6 +607,27 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea } }; + const acquireOverlayPosition = (): boolean => { + if (mode !== 'puc_overlay') return true; + try { + if (!isBindingCurrent()) return false; + const view = ownerDocument.defaultView; + if (!view) return false; + const computed = view.getComputedStyle(container); + if (computed.position !== 'static') return true; + const style = container.style; + previousHostPosition = style.getPropertyValue('position'); + previousHostPositionPriority = style.getPropertyPriority('position'); + style.setProperty('position', 'relative'); + hostPositionOwned = + style.getPropertyValue('position') === 'relative' && + style.getPropertyPriority('position') === ''; + return hostPositionOwned && isBindingCurrent(); + } catch { + return false; + } + }; + const exactFrameBinding = (permanent: boolean): boolean => { try { return ( @@ -639,13 +723,29 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea if (loaded?.['nonce'] === rendererNonce) return; const completed = Reflect.apply(parseMessageMethod, messaging, ['apsRenderCompleted', data]); if (completed?.['nonce'] === rendererNonce) { + if (!documentAccepted) return; + if (mode === 'puc_overlay') { + try { + if (!exactFrameBinding(true) || !isBindingCurrent()) { + fail('slot_unresolved'); + return; + } + iframe.style.setProperty('visibility', 'visible'); + if (iframe.style.getPropertyValue('visibility') !== 'visible') { + fail('internal_error'); + return; + } + } catch { + fail('internal_error'); + return; + } + } if ( - documentAccepted && Reflect.apply(acceptMethod, attempt, []) === true && !disposed && exactFrameBinding(true) ) { - commitContainer(insertionPredecessors); + if (mode === 'direct') commitContainer(insertionPredecessors); } return; } @@ -786,6 +886,7 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea } try { + const initialState = mode === 'direct' ? 'rendering_direct' : 'waiting_for_insertion'; releaseGlobalListener = Reflect.apply(installCaptureMethod, messaging, [receiveGlobal]); if (!releaseGlobalListener) return startupFailure('renderer_document_no_load'); Reflect.apply(eventTargetAddListenerIntrinsic, iframe, ['error', onFrameError]); @@ -793,16 +894,17 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea if ( Reflect.apply(elementGetAttributeIntrinsic, iframe, ['src']) !== expectedFrameSource || Reflect.apply(iframeSourceGetter!, iframe, []) !== expectedFrameSource || - attemptState() !== 'rendering_direct' || + attemptState() !== initialState || Reflect.apply(nodeIsConnectedGetter, container, []) !== true ) { return startupFailure('renderer_document_no_load'); } - insertionPredecessors = snapshotContainerPredecessors(); + insertionPredecessors = mode === 'direct' ? snapshotContainerPredecessors() : []; if ( disposed || - attemptState() !== 'rendering_direct' || - Reflect.apply(nodeIsConnectedGetter, container, []) !== true + attemptState() !== initialState || + Reflect.apply(nodeIsConnectedGetter, container, []) !== true || + !acquireOverlayPosition() ) { return startupFailure('renderer_document_no_load'); } @@ -828,6 +930,14 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea return startupFailure('internal_error'); } artifactOwnedByAttempt = true; + if (mode === 'puc_overlay') { + try { + onArtifactTransferred(); + baseArtifactTransferred = true; + } catch { + return fail('internal_error'); + } + } if ( disposed || frameErrorObserved || @@ -841,3 +951,13 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea return startupFailure('renderer_document_no_load'); } } + +/** Drive one direct APS attempt through the shared top-page mount service. */ +export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolean { + return mountApsTopPageAttempt({ ...options, mode: 'direct' }); +} + +/** Drive one PUC APS attempt through a hidden top-page overlay. */ +export function renderPucApsAttempt(options: PucApsAttemptOptions): boolean { + return mountApsTopPageAttempt({ ...options, mode: 'puc_overlay' }); +} 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 3deabe5a3..41f0bf9a7 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -34,7 +34,6 @@ import type { RenderAttempt, RenderAttemptCreationResult, RenderFailureReason, - RendererNonceRegistry, SlotOperationCreationResult, SlotOperationOptions, } from '../../services/render'; @@ -42,6 +41,7 @@ import { resizeCollapsedPucShell } from '../../core/puc_shell'; import { createPucBridge, type PucBridge, + type PucBridgeOptions, type PucGamAttemptInput, } from '../../services/puc_bridge'; import type { ReservationService } from '../../services/reservations'; @@ -416,6 +416,10 @@ interface ProductionSlotsCapability { readonly attachPhysicalService: (service: SlotService) => () => void; } +interface ProductionApsCapability { + readonly renderPuc: NonNullable; +} + interface ProductionRenderCapability { readonly attachPucGamAttemptRegistrar: ( registrar: (input: PucGamAttemptInput) => boolean @@ -435,8 +439,6 @@ interface ProductionRenderCapability { candidate: unknown ) => boolean; readonly mintLifecycleTicket: () => IdentityGenerationResult; - readonly publisherOrigin: string; - readonly rendererNonces: RendererNonceRegistry; readonly renderWinner: (attempt: RenderAttempt) => boolean; readonly reservations: ReservationService; } @@ -1578,6 +1580,7 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg } const auction = exactCapability(context.interfaces, 'auction.v1'); const slotCapability = exactCapability(context.interfaces, 'slots.v1'); + const aps = exactCapability(context.interfaces, 'aps.v1'); const render = exactCapability(context.interfaces, 'render.v1'); const messages = exactCapability(context.interfaces, 'messages.v1'); const trace = exactCapability(context.interfaces, 'trace.v1'); @@ -1598,7 +1601,7 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg typeof render.commitPageBids !== 'function' || typeof render.mintLifecycleTicket !== 'function' || typeof render.renderWinner !== 'function' || - typeof render.publisherOrigin !== 'string' || + (aps !== undefined && typeof aps.renderPuc !== 'function') || typeof trace.observations?.publish !== 'function' ) { throw new TypeError('GPT capability graph is malformed'); @@ -1633,7 +1636,6 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg scope.onDispose(() => slots.dispose()); const targeting = createTargetingService(); scope.onDispose(() => targeting.dispose()); - const rendererUrl = new URL('/integrations/aps/renderer/v1', render.publisherOrigin).href; const startup = createGptStartup({ googletag, slots: () => slots }); const diagnosticsEnabled = gptDiagnosticsActive(runtime); const diagnosticsFacts = diagnosticsEnabled @@ -1864,12 +1866,11 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg const bridge = createPucBridge({ messaging: messages.messaging, mintLifecycleTicket: render.mintLifecycleTicket, + ...(aps ? { mountAps: aps.renderPuc } : {}), now: () => document.defaultView!.performance.now(), - publisherOrigin: render.publisherOrigin, - rendererNonces: render.rendererNonces, - rendererUrl, reservations: render.reservations, resizeCollapsedShell: resizeCollapsedPucShell, + slots, }); const ticketAdoption = adoptionCandidate === undefined diff --git a/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts b/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts index a5e2cbd74..663c497d5 100644 --- a/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts +++ b/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts @@ -1,14 +1,14 @@ /** Every protocol literal shared by the version-1 browser message channels. */ export const TSJS_MESSAGE_PROTOCOL_V1 = Object.freeze({ version: 1 as const, - rendererVersion: '3' as const, + rendererVersion: '4' as const, message: Object.freeze({ prebidRequest: 'Prebid Request' as const, prebidResponse: 'Prebid Response' as const, ownerRegister: 'TS Render Owner Register' as const, ownerRegistered: 'TS Render Owner Registered' as const, ownerRefused: 'TS Render Owner Refused' as const, - apsStart: 'TS APS Start' as const, + apsTopMountStarted: 'TS APS Top Mount Started' as const, admStart: 'TS ADM Start' as const, ownerInserted: 'TS Owner Inserted' as const, ownerSettled: 'TS Owner Settled' as const, diff --git a/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts b/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts index 9d354ddc8..5f7797054 100644 --- a/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts +++ b/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts @@ -12,8 +12,6 @@ function installPucDynamicOwner(): void { 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', @@ -318,56 +316,6 @@ function installPucDynamicOwner(): void { } return true; }; - const utf8Length = (value: string): number => new TextEncoder().encode(value).byteLength; - 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']; - 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 || - containsAsciiControl(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) => { let outer: Record | undefined; @@ -392,7 +340,7 @@ function installPucDynamicOwner(): void { !outer || !owner || outer['message'] !== 'Prebid Response' || - outer['rendererVersion'] !== '3' || + outer['rendererVersion'] !== '4' || typeof adId !== 'string' || !reservationPattern.test(adId) || owner['version'] !== 1 || @@ -413,11 +361,9 @@ function installPucDynamicOwner(): void { let helperDisposer: (() => void) | undefined; let ownerTimer: number | undefined; 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; const removeFrameHandlers = (): void => { @@ -485,9 +431,7 @@ function installPucDynamicOwner(): void { // One hostile handler setter cannot retain the remaining authority. } } - closePort(documentPort); closePort(controlPort); - documentPort = undefined; controlPort = undefined; ownerFrameCurrent = undefined; } finally { @@ -579,139 +523,12 @@ function installPucDynamicOwner(): void { 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 || - !parsedUrl || - !parsedPublisherOrigin || - !validApsRenderer(renderer, 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 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; - 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; - } - removeFrame(next); - }; - next.onload = () => { - if (settled || ownerFrameCurrent?.() !== true || !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 = intendedSource; - frame = next; - creativeWindow.document.body.appendChild(next); - intendedWindow = next.contentWindow; - 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 ports = eventPorts(event, 0); const dataValue = eventDataValue(event); const routedMessage = ownDataValue(dataValue, 'message'); const routedOutcome = ownDataValue(dataValue, 'outcome'); @@ -719,15 +536,13 @@ function installPucDynamicOwner(): void { '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'] - : []), + ...(routedMessage === 'TS ADM Start' + ? ['source'] + : routedMessage === 'TS Owner Settled' && routedOutcome !== undefined + ? routedOutcome === 'accepted' + ? ['outcome'] + : ['outcome', 'reason'] + : []), ]); if ( !message || @@ -750,20 +565,19 @@ function installPucDynamicOwner(): void { return; } if ( - message['message'] === 'TS APS Start' && + message['message'] === 'TS APS Top Mount Started' && ownerKind === 'aps' && - ports.length === 1 && + ports.length === 0 && !started ) { started = true; - insertAps(message, ports); return; } if (message['message'] === 'TS Owner Settled' && ports.length === 0) { if ( message['outcome'] === 'accepted' && - !localApsFailure && - ownerFrameCurrent?.() === true + started && + (ownerKind === 'aps' || ownerFrameCurrent?.() === true) ) { frameCommitted = true; finish(true, ''); 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 c385f598a..09d39050f 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -3,19 +3,13 @@ import { TSJS_MESSAGE_PROTOCOL_V1 } from '../kernel/contracts/message_protocol'; import { PUC_DYNAMIC_OWNER } from '../kernel/contracts/puc_dynamic_owner'; import type { IdentityGenerationResult } from '../kernel/identity'; -interface CollapsedPucShellResizeInput { - readonly source: object; - readonly width: number; - readonly height: number; -} - import type { CommittedRenderArtifact, RenderAttempt, RenderFailureReason, RenderOutcome, - RendererNonceRegistry, } from './render'; +import type { SlotService } from './slots'; import type { ReservationAttempt, ReservationRecognition, @@ -25,6 +19,12 @@ import type { export { PUC_DYNAMIC_OWNER }; +interface CollapsedPucShellResizeInput { + readonly source: object; + readonly width: number; + readonly height: number; +} + const CLAIM_DEADLINE_MS = 3_000; const LIFECYCLE_TICKET_TTL_MS = 3_000; const MAX_DYNAMIC_OWNER_BYTES = 64 * 1_024; @@ -100,14 +100,22 @@ export interface PucBridgeOptions { readonly reservations: Pick & Partial>; readonly mintLifecycleTicket: () => IdentityGenerationResult; + readonly mountAps?: (input: PucApsMountInput) => boolean; readonly now?: () => number; - readonly publisherOrigin?: string; readonly resizeCollapsedShell?: (input: CollapsedPucShellResizeInput) => boolean; - readonly rendererNonces?: Pick; - readonly rendererUrl?: string; + readonly slots?: Pick; readonly scheduler?: PucBridgeScheduler; } +export interface PucApsMountInput { + readonly attempt: RenderAttempt; + readonly baseArtifact: CommittedRenderArtifact; + readonly container: HTMLElement; + readonly isBindingCurrent: () => boolean; + readonly messaging: MessagingAdapter; + readonly onArtifactTransferred: () => void; +} + export interface PucBridgeInventory { readonly attempts: number; readonly disposed: boolean; @@ -146,17 +154,9 @@ interface GamAttemptBinding { 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; @@ -266,56 +266,6 @@ function readMintedTicket(value: unknown): string | 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 @@ -501,9 +451,8 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { let reservations: PucBridgeOptions['reservations']; let mintLifecycleTicket: () => IdentityGenerationResult; let nowSource: () => number; - let publisherOrigin: string | undefined; - let rendererNonces: PucBridgeOptions['rendererNonces']; - let rendererUrl: string | undefined; + let mountAps: PucBridgeOptions['mountAps']; + let slots: PucBridgeOptions['slots']; let resizeCollapsedShell: PucBridgeOptions['resizeCollapsedShell']; let scheduler: PucBridgeScheduler; try { @@ -511,9 +460,8 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { reservations = options.reservations; mintLifecycleTicket = options.mintLifecycleTicket; nowSource = options.now ?? defaultNow; - publisherOrigin = options.publisherOrigin; - rendererNonces = options.rendererNonces; - rendererUrl = options.rendererUrl; + mountAps = options.mountAps; + slots = options.slots; resizeCollapsedShell = options.resizeCollapsedShell; scheduler = options.scheduler ?? defaultScheduler(); } catch { @@ -521,9 +469,8 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { reservations = options.reservations; mintLifecycleTicket = () => frozen({ ok: false, reason: 'identity_generation_failed' }); nowSource = () => Number.NaN; - publisherOrigin = undefined; - rendererNonces = undefined; - rendererUrl = undefined; + mountAps = undefined; + slots = undefined; resizeCollapsedShell = undefined; scheduler = defaultScheduler(); } @@ -752,27 +699,10 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { // 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); @@ -1190,259 +1120,64 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { 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) { + if (!controlPort || binding.controlStarted || !binding.active || !mountAps || !slots) { failBinding(binding, 'internal_error', false); return false; } - if ( - !binding.active || - binding.controlPort !== controlPort || - !currentBindingState(binding, 'waiting_for_insertion') - ) { - closeChannel(documentChannel); + const mountBinding = slots.resolveApsMountBinding( + binding.navigationGeneration, + binding.slot, + binding.attemptId + ); + if (!mountBinding || !mountBinding.isCurrent()) { + failBinding(binding, 'slot_unresolved', false); 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 receiveUnexpected = (event: unknown): void => { + const ports = messaging.inspectTransferredPorts(event)?.ports ?? []; + for (let index = 0; index < ports.length; index += 1) { + const port = ports[index]; + if (port) closePort(port); } - 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; + failBinding(binding, 'internal_error', false); + }; try { - source = binding.attempt.renderSource; + binding.controlListenerDispose = controlPort.listen(receiveUnexpected, () => + failBinding(binding, 'internal_error', false) + ); + binding.controlStarted = true; } catch { - source = undefined; + failBinding(binding, 'internal_error', false); + return false; } - const start = messaging.parseProtocolMessage('apsStart', { - message: TSJS_MESSAGE_PROTOCOL_V1.message.apsStart, + const start = messaging.parseProtocolMessage('apsTopMountStarted', { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsTopMountStarted, version: 1, lifecycleTicket, - rendererUrl, - envelope: { - version: 1, - nonce, - publisherOrigin, - renderer: source, - }, }); if (!start) { - failBinding(binding, 'winner_not_renderable', false); + failBinding(binding, 'internal_error', 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) { - if (binding.documentTerminalPending === undefined) { - 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) { - if (binding.documentTerminalPending === undefined) { - 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) { + const mounted = Reflect.apply(mountAps, undefined, [ + { + attempt: binding.attempt as unknown as RenderAttempt, + baseArtifact: binding.artifact, + container: mountBinding.element as HTMLElement, + isBindingCurrent: mountBinding.isCurrent, + messaging, + onArtifactTransferred: () => { + binding.artifactOwned = false; + binding.ownerInserted = true; + }, + }, + ]) as boolean; + if (!mounted || !binding.active || !binding.ownerInserted) return false; + if (controlPort.post(start, []) !== true) { failBinding(binding, 'internal_error', false); return false; } - closePort(documentChannel.transferred); return binding.active; } catch { failBinding(binding, 'internal_error', false); @@ -1781,17 +1516,9 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { 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, diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 901c114ed..42f86c917 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -140,6 +140,14 @@ export interface SlotServiceInventory { readonly records: number; } +export interface ApsSlotMountBinding { + readonly bindingEpoch: object; + readonly cycle: Readonly; + readonly element: object; + readonly physicalSlot: object; + readonly isCurrent: () => boolean; +} + /** Runtime-owned slot registry and physical-cycle boundary. */ export interface SlotService { readonly activate: () => void; @@ -163,6 +171,11 @@ export interface SlotService { registeredSlotId: string, slot: object ) => boolean; + readonly resolveApsMountBinding: ( + navigationGeneration: object, + registeredSlotId: string, + intentId: string + ) => Readonly | undefined; readonly prepareProjectionSlots: ( owner: NavigationSession, slots: readonly ProjectionSlotRegistration[] @@ -256,7 +269,9 @@ interface PhysicalCycle { interface PhysicalSlot { activeCycle: PhysicalCycle | undefined; + apsMountClaim: Readonly<{ cycle: PhysicalCycle; token: object }> | undefined; artifactRetirementAttempted: boolean; + bindingEpoch: object; definition: GoogletagReplacementDefinition | undefined; domElement: object | undefined; elementIdPrefix: string | undefined; @@ -828,9 +843,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const reconciliationElementIds = ( record: InternalSlotRecord, - definition: GoogletagReplacementDefinition + definition: GoogletagReplacementDefinition | undefined ): readonly string[] => { - const values: string[] = [definition.elementId]; + const values: string[] = definition ? [definition.elementId] : []; for (let aliasIndex = 0; aliasIndex < record.view.domAliases.length; aliasIndex += 1) { const alias = record.view.domAliases[aliasIndex]; if (alias === undefined) continue; @@ -848,7 +863,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const resolveReconciliationElement = ( record: InternalSlotRecord, - definition: GoogletagReplacementDefinition + definition: GoogletagReplacementDefinition | undefined ): SlotReconciliationResolution | undefined => { if (!reconciliationBoundary || !reconciliationResolve) return undefined; try { @@ -1045,7 +1060,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const replacementTraceToken = traceTokenFor(replacement); const physical: PhysicalSlot = { activeCycle: undefined, + apsMountClaim: undefined, artifactRetirementAttempted: false, + bindingEpoch: Object.freeze({}), definition, domElement, elementIdPrefix: oldPhysical.elementIdPrefix, @@ -1113,7 +1130,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!orphanedSlot || orphanedSlot === source.slot) return; const orphan: PhysicalSlot = { activeCycle: undefined, + apsMountClaim: undefined, artifactRetirementAttempted: true, + bindingEpoch: Object.freeze({}), definition: source.definition, domElement: undefined, elementIdPrefix: source.elementIdPrefix, @@ -2277,10 +2296,7 @@ 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 initialResolution = resolveReconciliationElement(record, definition); const domElement = initialResolution?.status === 'unique' ? initialResolution.element : undefined; const slotObject = slot as object; @@ -2318,6 +2334,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const previousDefinition = existing.definition; const previousDomElement = existing.domElement; const previousElementIdPrefix = existing.elementIdPrefix; + const previousBindingEpoch = existing.bindingEpoch; const previousPlacementKeys = existing.placementKeys; const previousPublisherElementIds = existing.publisherElementIds; const previousView = record.view; @@ -2331,6 +2348,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { existing.definition = definition; existing.domElement = domElement; existing.elementIdPrefix = elementIdPrefix; + existing.bindingEpoch = Object.freeze({}); existing.placementKeys = bindingPlacementKeys; existing.publisherElementIds = ownership === 'publisher' && definition @@ -2347,6 +2365,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { existing.definition = previousDefinition; existing.domElement = previousDomElement; existing.elementIdPrefix = previousElementIdPrefix; + existing.bindingEpoch = previousBindingEpoch; existing.placementKeys = previousPlacementKeys; existing.publisherElementIds = previousPublisherElementIds; record.view = previousView; @@ -2359,7 +2378,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } const physical: PhysicalSlot = { activeCycle: undefined, + apsMountClaim: undefined, artifactRetirementAttempted: false, + bindingEpoch: Object.freeze({}), definition, domElement, elementIdPrefix, @@ -3093,6 +3114,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } physical.ownership = 'publisher'; physical.publisherElementIds = aliases; + physical.bindingEpoch = Object.freeze({}); physical.suppressPublisherDisplay = true; physical.suppressPublisherRefresh = initialLoadDisabled === true; return Object.freeze({ action: 'handoff', slot: physical.slot }); @@ -3225,6 +3247,124 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; }; + const resolveApsMountBinding = ( + navigationGeneration: object, + registeredSlotId: string, + intentId: string + ): Readonly | undefined => { + try { + const state = mapValue(navigationStates, navigationGeneration); + const record = state ? mapValue(state.records, registeredSlotId) : undefined; + const physical = record?.physical; + const cycle = physical?.lastCycle; + const intent = cycle?.intent; + if ( + !state || + state.disposed || + !state.owner.isCurrent() || + !record || + !physical || + physical.record !== record || + physical.state !== 'live' || + !cycle || + cycle.kind !== 'trusted_server' || + cycle.traceRetirement.retired || + !intent || + intent.input.intentId !== intentId || + intent.input.registeredSlotId !== registeredSlotId || + intent.input.navigationGeneration !== navigationGeneration || + intent.input.expectedSlot !== physical.slot || + !intent.terminal || + !reconciliationBoundary || + !reconciliationResolve || + !reconciliationIsConnected + ) { + return undefined; + } + + const ids: string[] = []; + const addId = (value: string | undefined): void => { + if (!value) return; + for (let index = 0; index < ids.length; index += 1) { + if (ids[index] === value) return; + } + ids[ids.length] = value; + }; + addId(physical.definition?.elementId); + for (let index = 0; index < physical.publisherElementIds.length; index += 1) { + addId(physical.publisherElementIds[index]); + } + for (let index = 0; index < record.view.domAliases.length; index += 1) { + addId(record.view.domAliases[index]); + } + if (ids.length === 0) return undefined; + + const resolution = Reflect.apply(reconciliationResolve, reconciliationBoundary, [ + Object.freeze(ids), + ]) as SlotReconciliationResolution; + if ( + resolution.status !== 'unique' || + !Reflect.apply(reconciliationIsConnected, reconciliationBoundary, [resolution.element]) || + physical.domElement === undefined || + physical.domElement !== resolution.element || + !Reflect.apply(reconciliationIsConnected, reconciliationBoundary, [physical.domElement]) + ) { + return undefined; + } + + const existingClaim = physical.apsMountClaim; + if (existingClaim && !existingClaim.cycle.traceRetirement.retired) return undefined; + const token = Object.freeze({}); + const bindingEpoch = physical.bindingEpoch; + physical.apsMountClaim = Object.freeze({ cycle, token }); + const current = (): boolean => { + try { + if ( + state.disposed || + !state.owner.isCurrent() || + mapValue(state.records, registeredSlotId) !== record || + record.physical !== physical || + physical.record !== record || + physical.state !== 'live' || + intent.input.expectedSlot !== physical.slot || + physical.bindingEpoch !== bindingEpoch || + physical.lastCycle !== cycle || + cycle.traceRetirement.retired || + physical.apsMountClaim?.cycle !== cycle || + physical.apsMountClaim.token !== token + ) { + return false; + } + const revalidated = Reflect.apply(reconciliationResolve, reconciliationBoundary, [ + Object.freeze([...ids]), + ]) as SlotReconciliationResolution; + return ( + revalidated.status === 'unique' && + revalidated.element === resolution.element && + Reflect.apply(reconciliationIsConnected, reconciliationBoundary, [ + resolution.element, + ]) === true + ); + } catch { + return false; + } + }; + if (!current()) { + if (physical.apsMountClaim?.token === token) physical.apsMountClaim = undefined; + return undefined; + } + return Object.freeze({ + bindingEpoch, + cycle: cycle.traceHandle, + element: resolution.element, + physicalSlot: physical.slot, + isCurrent: current, + }); + } catch { + return undefined; + } + }; + const service: SlotService = Object.freeze({ activate: (): void => { if (reconciliationActive) return; @@ -3295,6 +3435,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return false; } }, + resolveApsMountBinding, prepareProjectionSlots: ( owner: NavigationSession, slots: readonly ProjectionSlotRegistration[] 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 22b96fa91..357121707 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -679,14 +679,14 @@ describe('browser messaging adapter', () => { it('centralizes every protocol literal and exact message shape as frozen data', () => { expect(TSJS_MESSAGE_PROTOCOL_V1).toEqual({ version: 1, - rendererVersion: '3', + rendererVersion: '4', message: { prebidRequest: 'Prebid Request', prebidResponse: 'Prebid Response', ownerRegister: 'TS Render Owner Register', ownerRegistered: 'TS Render Owner Registered', ownerRefused: 'TS Render Owner Refused', - apsStart: 'TS APS Start', + apsTopMountStarted: 'TS APS Top Mount Started', admStart: 'TS ADM Start', ownerInserted: 'TS Owner Inserted', ownerSettled: 'TS Owner Settled', @@ -719,7 +719,7 @@ describe('browser messaging adapter', () => { expect(Object.isFrozen(TSJS_MESSAGE_PROTOCOL_V1)).toBe(true); expect(Object.isFrozen(TSJS_MESSAGE_PROTOCOL_V1.message)).toBe(true); expect(Object.isFrozen(PROTOCOL_MESSAGE_SCHEMAS_V1)).toBe(true); - expect(Object.isFrozen(PROTOCOL_MESSAGE_SCHEMAS_V1.apsStart.keys)).toBe(true); + expect(Object.isFrozen(PROTOCOL_MESSAGE_SCHEMAS_V1.apsTopMountStarted.keys)).toBe(true); }); it('parses global JSON and structured messages through exact descriptor-safe schemas', () => { @@ -1070,44 +1070,20 @@ describe('browser messaging adapter', () => { ).toBeUndefined(); }); - it('fails APS start closed without exact generation expectations and semantic validation', () => { + it('accepts only the exact data-free APS top-mount notification', () => { const message = { - message: 'TS APS Start', + message: 'TS APS Top Mount Started', version: 1, lifecycleTicket: 't1_abcdefghijklmnopqrstuv', - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', - envelope: { - version: 1, - nonce: 'n1_abcdefghijklmnopqrstuv', - publisherOrigin: 'https://publisher.example', - renderer: Object.freeze(createApsRenderer()), - }, }; + const adapter = createBrowserMessagingAdapter(createTarget()); + expect(adapter.parseProtocolMessage('apsTopMountStarted', message)).toEqual(message); expect( - createBrowserMessagingAdapter(createTarget()).parseProtocolMessage('apsStart', message) - ).toBeUndefined(); - - const adapter = createBrowserMessagingAdapter(createTarget(), { - expectedPublisherOrigin: 'https://publisher.example', - expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', - validateApsRenderer: () => true, - }); - expect(adapter.parseProtocolMessage('apsStart', message)).toBeDefined(); - expect( - adapter.parseProtocolMessage('apsStart', { + adapter.parseProtocolMessage('apsTopMountStarted', { ...message, - envelope: { ...message.envelope, publisherOrigin: 'https://wrong.example' }, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', }) ).toBeUndefined(); - const throwing = createBrowserMessagingAdapter(createTarget(), { - expectedPublisherOrigin: 'https://publisher.example', - expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', - validateApsRenderer: () => { - throw new Error('validator failed'); - }, - }); - expect(() => throwing.parseProtocolMessage('apsStart', message)).not.toThrow(); - expect(throwing.parseProtocolMessage('apsStart', message)).toBeUndefined(); }); it('canonicalizes an exact APS renderer before invoking the semantic validator', () => { @@ -1123,17 +1099,11 @@ describe('browser messaging adapter', () => { expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', validateApsRenderer: validator, }); - const parsed = adapter.parseProtocolMessage('apsStart', { - message: 'TS APS Start', + const parsed = adapter.parseProtocolMessage('apsEnvelope', { version: 1, - lifecycleTicket: 't1_abcdefghijklmnopqrstuv', - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', - envelope: { - version: 1, - nonce: 'n1_abcdefghijklmnopqrstuv', - publisherOrigin: 'https://publisher.example', - renderer, - }, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer, }); expect(validator).toHaveBeenCalledTimes(1); @@ -1141,9 +1111,7 @@ describe('browser messaging adapter', () => { expect(Object.getPrototypeOf(canonical)).toBeNull(); expect(Object.isFrozen(canonical)).toBe(true); expect((canonical as Record)['bidId']).toBe('bid-1'); - expect( - (parsed?.['envelope'] as Readonly> | undefined)?.['renderer'] - ).toBe(canonical); + expect(parsed?.['renderer']).toBe(canonical); }); it('rejects APS renderer accessors, proxies, and unknown keys before validation', () => { @@ -1177,38 +1145,6 @@ describe('browser messaging adapter', () => { expect(validator).not.toHaveBeenCalled(); }); - it('validates both renderer URL expectations and candidates before exact equality', () => { - const invalidUrls = [ - '/integrations/aps/renderer/v1', - 'ftp://publisher.example/integrations/aps/renderer/v1', - 'https://user@publisher.example/integrations/aps/renderer/v1', - 'https://publisher.example/integrations/aps/renderer/v1?query=1', - 'https://publisher.example/integrations/aps/renderer/v1#fragment', - 'https://publisher.example/wrong-path', - ]; - for (const invalidUrl of invalidUrls) { - const adapter = createBrowserMessagingAdapter(createTarget(), { - expectedPublisherOrigin: 'https://publisher.example', - expectedRendererUrl: invalidUrl, - validateApsRenderer: () => true, - }); - expect( - adapter.parseProtocolMessage('apsStart', { - message: 'TS APS Start', - version: 1, - lifecycleTicket: 't1_abcdefghijklmnopqrstuv', - rendererUrl: invalidUrl, - envelope: { - version: 1, - nonce: 'n1_abcdefghijklmnopqrstuv', - publisherOrigin: 'https://publisher.example', - renderer: createApsRenderer(), - }, - }) - ).toBeUndefined(); - } - }); - it('returns canonical frozen nested records without invoking prototype serialization hooks', () => { const adapter = createBrowserMessagingAdapter(createTarget()); const owner = { @@ -1226,7 +1162,7 @@ describe('browser messaging adapter', () => { message: 'Prebid Response', adId: 'r1_abcdefghijklmnopqrstuv', renderer: 'renderer program', - rendererVersion: '3', + rendererVersion: '4', tsOwner: owner, }); expect(parsed).toBeDefined(); @@ -1245,7 +1181,7 @@ describe('browser messaging adapter', () => { const refused = { message: 'Prebid Response', adId: 'r1_abcdefghijklmnopqrstuv', - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'refused' }, }; expect(adapter.parseProtocolMessage('prebidResponseRefused', refused)).toBeDefined(); 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 4364adc20..f0ed3446a 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1017,27 +1017,24 @@ describe('browser composition', () => { aaxResponse: 'renderer-envelope', }; const message = { - message: 'TS APS Start', + message: 'TS APS Top Mount Started', version: 1, lifecycleTicket: 't1_abcdefghijklmnopqrstuv', - rendererUrl: new URL('/integrations/aps/renderer/v1', window.location.origin).href, - envelope: { - version: 1, - nonce: 'n1_abcdefghijklmnopqrstuv', - publisherOrigin: window.location.origin, - renderer, - }, }; - const validation = { validateApsRenderer: () => true }; + const validation = { validateApsRenderer: () => renderer !== undefined }; const browser = createBrowserComposition({ messagingValidation: validation }); - expect(browser.adapters.messaging.parseProtocolMessage('apsStart', message)).toBeDefined(); + expect( + browser.adapters.messaging.parseProtocolMessage('apsTopMountStarted', message) + ).toBeDefined(); const injected = createBrowserComposition({ target: createTarget(), messagingValidation: validation, }); - expect(injected.adapters.messaging.parseProtocolMessage('apsStart', message)).toBeUndefined(); + expect( + injected.adapters.messaging.parseProtocolMessage('apsTopMountStarted', message) + ).toBeDefined(); }); it('installs the capture-phase message listener synchronously and disposes once', () => { diff --git a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts index 81ff95e62..1496b3a0e 100644 --- a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts @@ -31,6 +31,7 @@ function harness() { }), }), element, + isCurrent: () => true, ownership: 'trusted_server', physicalSlot: {}, placement: Object.freeze({ diff --git a/crates/trusted-server-js/lib/test/first_display/agent.test.ts b/crates/trusted-server-js/lib/test/first_display/agent.test.ts index 63b746ccb..12f21a467 100644 --- a/crates/trusted-server-js/lib/test/first_display/agent.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/agent.test.ts @@ -610,6 +610,7 @@ describe('bounded first-display agent', () => { Object.freeze({ bid: projectedBatch.projection.bids[0]!, element, + isCurrent: () => true, ownership: 'trusted_server' as const, physicalSlot, placement: projectedBatch.projection.slots[0]!, diff --git a/crates/trusted-server-js/lib/test/first_display/driver.test.ts b/crates/trusted-server-js/lib/test/first_display/driver.test.ts index 6da8db90f..62bd6a054 100644 --- a/crates/trusted-server-js/lib/test/first_display/driver.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/driver.test.ts @@ -144,6 +144,7 @@ function harness() { const cycle = Object.freeze({ bid: value.projection.bids[0]!, element: document.createElement('div'), + isCurrent: () => true, ownership: 'trusted_server' as const, physicalSlot: {}, placement: value.projection.slots[0]!, diff --git a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts index 826be05fd..30ec37539 100644 --- a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts @@ -258,6 +258,71 @@ describe('first-display GPT adapter', () => { ]); }); + it('invalidates the delayed-owner binding when a publisher replaces the physical slot', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const listeners = new Map void>(); + const slot = { + addService: () => slot, + getSlotElementId: () => 'slot-1', + setTargeting: () => slot, + }; + const replacement = { + addService: () => replacement, + getSlotElementId: () => 'slot-1', + setTargeting: () => replacement, + }; + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [], + removeEventListener: () => undefined, + }; + const defineSlot = vi.fn().mockReturnValueOnce(slot).mockReturnValueOnce(replacement); + const destroySlots = vi.fn((_slots?: readonly object[]) => true); + const binding = { + cmd: { push: (command: () => void) => command() }, + defineSlot, + destroySlots, + display: (_elementId: string) => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: binding, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + let bound: { readonly isCurrent: () => boolean } | undefined; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + adapter.start({ + onBound: (cycle) => { + bound = cycle; + }, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + + expect(bound?.isCurrent()).toBe(true); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + expect(bound?.isCurrent()).toBe(true); + + expect(binding.destroySlots([slot])).toBe(true); + expect(binding.defineSlot('/publisher/replacement', [[300, 250]], 'slot-1')).toBe(replacement); + binding.display('slot-1'); + expect(bound?.isCurrent()).toBe(false); + }); + it('cancels a pending command and compare-restores an adapter-created GPT queue', () => { const dom = new JSDOM('
', { url: 'https://publisher.example/', diff --git a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts index c41359ef0..e5df5578a 100644 --- a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts @@ -79,16 +79,18 @@ function fixture(kind: 'adm' | 'aps' = 'adm') { formats: Object.freeze([Object.freeze([300, 250] as const)]), targeting: Object.freeze({}), }); + let cycleCurrent = true; const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze({ bid, element, ownership: 'trusted_server', physicalSlot: {}, + isCurrent: () => cycleCurrent, placement, slotId: 'slot-1', traceToken: 'gt1_1', }); - return { cycle, dom, element }; + return { cycle, dom, element, invalidateCycle: () => (cycleCurrent = false) }; } function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) { @@ -294,6 +296,49 @@ function registerOwner(h: ReturnType) { return { source, ticket }; } +function startApsDocument(h: ReturnType) { + const frame = h.element.querySelector('iframe'); + if (!frame?.contentWindow) throw new Error('expected the top-page APS frame'); + const bootstrapNonce = new URL(frame.src).hash.slice(1); + const postMessage = vi + .spyOn(frame.contentWindow, 'postMessage') + .mockImplementation(() => undefined); + h.dispatch({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: 'null', + ports: [], + source: frame.contentWindow, + }); + const navigation = JSON.parse(postMessage.mock.calls[0]?.[0] as string) as Record< + string, + unknown + >; + const decodedOuter = decodeURIComponent( + (navigation.containerUrl as string) + .slice('data:text/html;charset=utf-8,'.length) + .split('#')[0] ?? '' + ); + const nonce = /rendererNonce=(n1_[A-Za-z0-9_-]{22})/.exec(decodedOuter)?.[1]; + if (!nonce) throw new Error('expected the renderer nonce'); + const documentPort = new FakePort(); + h.dispatch({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce: nonce, + }), + origin: 'null', + ports: [documentPort], + source: frame.contentWindow, + }); + return { documentPort, frame, nonce }; +} + describe('bounded first-display render bridge', () => { it('observes admitted bridge activity and terminal tombstone expiry', () => { const mutations = vi.fn(() => true); @@ -339,7 +384,7 @@ describe('bounded first-display render bridge', () => { expect(JSON.parse(refusedPort.postMessage.mock.calls[0]?.[0] as string)).toEqual({ message: 'Prebid Response', adId: RESERVATION_ID, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'refused' }, }); expect(refusedPort.close).toHaveBeenCalledOnce(); @@ -388,7 +433,7 @@ describe('bounded first-display render bridge', () => { message: 'Prebid Response', adId: RESERVATION_ID, renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', kind: 'adm' }, }); const ticket = (outer.tsOwner as Record).lifecycleTicket; @@ -449,6 +494,25 @@ describe('bounded first-display render bridge', () => { }); }); + it('refuses delayed APS owner registration after a later physical GPT request', () => { + const h = harness('aps'); + const source = {}; + const responsePort = new FakePort(); + h.dispatch(requestEvent(responsePort, { source })); + expect(h.bridge.recordGam(h.cycle, 'nonempty_gam')).toBe(true); + const response = JSON.parse(responsePort.postMessage.mock.calls[0]?.[0] as string); + + h.invalidateCycle(); + const registrationPort = new FakePort(); + h.dispatch( + ownerRegistration(response.tsOwner.lifecycleTicket as string, registrationPort, source) + ); + + expect(h.terminals).toEqual(['failed']); + expect(h.terminalFacts).toEqual([['failed', 'slot_unresolved']]); + expect(h.element.querySelector('iframe')).toBeNull(); + }); + it('never resizes an anchored or already-expanded PUC shell', () => { const cases = [ { @@ -477,8 +541,9 @@ describe('bounded first-display render bridge', () => { } }); - it('requires APS document acceptance and callback completion after owner insertion', () => { + it('requires APS document acceptance and completion in a hidden top-page PUC overlay', () => { const h = harness('aps'); + h.element.innerHTML = 'publisher GAM content'; const source = {}; const responsePort = new FakePort(); h.dispatch(requestEvent(responsePort, { source })); @@ -489,43 +554,46 @@ describe('bounded first-display render bridge', () => { h.dispatch(ownerRegistration(ticket, registrationPort, source)); const start = h.channels[0]?.port1.postMessage.mock.calls[0]?.[0] as Record; - expect(start).toMatchObject({ - message: 'TS APS Start', + expect(start).toEqual({ + message: 'TS APS Top Mount Started', version: 1, lifecycleTicket: ticket, - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', - envelope: { - version: 1, - publisherOrigin: 'https://publisher.example', - renderer: h.cycle.bid.renderSource, - }, }); - const nonce = (start.envelope as Record).nonce as string; - expect(nonce).toMatch(/^n1_[A-Za-z0-9_-]{22}$/); - expect(h.channels[0]?.port1.postMessage.mock.calls[0]?.[1]).toEqual([h.channels[1]?.port2]); + expect(h.channels[0]?.port1.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(h.channels).toHaveLength(1); + expect(h.element.querySelector('span')?.textContent).toBe('publisher GAM content'); + const { documentPort, frame, nonce } = startApsDocument(h); + expect(frame.parentNode).toBe(h.element); + expect(frame.style.position).toBe('absolute'); + expect(frame.style.visibility).toBe('hidden'); + expect(h.element.style.position).toBe('relative'); - h.channels[1]?.port1.dispatch({ + documentPort.dispatch({ message: 'TS APS Document Accepted', version: 1, nonce, }); - h.channels[1]?.port1.dispatch({ + documentPort.dispatch({ message: 'TS APS Runner Loaded', version: 1, nonce, }); expect(h.terminals).toEqual([]); - h.channels[0]?.port1.dispatch({ - message: 'TS Owner Inserted', - version: 1, - lifecycleTicket: ticket, - }); - h.channels[1]?.port1.dispatch({ + expect(frame.style.visibility).toBe('hidden'); + documentPort.dispatch({ message: 'TS APS Render Completed', version: 1, nonce, }); expect(h.terminals).toEqual(['accepted']); + expect(frame.style.visibility).toBe('visible'); + expect(h.element.querySelector('span')?.textContent).toBe('publisher GAM content'); + expect(h.channels[0]?.port1.postMessage.mock.calls[1]?.[0]).toEqual({ + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: ticket, + outcome: 'accepted', + }); }); it('renders an attributable empty-GAM ADM fallback directly into the bound element', () => { @@ -731,29 +799,15 @@ describe('bounded first-display render bridge', () => { expect(adm.terminalFacts).toEqual([['failed', 'adm_document_no_load']]); const document = harness('aps'); - const documentOwner = registerOwner(document); - document.channels[0]?.port1.dispatch({ - message: 'TS Owner Inserted', - version: 1, - lifecycleTicket: documentOwner.ticket, - }); + registerOwner(document); document.fireLast(3_000); expect(document.terminals).toEqual(['failed']); expect(document.terminalFacts).toEqual([['failed', 'renderer_document_no_load']]); const completion = harness('aps'); - const completionOwner = registerOwner(completion); - const start = completion.channels[0]?.port1.postMessage.mock.calls[0]?.[0] as Record< - string, - unknown - >; - const nonce = (start.envelope as Record).nonce; - completion.channels[0]?.port1.dispatch({ - message: 'TS Owner Inserted', - version: 1, - lifecycleTicket: completionOwner.ticket, - }); - completion.channels[1]?.port1.dispatch({ + registerOwner(completion); + const { documentPort, nonce } = startApsDocument(completion); + documentPort.dispatch({ message: 'TS APS Document Accepted', version: 1, nonce, diff --git a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts index 75a0f4cba..e7c079a3e 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts @@ -7,6 +7,7 @@ import type { PreparedIntegration, } from '../../../src/kernel/integration_registry'; import type { RenderAttempt } from '../../../src/services/render'; +import type { PucApsMountInput } from '../../../src/services/puc_bridge'; const RELEASE_ID = 'a'.repeat(64); @@ -51,12 +52,14 @@ describe('APS provider', () => { ) as PreparedIntegration; const aps = prepared.interfaces?.['aps.v1'] as { render: (attempt: RenderAttempt, container: HTMLElement) => boolean; + renderPuc: (input: PucApsMountInput) => boolean; }; expect(Object.isFrozen(aps)).toBe(true); expect(render.registerRenderer).not.toHaveBeenCalled(); expect(messages.registerApsValidation).not.toHaveBeenCalled(); expect(aps.render({} as RenderAttempt, document.createElement('div'))).toBe(false); + expect(aps.renderPuc({} as PucApsMountInput)).toBe(false); prepared.activate( Object.freeze({ @@ -79,6 +82,7 @@ describe('APS provider', () => { expect(registeredRenderer).toBeUndefined(); expect(registeredValidation).toBeUndefined(); expect(aps.render({} as RenderAttempt, document.createElement('div'))).toBe(false); + expect(aps.renderPuc({} as PucApsMountInput)).toBe(false); preparationRelease.reverse().forEach((callback) => callback()); }); 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 9be107618..285c7f7e8 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,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; +import apsEnvelope from '../fixtures/aps-renderer-v1.json'; import { createBrowserMessagingAdapter } from '../../src/adapters/messaging'; +import { renderPucApsAttempt } from '../../src/integrations/aps/render'; import { createPucBridge, PUC_DYNAMIC_OWNER, @@ -50,29 +52,33 @@ interface HarnessOptions { readonly now?: PucBridgeOptions['now']; readonly publisherOrigin?: string; readonly resizeCollapsedShell?: PucBridgeOptions['resizeCollapsedShell']; - readonly rendererNonces?: PucBridgeOptions['rendererNonces']; - readonly rendererUrl?: string; + readonly mountAps?: PucBridgeOptions['mountAps']; readonly scheduler?: PucBridgeOptions['scheduler']; + readonly slots?: PucBridgeOptions['slots']; } function createHarness( recognize: (reservationId: unknown) => ReservationRecognition, options: HarnessOptions = {} ) { - let listener: ((event: MessageEvent) => void) | undefined; + const listeners: Array<(event: MessageEvent) => void> = []; const target = { addEventListener: vi.fn( (_type: 'message', next: (event: MessageEvent) => void, _capture: true) => { - listener = next; + listeners.push(next); + } + ), + removeEventListener: vi.fn( + (_type: 'message', removed: (event: MessageEvent) => void, _capture: true) => { + const index = listeners.indexOf(removed); + if (index >= 0) listeners.splice(index, 1); } ), - removeEventListener: vi.fn(), ...(options.messageChannel ? { MessageChannel: options.messageChannel } : {}), }; const bridgeOptions: PucBridgeOptions = { messaging: createBrowserMessagingAdapter(target, { ...(options.publisherOrigin ? { expectedPublisherOrigin: options.publisherOrigin } : {}), - ...(options.rendererUrl ? { expectedRendererUrl: options.rendererUrl } : {}), validateApsRenderer: () => true, }), mintLifecycleTicket: @@ -83,18 +89,19 @@ function createHarness( recognize, }, ...(options.now ? { now: options.now } : {}), - ...(options.publisherOrigin ? { publisherOrigin: options.publisherOrigin } : {}), ...(options.resizeCollapsedShell ? { resizeCollapsedShell: options.resizeCollapsedShell } : {}), - ...(options.rendererNonces ? { rendererNonces: options.rendererNonces } : {}), - ...(options.rendererUrl ? { rendererUrl: options.rendererUrl } : {}), + ...(options.mountAps ? { mountAps: options.mountAps } : {}), ...(options.scheduler ? { scheduler: options.scheduler } : {}), + ...(options.slots ? { slots: options.slots } : {}), }; 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); + if (listeners.length === 0) { + throw new Error('Expected the capture listener to be installed synchronously'); + } + for (const listener of [...listeners]) listener(event as unknown as MessageEvent); }; - return { bridge, dispatch, target }; + return { bridge, dispatch, listeners, target }; } function createGamAttempt(kind: 'aps' | 'adm' = 'aps', index = 0) { @@ -108,6 +115,7 @@ function createGamAttempt(kind: 'aps' | 'adm' = 'aps', index = 0) { let outcome: RenderOutcome | undefined; let renderSource: ReservationRenderSource | undefined; const settlementObservers: Array<(outcome: RenderOutcome) => void> = []; + const apsBid = apsEnvelope.seatbid[0]!.bid[0]!; const owner = Object.freeze({ id, slot: `slot-${index}`, @@ -145,12 +153,12 @@ function createGamAttempt(kind: 'aps' | 'adm' = 'aps', index = 0) { type: 'aps', version: 1, accountId: 'publisher-account', - bidId: 'bid-1', - tagType: 'iframe', - creativeUrl: 'https://creative.example/render', - width: 300, - height: 250, - aaxResponse: 'renderer-envelope', + bidId: apsBid.id, + tagType: apsBid.ext.tagtype, + creativeUrl: apsBid.ext.creativeurl, + width: apsBid.w, + height: apsBid.h, + aaxResponse: btoa(JSON.stringify(apsEnvelope)), } : { type: 'adm', @@ -342,44 +350,35 @@ describe('Universal Creative bridge dispatcher', () => { ).toBe(false); }); - it('installs owner iframe lifecycle handlers before assigning either document source', () => { + it('keeps APS out of the PUC realm while preserving ADM iframe ordering', () => { 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); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, 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(controlStart).toBeGreaterThan(admStart); 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;') - ); + expect(PUC_DYNAMIC_OWNER).not.toContain('const insertAps'); + expect(PUC_DYNAMIC_OWNER).not.toContain('TS APS Start'); + expect(PUC_DYNAMIC_OWNER).not.toContain('rendererUrl'); + expect(PUC_DYNAMIC_OWNER).not.toContain('aaxResponse'); + expect(PUC_DYNAMIC_OWNER).toContain('TS APS Top Mount Started'); }); - it('binds owner load and final acceptance to the exact inserted navigation', () => { + it('binds ADM 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); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, 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'); }); @@ -427,7 +426,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -551,7 +550,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -656,7 +655,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -779,7 +778,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -904,7 +903,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -996,7 +995,7 @@ describe('Universal Creative bridge dispatcher', () => { } }); - it('accepts the optional APS creative id and preserves no-referrer on the owner iframe', async () => { + it('keeps APS DOM ownership out of the PUC frame after a data-free top-mount signal', async () => { const dynamicWindow = window as unknown as { render?: ( data: Readonly>, @@ -1026,8 +1025,6 @@ describe('Universal Creative bridge dispatcher', () => { }, set onmessageerror(_listener: ((event: unknown) => void) | null) {}, }; - const documentPort = createPort(); - try { const rendered = dynamicWindow.render!( window.JSON.parse( @@ -1035,7 +1032,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -1058,34 +1055,14 @@ describe('Universal Creative bridge dispatcher', () => { }); controlListener?.({ data: { - message: 'TS APS Start', + message: 'TS APS Top Mount Started', 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], + ports: [], }); - const frame = document.body.querySelector('iframe'); - expect(frame).not.toBeNull(); - expect(frame?.getAttribute('referrerpolicy')).toBe('no-referrer'); + expect(document.body.querySelector('iframe')).toBeNull(); controlListener?.({ data: { message: 'TS Owner Settled', @@ -1096,13 +1073,14 @@ describe('Universal Creative bridge dispatcher', () => { ports: [], }); await expect(rendered).resolves.toBeUndefined(); + expect(controlPort.close).toHaveBeenCalledOnce(); } finally { delete dynamicWindow.render; document.body.innerHTML = ''; } }); - it('keeps APS ownership alive after a local frame error until the kernel settles failure', async () => { + it('refuses the removed data-bearing APS start and closes every transferred port', async () => { vi.useFakeTimers(); const dynamicWindow = window as unknown as { render?: ( @@ -1143,7 +1121,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -1190,30 +1168,8 @@ describe('Universal Creative bridge dispatcher', () => { 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(); + await expect(rendered).rejects.toThrow('TS render owner control refused'); 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(); @@ -1243,7 +1199,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -1303,7 +1259,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -1384,7 +1340,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -1450,132 +1406,90 @@ 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', - }, - { - 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(); - } - ); - 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', + { caseName: 'unexpected field', message: { unexpected: true }, ports: [] }, + { caseName: 'transferred port', message: {}, ports: [createPort()] }, + { caseName: 'wrong message', message: { message: 'TS APS Start' }, ports: [] }, + ])('refuses a malformed top-mount signal with an $caseName', async ({ message, ports }) => { + 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; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ adId: RESERVATION_ID, - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - }), - ports: [controlPort], - }); - controlListener?.({ - data: { - message: 'TS APS Start', - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - rendererUrl, - envelope: { + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { 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', - ...rendererOverrides, - }, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, }, - }, - 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 = ''; - } + }) + ) 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 Top Mount Started', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + ...message, + }, + ports, + }); + await expect(rendered).rejects.toThrow('TS render owner control refused'); + expect(document.body.querySelector('iframe')).toBeNull(); + for (const port of ports) expect(port.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 })); @@ -1660,7 +1574,7 @@ describe('Universal Creative bridge dispatcher', () => { expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0]))).toEqual({ message: 'Prebid Response', adId: RESERVATION_ID, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'refused' }, }); expect(port.postMessage.mock.calls[0]?.[1]).toEqual([]); @@ -1690,7 +1604,7 @@ describe('Universal Creative bridge dispatcher', () => { expect(JSON.parse(String(first.postMessage.mock.calls[0]?.[0]))).toEqual({ message: 'Prebid Response', adId: RESERVATION_ID, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'refused' }, }); expect(first.postMessage.mock.calls[0]?.[1]).toEqual([]); @@ -1947,7 +1861,7 @@ describe('Universal Creative bridge dispatcher', () => { message: 'Prebid Response', adId: RESERVATION_ID, renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -2816,27 +2730,38 @@ describe('Universal Creative bridge dispatcher', () => { expect(gam.artifact.dispose).toHaveBeenCalledOnce(); }); - it('sends exact APS start with one document port and accepts exact document completion', () => { + it('mounts APS in the exact top-page slot and sends only the data-free owner signal', () => { + document.body.innerHTML = + '
gam
'; 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: Parameters['issue']>[0]) => { - const port = input.port; - if (!port) throw new Error('should receive the PUC renderer port'); - expect(input.attempt.onSettled(() => port.close())).toBe(true); - return Object.freeze({ ok: true as const, nonce: 'n1_abcdefghijklmnopqrstuv' }); - } + const bootstrapNonce = `b1_${'b'.repeat(22)}`; + const rendererNonce = `n1_${'n'.repeat(22)}`; + const nonceRegistry = (nonce: string) => + Object.freeze({ + bindSource: vi.fn(() => true), + consume: vi.fn(() => true), + dispose: vi.fn(), + issue: vi.fn(() => Object.freeze({ ok: true as const, nonce })), + snapshotForTest: vi.fn(() => + Object.freeze({ bindings: 1, disposed: false, liveNonces: 1 }) + ), + }); + const bootstraps = nonceRegistry(bootstrapNonce); + const renderers = nonceRegistry(rendererNonce); + const topSlot = document.getElementById('top-slot')!; + const isCurrent = vi.fn(() => true); + const resolveApsMountBinding = vi.fn(() => + Object.freeze({ + bindingEpoch: Object.freeze({ binding: 1 }), + cycle: Object.freeze({ isRetired: () => false }), + element: topSlot, + physicalSlot: Object.freeze({ slot: 'physical' }), + isCurrent, + }) ); - const consume = vi.fn(() => true); const harness = createHarness( () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), { @@ -2847,267 +2772,108 @@ describe('Universal Creative bridge dispatcher', () => { 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; - } + readonly port1 = controlRetained; + readonly port2 = controlTransferred; }, 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', + mountAps: (input) => + renderPucApsAttempt({ + ...input, + bootstrapNonces: bootstraps, + nonces: renderers, + publisherOrigin: window.location.origin, + }), + slots: { resolveApsMountBinding }, } ); - issueReadyTicket(harness, gam, pucSource); - harness.dispatch({ - data: exactOwnerRegistration(gam.reservationId), - ports: [createPort()], - source: pucSource, - stopImmediatePropagation: vi.fn(), - }); + try { + 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: { + expect(resolveApsMountBinding).toHaveBeenCalledWith( + gam.owner.navigationGeneration, + gam.owner.slot, + gam.attempt.id + ); + expect(gam.attempt.fail.mock.calls).toEqual([]); + expect(bootstraps.issue).toHaveBeenCalledOnce(); + expect(renderers.issue).toHaveBeenCalledOnce(); + expect(gam.attempt.beginApsDocument).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); + expect(document.body.querySelector('#publisher-child iframe')).toBeNull(); + expect(document.body.querySelectorAll('#top-slot iframe')).toHaveLength(1); + const frame = topSlot.querySelector('iframe')!; + expect(frame.style.position).toBe('absolute'); + expect(frame.style.visibility).toBe('hidden'); + expect(topSlot.querySelector('span')?.textContent).toBe('gam'); + expect(controlRetained.postMessage).toHaveBeenCalledExactlyOnceWith( + { + message: 'TS APS Top Mount Started', 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', - }, + lifecycleTicket: LIFECYCLE_TICKET, }, - }, - [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', - }); - 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 - // 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).toHaveBeenCalledWith(gam.artifact); - expect(gam.artifact.dispose).not.toHaveBeenCalled(); - 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(); - 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: Parameters['issue']>[0]) => { - const port = input.port; - if (!port) throw new Error('should receive the PUC renderer port'); - expect(input.attempt.onSettled(() => 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, + const source = frame.contentWindow!; + harness.dispatch({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, }), - 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' }); - 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, + origin: 'null', + ports: [], + source, + }); + const rendererPort = createPort(); + harness.dispatch({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, }), - messageChannel: class { - readonly port1: unknown; - readonly port2: unknown; + origin: 'null', + ports: [rendererPort], + source, + }); + dispatchPortMessage(rendererPort, { + message: 'TS APS Document Accepted', + version: 1, + nonce: rendererNonce, + }); + dispatchPortMessage(rendererPort, { + message: 'TS APS Render Completed', + version: 1, + nonce: rendererNonce, + }); - constructor() { - channelIndex += 1; - if (channelIndex === 1) { - this.port1 = controlRetained; - this.port2 = controlTransferred; - return; - } - this.port1 = documentRetained; - this.port2 = documentTransferred; - gam.attempt.fail('internal_error'); - } + expect(frame.style.visibility).toBe('visible'); + expect(gam.attempt.accept).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage.mock.calls[1]).toEqual([ + { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', }, - 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); + [], + ]); + expect(controlRetained.close).toHaveBeenCalledOnce(); + } finally { + bootstraps.dispose(); + renderers.dispose(); + harness.bridge.dispose(); + document.body.innerHTML = ''; + } }); - 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' }); 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 afa21d6b7..b4fbd6ed9 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -12,6 +12,7 @@ import { APS_RENDERER_SANDBOX, APS_RENDERER_V1_PATH, renderDirectApsAttempt, + renderPucApsAttempt, resolveApsRendererV1Url, } from '../../src/integrations/aps/render'; import { @@ -1687,6 +1688,157 @@ describe('direct APS attempt rendering', () => { ); expect(resolveApsRendererV1Url('http://publisher.example')).toBeUndefined(); }); + + it('keeps a PUC APS overlay hidden until completion and preserves the slot host', () => { + document.body.innerHTML = '
GAM content
'; + const scope = owner(); + const render = attempt(scope); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, DIRECT_APS_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + const baseArtifact = artifact(scope, 'puc'); + const bootstrapNonce = indexedBootstrapNonce(41); + const rendererNonce = indexedRendererNonce(41); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: bootstrapNonce }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: rendererNonce }), + }); + let captureListener: ((event: MessageEvent) => void) | undefined; + const messaging = createBrowserMessagingAdapter({ + addEventListener: ( + _type: 'message', + listener: (event: MessageEvent) => void, + _capture: true + ) => { + captureListener = listener; + }, + removeEventListener: vi.fn(), + }); + const container = document.getElementById('fictional-slot')!; + const rendererPort = browserMessagePort(); + const isBindingCurrent = vi.fn(() => true); + const onArtifactTransferred = vi.fn(); + + try { + expect( + renderPucApsAttempt({ + attempt: render, + baseArtifact, + bootstrapNonces: bootstraps, + container, + isBindingCurrent, + messaging, + nonces: renderers, + onArtifactTransferred, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe')!; + const source = frame.contentWindow!; + expect(onArtifactTransferred).toHaveBeenCalledOnce(); + expect(container.querySelector('span')?.textContent).toBe('GAM content'); + expect(container.style.position).toBe('relative'); + expect(frame.style.position).toBe('absolute'); + expect(frame.style.visibility).toBe('hidden'); + + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: 'null', + ports: [], + source, + } as unknown as MessageEvent); + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, + }), + origin: 'null', + ports: [rendererPort], + source, + } as unknown as MessageEvent); + rendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: rendererNonce, + }); + rendererPort.emit({ + message: 'TS APS Runner Loaded', + version: 1, + nonce: rendererNonce, + }); + expect(frame.style.visibility).toBe('hidden'); + rendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: rendererNonce, + }); + + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(frame.style.visibility).toBe('visible'); + expect(container.querySelector('span')?.textContent).toBe('GAM content'); + expect(baseArtifact.dispose).not.toHaveBeenCalled(); + } finally { + bootstraps.dispose(); + renderers.dispose(); + document.body.innerHTML = ''; + } + }); + + it('removes only the failed PUC overlay and compare-restores its host position', () => { + document.body.innerHTML = '
publisher
'; + const scope = owner(); + const render = attempt(scope); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, DIRECT_APS_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + const baseArtifact = artifact(scope, 'puc'); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedBootstrapNonce(42) }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(42) }), + }); + const container = document.getElementById('fictional-slot')!; + + try { + expect( + renderPucApsAttempt({ + attempt: render, + baseArtifact, + bootstrapNonces: bootstraps, + container, + isBindingCurrent: () => true, + messaging: createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + nonces: renderers, + onArtifactTransferred: vi.fn(), + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(container.style.position).toBe('relative'); + expect(render.fail('runner_failed')).toBe(true); + expect(container.querySelector('iframe')).toBeNull(); + expect(container.querySelector('span')?.textContent).toBe('publisher'); + expect(container.style.position).toBe(''); + expect(baseArtifact.dispose).toHaveBeenCalledOnce(); + } finally { + bootstraps.dispose(); + renderers.dispose(); + document.body.innerHTML = ''; + } + }); }); function claimed( 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 1f3188985..aa6f4d321 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -1022,6 +1022,112 @@ describe('slot registry', () => { describe('navigation-owned DOM reconciliation', () => { afterEach(() => vi.useRealTimers()); + async function completedApsMountCycle( + ownership: 'publisher' | 'trusted_server' = 'trusted_server' + ) { + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const element = Object.freeze({ element: 'top-slot' }); + dom.put('slot-div', element); + const service = createSlotService({ googletag: gpt.adapter, reconciliation: dom.boundary }); + const navigation = createNavigation(); + let slot: object; + if (ownership === 'trusted_server') { + slot = bindTrustedSlot(service, navigation); + } else { + slot = Object.freeze({ publisher: true }); + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ + ok: true, + }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition: Object.freeze({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: Object.freeze([[300, 250] as const]), + }), + ownership, + slot, + }) + ).toEqual({ ok: true }); + } + const intentId = `a1_${'m'.repeat(22)}`; + const request = service.request({ + intentId, + expectedSlot: slot, + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'aps-top-mount', + slot, + }); + await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); + return { dom, element, intentId, navigation, service, slot }; + } + + it('claims one exact completed TS cycle and invalidates it on DOM replacement', async () => { + const h = await completedApsMountCycle(); + const binding = h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId); + + expect(binding).toMatchObject({ element: h.element, physicalSlot: h.slot }); + expect(binding?.isCurrent()).toBe(true); + expect( + h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId) + ).toBeUndefined(); + + h.dom.replace('slot-div', Object.freeze({ element: 'replacement' })); + expect(binding?.isCurrent()).toBe(false); + }); + + it('refuses a publisher-owned cycle when its exact host was replaced before binding', async () => { + const h = await completedApsMountCycle('publisher'); + h.dom.replace('slot-div', Object.freeze({ element: 'publisher-replacement' })); + + expect( + h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId) + ).toBeUndefined(); + }); + + it('refuses missing, ambiguous, disconnected, and stale APS mount bindings', async () => { + const ambiguous = await completedApsMountCycle(); + ambiguous.dom.replaceAmbiguously('slot-div', [ + Object.freeze({ element: 'first' }), + Object.freeze({ element: 'second' }), + ]); + expect( + ambiguous.service.resolveApsMountBinding( + ambiguous.navigation.generation, + 'slot', + ambiguous.intentId + ) + ).toBeUndefined(); + + const disconnected = await completedApsMountCycle(); + disconnected.dom.disconnect('slot-div'); + expect( + disconnected.service.resolveApsMountBinding( + disconnected.navigation.generation, + 'slot', + disconnected.intentId + ) + ).toBeUndefined(); + + const stale = await completedApsMountCycle(); + stale.navigation.dispose(); + expect( + stale.service.resolveApsMountBinding(stale.navigation.generation, 'slot', stale.intentId) + ).toBeUndefined(); + expect( + stale.service.resolveApsMountBinding(stale.navigation.generation, 'missing', stale.intentId) + ).toBeUndefined(); + }); + it('installs reconciliation only for one explicit reversible deferred owner', () => { const gpt = createGptHarness(); const dom = createReconciliationBoundary(); From bef3cbc64012c70bfd42384eddf2b608835929d8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:51:20 -0700 Subject: [PATCH 523/844] Retire committed APS overlays exactly once --- .../browser/helpers/gpt-stub.ts | 28 +- .../tests/shared/aps-puc-lifecycle.spec.ts | 40 ++ .../lib/src/composition/browser_test.ts | 18 +- .../src/first_display/adapters/googletag.ts | 352 +++++++++-- .../src/first_display/adm_render_bridge.ts | 67 ++- .../lib/src/first_display/agent.ts | 23 +- .../lib/src/first_display/driver.ts | 15 +- .../lib/src/first_display/render_bridge.ts | 133 ++++- .../lib/src/integrations/aps/module.ts | 19 + .../lib/src/integrations/aps/render.ts | 177 +++++- .../lib/src/integrations/gpt/module.ts | 95 ++- .../src/integrations/render_runtime/module.ts | 135 ++++- .../lib/src/services/puc_bridge.ts | 4 +- .../lib/src/services/render.ts | 272 ++++++++- .../lib/src/services/slots.ts | 161 ++++- .../lib/src/services/targeting.ts | 91 +++ .../lib/src/shared/first_display_contracts.ts | 73 ++- .../first_display/adm_render_bridge.test.ts | 15 + .../lib/test/first_display/agent.test.ts | 9 + .../lib/test/first_display/contracts.test.ts | 12 +- .../lib/test/first_display/driver.test.ts | 20 +- .../test/first_display/gpt_adapter.test.ts | 565 +++++++++++++++++- .../lib/test/first_display/handoff.test.ts | 4 + .../test/first_display/render_bridge.test.ts | 82 +++ .../lib/test/first_display/takeover.test.ts | 3 + .../lib/test/integrations/aps/module.test.ts | 11 +- .../lib/test/integrations/gpt/module.test.ts | 182 +++++- .../render_runtime/module.test.ts | 249 +++++++- .../test/kernel/integration_registry.test.ts | 1 + .../lib/test/kernel/lifecycle_module.test.ts | 1 + .../lib/test/kernel/runtime.test.ts | 1 + .../lib/test/services/puc_bridge.test.ts | 18 +- .../lib/test/services/render.test.ts | 265 +++++++- .../lib/test/services/slots.test.ts | 136 ++++- .../lib/test/services/targeting.test.ts | 57 ++ 35 files changed, 3143 insertions(+), 191 deletions(-) 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 683ba8d7e..df77f648c 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts @@ -297,6 +297,7 @@ export async function installGptStub(page: Page): Promise { pairedHistoryWrappers: boolean; }; refreshCount(): number; + publisherRefresh(slotId: string): void; renderUniversalCreative(slotId: string, adId: string): void; slot(id: string, adUnitPath?: string): StubSlot; targeting(slotId: string, key: string): readonly string[]; @@ -340,6 +341,21 @@ export async function installGptStub(page: Page): Promise { refreshCount() { return refreshCalls.length; }, + publisherRefresh(slotId: string) { + const slot = slots.get(slotId); + if (!slot || !physicalSlots.has(slot)) { + throw new Error(`missing fictional publisher slot: ${slotId}`); + } + pubadsService.refresh([slot]); + emissions.push({ + name: "slotRequested", + listeners: listeners.get("slotRequested")?.size ?? 0, + physical: physicalSlots.has(slot), + }); + emit("slotRequested", slot, { + responseIdentifier: "fictional-publisher-refresh", + }); + }, targeting(slotId: string, key: string) { return slots.get(slotId)?.getTargeting(key) ?? []; }, @@ -412,12 +428,12 @@ export async function installGptStub(page: Page): Promise { window.history.replaceState !== references.replaceState; return { diagnosticsSafe: - 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, + 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, pairedHistoryWrappers: pushStateChanged && replaceStateChanged, }; }, diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts index 878bdf3c2..182efd617 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts @@ -388,4 +388,44 @@ test.describe("APS/PUC lifecycle over the hard-cutover runtime", () => { topCreativeFrames: 1, }); }); + + test("retires the exact APS overlay before a publisher refresh returns", async ({ + page, + }) => { + await openLifecyclePage(page); + await startFictionalPuc(page); + await emitNonemptyGam(page); + await expectAcceptedLifecycle(page); + + expect( + await page.evaluate((slot) => { + const stub = ( + window as unknown as { + __gptDiagnosticsStub: { + publisherRefresh(slotId: string): void; + universalCreativeSnapshot(): Readonly<{ + lifecycle: readonly string[]; + }>; + }; + } + ).__gptDiagnosticsStub; + stub.publisherRefresh(slot); + const root = document.getElementById(slot); + return { + lifecycle: stub.universalCreativeSnapshot().lifecycle, + position: root?.style.getPropertyValue("position"), + pucFrames: + root?.querySelectorAll("iframe[data-fictional-puc]").length ?? -1, + topCreativeFrames: + root?.querySelectorAll(':scope > iframe[title="Ad content"]') + .length ?? -1, + }; + }, SLOT), + ).toEqual({ + lifecycle: ["accepted"], + position: "", + pucFrames: 1, + topCreativeFrames: 0, + }); + }); }); diff --git a/crates/trusted-server-js/lib/src/composition/browser_test.ts b/crates/trusted-server-js/lib/src/composition/browser_test.ts index c4daea959..18488c056 100644 --- a/crates/trusted-server-js/lib/src/composition/browser_test.ts +++ b/crates/trusted-server-js/lib/src/composition/browser_test.ts @@ -130,6 +130,9 @@ import { } from '../services/projections'; import { createReservationService, type ReservationService } from '../services/reservations'; import { + bindCommittedArtifactGuard, + bindCommittedArtifactRetirement, + createArtifactHostPositionLeaseRegistry, createCommittedArtifactStore, createBootstrapNonceRegistry, createRenderAttempt, @@ -137,6 +140,7 @@ import { createSlotOperation, renderDirectAdmAttempt, type RenderAttempt, + type ArtifactHostPositionLeaseRegistry, type CommittedArtifactStore, type BootstrapNonceRegistry, type RendererNonceRegistry, @@ -204,6 +208,7 @@ function installBrowserTestRuntimeScript(runtimeDocument: Document): void { export interface BrowserServices { readonly artifacts: CommittedArtifactStore; + readonly hostPositions: ArtifactHostPositionLeaseRegistry; readonly auctionBatches: AuctionBatchService; readonly bootstrapNonces: BootstrapNonceRegistry; readonly pucBridge: PucBridge; @@ -1557,10 +1562,15 @@ export function createTestBrowserRuntimeComposition( ? undefined : createBrowserSlotReconciliationBoundary(document, MutationObserver); const artifacts = createCommittedArtifactStore(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); const slotService = createSlotService({ - disposeCommittedArtifact: (navigationGeneration, registeredSlotId) => { + bindCommittedArtifactRetirement, + disposeCommittedArtifact: (navigationGeneration, registeredSlotId, expectedArtifact) => { const artifact = artifacts.current(registeredSlotId); - if (artifact?.navigationGeneration === navigationGeneration) { + if ( + artifact === expectedArtifact && + artifact.navigationGeneration === navigationGeneration + ) { artifacts.release(artifact); } }, @@ -1602,6 +1612,7 @@ export function createTestBrowserRuntimeComposition( try { return renderDirectApsAttempt({ attempt, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces, container, messaging: composition.adapters.messaging, @@ -1672,6 +1683,7 @@ export function createTestBrowserRuntimeComposition( artifacts, auctionBatches: batchCoordinator, bootstrapNonces, + hostPositions, reservations: reservationService, rendererNonces, renderDirectAdm, @@ -1799,7 +1811,9 @@ export function createTestBrowserRuntimeComposition( mountAps: (input) => renderPucApsAttempt({ ...input, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces: prepared.services.bootstrapNonces, + hostPositions: prepared.services.hostPositions, messaging: composition.adapters.messaging, nonces: prepared.services.rendererNonces, publisherOrigin: prepared.publisherOrigin, diff --git a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts index ba6086198..efa5f4116 100644 --- a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts @@ -51,6 +51,16 @@ export interface FirstDisplayGptBoundCycleV1 { readonly traceToken: string; } +export interface FirstDisplayTargetingOwnershipV1 { + readonly installed: string; + readonly key: string; + readonly prior: readonly string[]; +} + +export interface FirstDisplayGptHandoffCycleV1 extends FirstDisplayGptBoundCycleV1 { + readonly targetingOwnership: readonly FirstDisplayTargetingOwnershipV1[]; +} + export interface FirstDisplayGptDiagnosticCycleV1 { readonly nextCycleOrdinal: number; readonly quarantines: readonly string[]; @@ -78,14 +88,17 @@ export interface FirstDisplayGoogletagBatchCallbacks { cycle: FirstDisplayGptBoundCycleV1, result: FirstDisplayGptRenderResult ) => void; + /** Retire an accepted TS artifact when its exact parser-time physical binding is lost. */ + readonly onRetire?: (cycle: FirstDisplayGptBoundCycleV1) => void; } export interface FirstDisplayGoogletagBatch { readonly start: (callbacks: FirstDisplayGoogletagBatchCallbacks) => boolean; /** Stop provisional GPT ingress after every physical cycle is terminal. */ - readonly closeIngress: () => boolean; + /** Destroy noncommitted TS slots before closing publisher-mutation observation. */ + readonly closeIngress: (committedSlotIds: readonly string[]) => boolean; /** Capture exact terminal physical identities without changing disposal ownership. */ - readonly captureHandoff: () => readonly FirstDisplayGptBoundCycleV1[] | undefined; + readonly captureHandoff: () => readonly FirstDisplayGptHandoffCycleV1[] | undefined; readonly captureDiagnosticsHandoff: () => FirstDisplayGptDiagnosticsHandoffV1 | undefined; /** Exempt exactly the accepted slot identities from provisional destruction/restoration. */ readonly detachCommittedSlots: (slotIds: readonly string[]) => boolean; @@ -141,6 +154,22 @@ interface TargetingRestorer { valid: boolean; } +interface TargetingWrite { + consumed: boolean; + readonly operation: 'clearTargeting' | 'setTargeting'; + readonly slot: object; +} + +interface TargetingObserverRestorer { + readonly isCurrent: () => boolean; + readonly restore: () => boolean; +} + +interface TargetingObserver { + readonly isCurrent: () => boolean; + readonly restore: () => boolean; +} + function externalObject(value: unknown): ExternalObject | undefined { return (typeof value === 'object' && value !== null) || typeof value === 'function' ? (value as ExternalObject) @@ -235,8 +264,12 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private readonly createdSlots = new Set(); private readonly diagnosticFacts: Readonly[] = []; private readonly diagnosticListeners = new Map void>(); - private readonly targetingObservers = new Map void>(); + private readonly targetingObservers = new Map(); private readonly targetingRestorers: TargetingRestorer[] = []; + private readonly sealedTargetingOwnership = new Map< + object, + readonly FirstDisplayTargetingOwnershipV1[] + >(); private readonly publisherCallRestorers: Array<() => void> = []; private readonly timers = new Set(); private binding: ExternalObject | undefined; @@ -250,13 +283,14 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private started = false; private disposed = false; private ingressClosed = false; + private ingressClosing = false; private committedSlotsDetached = false; private readonly detachedSlots = new Set(); private firstAction = false; private diagnosticFactOverflow = 0; private diagnosticFactDrops = 0; private nextTraceTokenOrdinal = 1; - private targetingWriteDepth = 0; + private readonly targetingWrites: TargetingWrite[] = []; public constructor(private readonly options: FirstDisplayGoogletagBatchOptions) {} @@ -304,36 +338,84 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { return true; } - public closeIngress(): boolean { + public closeIngress(committedSlotIds: readonly string[]): boolean { if ( this.disposed || this.ingressClosed || + this.ingressClosing || !this.started || - [...this.cycles.values()].some((cycle) => !cycle.settled) + [...this.cycles.values()].some((cycle) => !cycle.settled) || + !Array.isArray(committedSlotIds) ) { return false; } + const committed = new Set(committedSlotIds); + if (committed.size !== committedSlotIds.length) return false; + const retainedSlots = new Set(); + for (const slotId of committed) { + const matches = [...this.cycles.values()].filter((cycle) => cycle.slotId === slotId); + if (matches.length !== 1 || !matches[0]?.settled) return false; + retainedSlots.add(matches[0].physicalSlot); + } + const destroyableSlots = [...this.createdSlots].filter((slot) => !retainedSlots.has(slot)); + this.ingressClosing = true; + if (destroyableSlots.length > 0) { + try { + if (!this.binding || call(this.binding, 'destroySlots', [[...destroyableSlots]]) !== true) { + this.ingressClosing = false; + return false; + } + } catch { + this.ingressClosing = false; + return false; + } + for (const slot of destroyableSlots) this.createdSlots.delete(slot); + } + this.invalidateStaleTargetingObservers(); + const retainedTargetingRestorers = this.targetingRestorers.filter((restoration) => + retainedSlots.has(restoration.slot) + ); + for (const restoration of [...this.targetingRestorers].reverse()) { + if (retainedSlots.has(restoration.slot)) continue; + try { + this.restorePublisherTargeting(restoration); + } catch { + // Publisher mutations win while retained-slot observation is still authoritative. + } + } + this.targetingRestorers.splice( + 0, + this.targetingRestorers.length, + ...retainedTargetingRestorers + ); this.ingressClosed = true; + this.ingressClosing = false; this.removePendingCommand(); for (const handle of [...this.timers]) this.clearOwnedTimer(handle); this.removeListener(); this.restorePublisherCalls(); - for (const restoreObserver of this.targetingObservers.values()) { - try { - restoreObserver(); - } catch { - // The closed generation cannot regain authority through a publisher replacement. - } + const targetingCandidates = this.captureRetainedTargeting(retainedSlots); + this.restoreTargetingObservers(); + for (const [slot, restorations] of targetingCandidates) { + this.sealedTargetingOwnership.set( + slot, + Object.freeze( + restorations + .filter(({ valid }) => valid) + .map(({ installed, key, prior }) => Object.freeze({ installed, key, prior })) + ) + ); } - this.targetingObservers.clear(); return true; } - public captureHandoff(): readonly FirstDisplayGptBoundCycleV1[] | undefined { + public captureHandoff(): readonly FirstDisplayGptHandoffCycleV1[] | undefined { if (this.disposed || !this.ingressClosed) return undefined; return Object.freeze( - [...this.cycles.values()].map((cycle) => - Object.freeze({ + [...this.cycles.values()].map((cycle) => { + const targetingOwnership = + this.sealedTargetingOwnership.get(cycle.physicalSlot) ?? Object.freeze([]); + return Object.freeze({ bid: cycle.bid, element: cycle.element, isCurrent: cycle.isCurrent, @@ -341,9 +423,10 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { physicalSlot: cycle.physicalSlot, placement: cycle.placement, slotId: cycle.slotId, + targetingOwnership: Object.freeze(targetingOwnership), traceToken: cycle.traceToken, - }) - ) + }); + }) ); } @@ -402,6 +485,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { for (const handle of [...this.timers]) this.clearOwnedTimer(handle); this.removeListener(); this.restorePublisherCalls(); + this.invalidateStaleTargetingObservers(); for (const restoration of this.targetingRestorers.reverse()) { if (this.detachedSlots.has(restoration.slot)) continue; try { @@ -411,14 +495,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } this.targetingRestorers.length = 0; - for (const restoreObserver of this.targetingObservers.values()) { - try { - restoreObserver(); - } catch { - // Publisher replacement wins over observer restoration. - } - } - this.targetingObservers.clear(); + this.restoreTargetingObservers(); const destroyableSlots = [...this.createdSlots].filter((slot) => !this.detachedSlots.has(slot)); if (this.binding && destroyableSlots.length > 0) { try { @@ -429,6 +506,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } this.createdSlots.clear(); this.cycles.clear(); + this.sealedTargetingOwnership.clear(); if (this.detachedSlots.size === 0) this.restoreCreatedBinding(); else this.createdBinding = undefined; this.detachedSlots.clear(); @@ -484,7 +562,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { const service = externalObject(call(binding, 'pubads', [])); if (!service) throw new TypeError('tsjs'); this.service = service; - this.observePublisherCalls(binding, service); + this.observePublisherCalls(binding, service, callbacks); this.installListeners(service, callbacks); const existing = call(service, 'getSlots', []); if (!Array.isArray(existing)) throw new TypeError('tsjs'); @@ -624,11 +702,13 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { const slot = physicalSlot(member(event, 'slot')); const cycle = slot ? this.cycles.get(slot) : undefined; if (!cycle) { - if (slot) this.invalidateCyclesForElement(this.readPhysicalElementId(slot), slot); + if (slot) { + this.invalidateCyclesForElement(this.readPhysicalElementId(slot), slot, callbacks); + } return; } if (cycle.settled) { - cycle.bindingState.current = false; + this.retireCycle(cycle, callbacks); this.captureDiagnosticFact('slotRequested', cycle, event); return; } @@ -744,7 +824,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private observePublisherTargeting(slot: object): void { if (this.targetingObservers.has(slot)) return; - const restorers: Array<() => void> = []; + const restorers: TargetingObserverRestorer[] = []; try { for (const key of ['setTargeting', 'clearTargeting'] as const) { const original = member(slot, key); @@ -752,10 +832,11 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { const prior = Object.getOwnPropertyDescriptor(slot, key); const invalidateTargeting = (targetingKey: string | undefined): void => this.invalidateTargeting(slot, targetingKey); - const isTrustedServerWrite = (): boolean => this.targetingWriteDepth > 0; + const consumeTrustedServerWrite = (): boolean => this.consumeTargetingWrite(slot, key); const ownerMutation = (): void => this.notifyNativeMutation(); const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { - if (!isTrustedServerWrite() && this === slot) { + const trustedServerWrite = this === slot && consumeTrustedServerWrite(); + if (!trustedServerWrite && this === slot) { const targetingKey = typeof arguments_[0] === 'string' ? arguments_[0] : undefined; invalidateTargeting(targetingKey); const result = Reflect.apply(original, this, arguments_); @@ -774,23 +855,49 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { ) { throw new TypeError('tsjs'); } - restorers.push(() => { - const current = Object.getOwnPropertyDescriptor(slot, key); - if (!current || !('value' in current) || current.value !== wrapper) return; - if (prior) Reflect.defineProperty(slot, key, prior); - else Reflect.deleteProperty(slot, key); + const isCurrent = (): boolean => { + try { + const current = Object.getOwnPropertyDescriptor(slot, key); + return !!current && 'value' in current && current.value === wrapper; + } catch { + return false; + } + }; + restorers.push({ + isCurrent, + restore: (): boolean => { + if (!isCurrent()) return false; + try { + return prior + ? Reflect.defineProperty(slot, key, prior) + : Reflect.deleteProperty(slot, key); + } catch { + return false; + } + }, }); } } catch (error) { - for (const restore of restorers.reverse()) restore(); + for (const restorer of restorers.reverse()) restorer.restore(); throw error; } - this.targetingObservers.set(slot, () => { - for (const restore of restorers.reverse()) restore(); + this.targetingObservers.set(slot, { + isCurrent: (): boolean => restorers.every(({ isCurrent }) => isCurrent()), + restore: (): boolean => { + let exact = restorers.every(({ isCurrent }) => isCurrent()); + for (const restorer of restorers.reverse()) { + if (!restorer.restore()) exact = false; + } + return exact; + }, }); } - private observePublisherCalls(binding: ExternalObject, service: ExternalObject): void { + private observePublisherCalls( + binding: ExternalObject, + service: ExternalObject, + callbacks: FirstDisplayGoogletagBatchCallbacks + ): void { const installed: Array<() => void> = []; try { for (const [receiver, key] of [ @@ -803,12 +910,19 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (typeof original !== 'function') continue; const prior = Object.getOwnPropertyDescriptor(receiver, key); const notify = (): void => this.notifyNativeMutation(); - const invalidate = (arguments_: readonly unknown[], result: unknown): void => - this.invalidateCyclesForPublisherCall(key, arguments_, result); + const snapshotDestroy = (arguments_: readonly unknown[]): readonly ActiveCycle[] => + key === 'destroySlots' ? this.snapshotDestroyedCycles(arguments_) : Object.freeze([]); + const invalidate = ( + arguments_: readonly unknown[], + result: unknown, + destroyed: readonly ActiveCycle[] + ): void => + this.invalidateCyclesForPublisherCall(key, arguments_, result, destroyed, callbacks); const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { + const destroyed = this === receiver ? snapshotDestroy(arguments_) : Object.freeze([]); const result = Reflect.apply(original, this, arguments_); if (this === receiver) { - invalidate(arguments_, result); + invalidate(arguments_, result, destroyed); notify(); } return result; @@ -848,11 +962,54 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } - private invalidateCyclesForElement(elementId: string | undefined, replacement: object): void { + private retireCycle(cycle: ActiveCycle, callbacks: FirstDisplayGoogletagBatchCallbacks): boolean { + if (!cycle.bindingState.current) return false; + cycle.bindingState.current = false; + try { + callbacks.onRetire?.( + Object.freeze({ + bid: cycle.bid, + element: cycle.element, + isCurrent: cycle.isCurrent, + ownership: cycle.ownership, + physicalSlot: cycle.physicalSlot, + placement: cycle.placement, + slotId: cycle.slotId, + traceToken: cycle.traceToken, + }) + ); + } catch { + // Binding invalidation remains authoritative when retirement observation fails. + } + return true; + } + + private snapshotDestroyedCycles(arguments_: readonly unknown[]): readonly ActiveCycle[] { + try { + const targets = arguments_[0]; + if (targets === undefined) return Object.freeze([...this.cycles.values()]); + if (!Array.isArray(targets)) return Object.freeze([]); + const cycles = new Set(); + for (let index = 0; index < targets.length; index += 1) { + const target = physicalSlot(targets[index]); + const cycle = target ? this.cycles.get(target) : undefined; + if (cycle) cycles.add(cycle); + } + return Object.freeze([...cycles]); + } catch { + return Object.freeze([]); + } + } + + private invalidateCyclesForElement( + elementId: string | undefined, + replacement: object, + callbacks: FirstDisplayGoogletagBatchCallbacks + ): void { if (!elementId) return; for (const cycle of this.cycles.values()) { if (cycle.physicalSlot !== replacement && cycle.elementId === elementId) { - cycle.bindingState.current = false; + this.retireCycle(cycle, callbacks); } } } @@ -860,28 +1017,20 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private invalidateCyclesForPublisherCall( key: string, arguments_: readonly unknown[], - result: unknown + result: unknown, + destroyed: readonly ActiveCycle[], + callbacks: FirstDisplayGoogletagBatchCallbacks ): void { try { if (key === 'destroySlots' && result === true) { - const targets = arguments_[0]; - if (targets === undefined) { - for (const cycle of this.cycles.values()) cycle.bindingState.current = false; - return; - } - if (!Array.isArray(targets)) return; - for (let index = 0; index < targets.length; index += 1) { - const target = physicalSlot(targets[index]); - const cycle = target ? this.cycles.get(target) : undefined; - if (cycle) cycle.bindingState.current = false; - } + for (const cycle of destroyed) this.retireCycle(cycle, callbacks); return; } if (key === 'defineSlot') { const replacement = physicalSlot(result); const elementId = arguments_[2]; if (replacement && typeof elementId === 'string') { - this.invalidateCyclesForElement(elementId, replacement); + this.invalidateCyclesForElement(elementId, replacement, callbacks); } } } catch { @@ -1110,9 +1259,25 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } private restorePublisherTargeting(restoration: TargetingRestorer): void { + if (!restoration.valid) return; + const observer = this.targetingObservers.get(restoration.slot); + if (observer && !observer.isCurrent()) { + this.invalidateTargeting(restoration.slot, undefined); + return; + } if (!restoration.valid) return; const current = call(restoration.slot, 'getTargeting', [restoration.key]); - if (!Array.isArray(current) || current.length !== 1 || current[0] !== restoration.installed) { + if (!restoration.valid) return; + if (observer && !observer.isCurrent()) { + this.invalidateTargeting(restoration.slot, undefined); + return; + } + if ( + !restoration.valid || + !Array.isArray(current) || + current.length !== 1 || + current[0] !== restoration.installed + ) { return; } if (restoration.prior.length === 0) { @@ -1127,12 +1292,73 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { operation: 'clearTargeting' | 'setTargeting', arguments_: readonly unknown[] ): unknown { - this.targetingWriteDepth += 1; + const write: TargetingWrite = { consumed: false, operation, slot }; + this.targetingWrites.push(write); try { return call(slot, operation, arguments_); } finally { - this.targetingWriteDepth -= 1; + const index = this.targetingWrites.lastIndexOf(write); + if (index >= 0) this.targetingWrites.splice(index, 1); + } + } + + private consumeTargetingWrite( + slot: object, + operation: 'clearTargeting' | 'setTargeting' + ): boolean { + const write = this.targetingWrites[this.targetingWrites.length - 1]; + if (!write || write.consumed || write.slot !== slot || write.operation !== operation) { + return false; + } + write.consumed = true; + return true; + } + + private invalidateStaleTargetingObservers(): void { + for (const [slot, observer] of this.targetingObservers) { + if (!observer.isCurrent()) this.invalidateTargeting(slot, undefined); + } + } + + private restoreTargetingObservers(): void { + for (const [slot, observer] of this.targetingObservers) { + if (!observer.restore()) this.invalidateTargeting(slot, undefined); + } + this.targetingObservers.clear(); + } + + private captureRetainedTargeting( + retainedSlots: ReadonlySet + ): Map { + const captured = new Map(); + for (const slot of retainedSlots) { + const observer = this.targetingObservers.get(slot); + if (observer && !observer.isCurrent()) this.invalidateTargeting(slot, undefined); + const restorations: TargetingRestorer[] = []; + for (const restoration of this.targetingRestorers) { + if (!restoration.valid || restoration.slot !== slot) continue; + let current: unknown; + try { + current = call(slot, 'getTargeting', [restoration.key]); + } catch { + continue; + } + if (observer && !observer.isCurrent()) { + this.invalidateTargeting(slot, undefined); + break; + } + if ( + restoration.valid && + Array.isArray(current) && + current.length === 1 && + current[0] === restoration.installed + ) { + restorations.push(restoration); + } + } + captured.set(slot, restorations); } + return captured; } private failRows( @@ -1196,7 +1422,7 @@ export function createFirstDisplayGoogletagBatch( const owner = new FirstDisplayGoogletagBatchOwner(options); return Object.freeze({ start: (callbacks: FirstDisplayGoogletagBatchCallbacks) => owner.start(callbacks), - closeIngress: () => owner.closeIngress(), + closeIngress: (committedSlotIds: readonly string[]) => owner.closeIngress(committedSlotIds), captureHandoff: () => owner.captureHandoff(), captureDiagnosticsHandoff: () => owner.captureDiagnosticsHandoff(), detachCommittedSlots: (slotIds: readonly string[]) => owner.detachCommittedSlots(slotIds), diff --git a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts index 1660fac23..19cc2d064 100644 --- a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts @@ -67,7 +67,10 @@ export function createFirstDisplayAdmRenderBridge( options: FirstDisplayAdmRenderBridgeOptionsV1 ): FirstDisplayRenderBridgeV1 { const attempts = new Map(); - const committedFrames = new Map>(); + const committedFrames = new Map< + string, + Readonly<{ frame: HTMLIFrameElement; host: HTMLElement; token: string }> + >(); const timers = new Set(); let nextReservationOrdinal = 1; let lastNow = Number.NEGATIVE_INFINITY; @@ -130,7 +133,11 @@ export function createFirstDisplayAdmRenderBridge( if (result === 'accepted' && frame?.isConnected) { committedFrames.set( attempt.cycle.slotId, - Object.freeze({ frame, token: attempt.cycle.bid.rendererReservationId }) + Object.freeze({ + frame, + host: attempt.cycle.element, + token: attempt.cycle.bid.rendererReservationId, + }) ); } else if (frame) { try { @@ -150,6 +157,54 @@ export function createFirstDisplayAdmRenderBridge( return true; }; const fail = (attempt: AdmAttempt, reason: string): boolean => settle(attempt, 'failed', reason); + const retireCommitted = (cycle: FirstDisplayGptBoundCycleV1): boolean => { + if (disposed || committedArtifactsDetached) return false; + const entry = committedFrames.get(cycle.slotId); + if (!entry || entry.token !== cycle.bid.rendererReservationId) return false; + committedFrames.delete(cycle.slotId); + try { + entry.frame.onload = null; + entry.frame.onerror = null; + entry.frame.remove(); + } catch { + // The exact TS frame loses authority even when hostile DOM cleanup fails. + } + notifyNativeMutation(); + return true; + }; + const sweepCommittedArtifacts = (): number => { + if (disposed || committedArtifactsDetached) return 0; + let retired = 0; + for (const [slotId, entry] of [...committedFrames.entries()]) { + const current = (() => { + try { + return ( + entry.frame.ownerDocument === options.document && + entry.host.ownerDocument === options.document && + entry.frame.parentNode === entry.host && + entry.frame.isConnected && + entry.host.isConnected && + entry.host.id.length > 0 && + options.document.getElementById(entry.host.id) === entry.host + ); + } catch { + return false; + } + })(); + if (committedFrames.get(slotId) !== entry || current) continue; + committedFrames.delete(slotId); + try { + entry.frame.onload = null; + entry.frame.onerror = null; + entry.frame.remove(); + } catch { + // The exact TS frame loses authority even when hostile DOM cleanup fails. + } + retired += 1; + } + if (retired > 0) notifyNativeMutation(); + return retired; + }; return Object.freeze({ bind: (cycle: FirstDisplayGptBoundCycleV1, onTerminal: AdmAttempt['onTerminal']): boolean => { @@ -222,6 +277,8 @@ export function createFirstDisplayAdmRenderBridge( attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') ); }, + retire: retireCommitted, + sweepCommittedArtifacts, sealTsAdmission: (): void => { if (disposed || sealed || [...attempts.values()].some(({ active }) => active)) { throw new TypeError('tsjs'); @@ -236,6 +293,7 @@ export function createFirstDisplayAdmRenderBridge( }, captureHandoff: () => { if (disposed || !ingressClosed || handoffCaptured) return undefined; + sweepCommittedArtifacts(); const observedAt = readNow(); if (observedAt === undefined) return undefined; handoffCaptured = true; @@ -243,6 +301,8 @@ export function createFirstDisplayAdmRenderBridge( artifacts: Object.freeze( [...committedFrames.entries()].map(([slotId, { frame, token }]) => Object.freeze({ + hostPosition: null, + hostPositionPriority: null, identity: frame, kind: 'gpt_adm' as const, owner: 'trusted_server' as const, @@ -272,6 +332,7 @@ export function createFirstDisplayAdmRenderBridge( if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { return false; } + if (sweepCommittedArtifacts() > 0) return false; committedArtifactsDetached = true; return true; }, @@ -285,6 +346,8 @@ export function createFirstDisplayAdmRenderBridge( if (!committedArtifactsDetached) { for (const { frame } of committedFrames.values()) { try { + frame.onload = null; + frame.onerror = null; frame.remove(); } catch { // A terminal owner cannot regain authority through a retained node. diff --git a/crates/trusted-server-js/lib/src/first_display/agent.ts b/crates/trusted-server-js/lib/src/first_display/agent.ts index 2bbbeeade..75d395801 100644 --- a/crates/trusted-server-js/lib/src/first_display/agent.ts +++ b/crates/trusted-server-js/lib/src/first_display/agent.ts @@ -26,8 +26,8 @@ import { import type { FirstDisplayGoogletagBatchInput, - FirstDisplayGptBoundCycleV1, FirstDisplayGptDiagnosticCycleV1, + FirstDisplayGptHandoffCycleV1, } from './adapters/googletag'; import { createFirstDisplayAdmRenderBridge } from './adm_render_bridge'; import { createFirstDisplayProjectedDriver, type FirstDisplayRenderBridgeV1 } from './driver'; @@ -81,18 +81,21 @@ export interface FirstDisplayDriver { readonly closeIngress: () => boolean; readonly captureHandoff: () => FirstDisplayDriverHandoffV1 | undefined; readonly detachCommittedArtifacts: () => boolean; + readonly sweepCommittedArtifacts: () => number; readonly dispose: () => void; } export interface FirstDisplayDriverHandoffV1 { readonly artifacts: readonly Readonly<{ + hostPosition: string | null; + hostPositionPriority: string | null; identity: object; kind: 'gpt_adm' | 'aps'; owner: 'trusted_server' | 'publisher'; slotId: string; token: string; }>[]; - readonly cycles: readonly FirstDisplayGptBoundCycleV1[]; + readonly cycles: readonly FirstDisplayGptHandoffCycleV1[]; readonly diagnosticCycles: readonly Readonly[]; readonly clockEpochMs: number; readonly gptDiagnostics: Readonly; @@ -701,6 +704,12 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { targeting: Object.keys(targeting) .sort() .map((key) => [key, targeting[key]]), + targetingOwnership: + cycle?.targetingOwnership.map((ownership) => ({ + installed: ownership.installed, + key: ownership.key, + prior: [...ownership.prior], + })) ?? [], committedArtifact, gptToken: cycleTokenBySlot.get(placement.slot) ?? null, }; @@ -747,6 +756,8 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { throw new TypeError('tsjs'); } return { + hostPosition: capturedArtifact.hostPosition, + hostPositionPriority: capturedArtifact.hostPositionPriority, slotId: slot.id, kind: slot.committedArtifact, owner: capturedArtifact.owner, @@ -879,6 +890,14 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { } private observeDomMutations(records: readonly MutationRecord[]): void { + if (records.length > 0) { + try { + this.options.driver.sweepCommittedArtifacts(); + } catch { + this.fail('bundle_partial'); + return; + } + } for (const record of records) { if (this.isOwnedRuntimeInsertion(record)) continue; if (!this.observeNativeMutation()) return; diff --git a/crates/trusted-server-js/lib/src/first_display/driver.ts b/crates/trusted-server-js/lib/src/first_display/driver.ts index bcf9b1463..d1d0a57d6 100644 --- a/crates/trusted-server-js/lib/src/first_display/driver.ts +++ b/crates/trusted-server-js/lib/src/first_display/driver.ts @@ -20,6 +20,8 @@ export interface FirstDisplayRenderBridgeV1 { result: FirstDisplayGptRenderResult ) => boolean; readonly recordFailure: (cycle: FirstDisplayGptBoundCycleV1) => boolean; + readonly retire: (cycle: FirstDisplayGptBoundCycleV1) => boolean; + readonly sweepCommittedArtifacts: () => number; readonly sealTsAdmission: () => void; readonly closeIngress: () => boolean; readonly captureHandoff: () => FirstDisplayRenderHandoffV1 | undefined; @@ -28,6 +30,8 @@ export interface FirstDisplayRenderBridgeV1 { } export interface FirstDisplayRenderHandoffArtifactV1 { + readonly hostPosition: string | null; + readonly hostPositionPriority: string | null; readonly identity: object; readonly kind: 'gpt_adm' | 'aps'; readonly owner: 'trusted_server' | 'publisher'; @@ -182,6 +186,10 @@ export function createFirstDisplayProjectedDriver( settle(cycle.slotId, 'failed', 'gpt_request_failed'); } }, + onRetire: (cycle): void => { + const exact = bound.get(cycle.slotId); + if (exact && sameCycle(exact, cycle)) options.renderer.retire(exact); + }, }); if (accepted !== true) throw new TypeError('tsjs'); }, @@ -192,9 +200,14 @@ export function createFirstDisplayProjectedDriver( sealed = true; options.renderer.sealTsAdmission(); }, + sweepCommittedArtifacts: (): number => + disposed ? 0 : options.renderer.sweepCommittedArtifacts(), closeIngress: (): boolean => { if (disposed || !sealed || ingressClosed) return false; - if (gptBatch && !gptBatch.closeIngress()) return false; + const acceptedIds = [...settledResults.entries()] + .filter(([, result]) => result === 'accepted') + .map(([slotId]) => slotId); + if (gptBatch && !gptBatch.closeIngress(acceptedIds)) return false; if (!options.renderer.closeIngress()) return false; ingressClosed = true; return true; diff --git a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts index 03c88e783..559a0eb6f 100644 --- a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts @@ -586,6 +586,13 @@ export function createFirstDisplayRenderBridge( string, Readonly<{ frame: HTMLIFrameElement; + frameAttributes: string; + frameContentWindow: Window; + frameSource: string; + frameSourceDocument: string; + host: HTMLElement; + hostPosition: string | null; + hostPositionPriority: string | null; kind: 'gpt_adm' | 'aps'; owner: 'trusted_server'; token: string; @@ -638,6 +645,103 @@ export function createFirstDisplayRenderBridge( } }; + const disposeCommittedFrame = ( + entry: Readonly<{ + frame: HTMLIFrameElement; + host: HTMLElement; + hostPosition: string | null; + hostPositionPriority: string | null; + }> + ): void => { + try { + entry.frame.onload = null; + entry.frame.onerror = null; + entry.frame.remove(); + } catch { + // The exact TS node loses authority even when hostile DOM cleanup fails. + } + if (entry.hostPosition === null) return; + try { + const style = entry.host.style; + if ( + style.getPropertyValue('position') !== 'relative' || + style.getPropertyPriority('position') !== '' + ) { + return; + } + if (entry.hostPosition === '') style.removeProperty('position'); + else style.setProperty('position', entry.hostPosition, entry.hostPositionPriority ?? ''); + } catch { + // Compare-owned restoration cannot overwrite publisher style changes. + } + }; + + const snapshotFrameAttributes = (frame: HTMLIFrameElement): string | undefined => { + try { + return JSON.stringify( + [...frame.attributes] + .map((attribute) => [attribute.name, attribute.value] as const) + .sort(([left], [right]) => left.localeCompare(right)) + ); + } catch { + return undefined; + } + }; + + const retireCommitted = (cycle: FirstDisplayGptBoundCycleV1): boolean => { + if (disposed || committedArtifactsDetached) return false; + const entry = committedFrames.get(cycle.slotId); + if (!entry || entry.token !== cycle.bid.rendererReservationId) return false; + committedFrames.delete(cycle.slotId); + disposeCommittedFrame(entry); + notifyNativeMutation(); + return true; + }; + + const committedFrameCurrent = (entry: { + readonly frame: HTMLIFrameElement; + readonly frameAttributes: string; + readonly frameContentWindow: Window; + readonly frameSource: string; + readonly frameSourceDocument: string; + readonly host: HTMLElement; + readonly hostPosition: string | null; + }): boolean => { + try { + return ( + entry.frame.ownerDocument === options.document && + entry.host.ownerDocument === options.document && + entry.frame.parentNode === entry.host && + entry.frame.isConnected && + entry.frame.contentWindow === entry.frameContentWindow && + entry.frame.src === entry.frameSource && + entry.frame.srcdoc === entry.frameSourceDocument && + snapshotFrameAttributes(entry.frame) === entry.frameAttributes && + entry.host.isConnected && + entry.host.id.length > 0 && + options.document.getElementById(entry.host.id) === entry.host && + (entry.hostPosition === null || + (entry.host.style.getPropertyValue('position') === 'relative' && + entry.host.style.getPropertyPriority('position') === '')) + ); + } catch { + return false; + } + }; + + const sweepCommittedArtifacts = (): number => { + if (disposed || committedArtifactsDetached) return 0; + let retired = 0; + for (const [slotId, entry] of [...committedFrames.entries()]) { + if (committedFrames.get(slotId) !== entry || committedFrameCurrent(entry)) continue; + committedFrames.delete(slotId); + disposeCommittedFrame(entry); + retired += 1; + } + if (retired > 0) notifyNativeMutation(); + return retired; + }; + const clearOwnedTimer = (handle: unknown): void => { if (handle === undefined || !timers.delete(handle)) return; try { @@ -792,11 +896,24 @@ export function createFirstDisplayRenderBridge( } const committedFrame = result === 'accepted' ? attempt.directFrame : undefined; releaseAttempt(attempt, result !== 'accepted'); - if (committedFrame?.isConnected) { + const frameAttributes = committedFrame ? snapshotFrameAttributes(committedFrame) : undefined; + const frameContentWindow = committedFrame?.contentWindow; + if (committedFrame?.isConnected && frameAttributes !== undefined && frameContentWindow) { committedFrames.set( attempt.cycle.slotId, Object.freeze({ frame: committedFrame, + frameAttributes, + frameContentWindow, + frameSource: committedFrame.src, + frameSourceDocument: committedFrame.srcdoc, + host: attempt.cycle.element, + hostPosition: + attempt.overlay && attempt.hostPositionOwned ? attempt.previousHostPosition : null, + hostPositionPriority: + attempt.overlay && attempt.hostPositionOwned + ? attempt.previousHostPositionPriority + : null, kind: attempt.cycle.bid.renderSource.type === 'adm' ? 'gpt_adm' : 'aps', owner: 'trusted_server', token: attempt.reservationId, @@ -1647,6 +1764,8 @@ export function createFirstDisplayRenderBridge( attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') ); }, + retire: retireCommitted, + sweepCommittedArtifacts, sealTsAdmission: (): void => { if (disposed || [...attempts.values()].some((attempt) => attempt.active)) { throw new TypeError('tsjs'); @@ -1673,6 +1792,7 @@ export function createFirstDisplayRenderBridge( }, captureHandoff: () => { if (disposed || !ingressClosed || handoffCaptured) return undefined; + sweepCommittedArtifacts(); const observedAt = readNow(); if (observedAt === undefined) return undefined; const reservationTombstones = [...reservations.entries()] @@ -1703,6 +1823,8 @@ export function createFirstDisplayRenderBridge( ); const artifacts = [...committedFrames.entries()].map(([slotId, entry]) => Object.freeze({ + hostPosition: entry.hostPosition, + hostPositionPriority: entry.hostPositionPriority, identity: entry.frame, kind: entry.kind, owner: entry.owner, @@ -1723,6 +1845,7 @@ export function createFirstDisplayRenderBridge( if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { return false; } + if (sweepCommittedArtifacts() > 0) return false; committedArtifactsDetached = true; return true; }, @@ -1742,13 +1865,7 @@ export function createFirstDisplayRenderBridge( } for (const handle of [...timers]) clearOwnedTimer(handle); if (!committedArtifactsDetached) { - for (const { frame } of committedFrames.values()) { - try { - frame.remove(); - } catch { - // The committed owner is terminal and cannot be restored by a hostile DOM node. - } - } + for (const entry of committedFrames.values()) disposeCommittedFrame(entry); } committedFrames.clear(); attempts.clear(); diff --git a/crates/trusted-server-js/lib/src/integrations/aps/module.ts b/crates/trusted-server-js/lib/src/integrations/aps/module.ts index dfaaeefb5..821168fb1 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/module.ts @@ -6,7 +6,9 @@ import type { IntegrationRegistration, } from '../../kernel/integration_registry'; import type { + ArtifactHostPositionLeaseRegistry, BootstrapNonceRegistry, + CommittedRenderArtifact, RendererNonceRegistry, RenderAttempt, } from '../../services/render'; @@ -21,7 +23,12 @@ import { } from './render'; interface RenderCapability { + readonly bindArtifactGuard: ( + artifact: CommittedRenderArtifact, + current: () => boolean + ) => boolean; readonly bootstrapNonces: BootstrapNonceRegistry; + readonly hostPositions: ArtifactHostPositionLeaseRegistry; readonly publisherOrigin: string; readonly rendererNonces: RendererNonceRegistry; readonly registerRenderer: ( @@ -61,8 +68,17 @@ export function createApsIntegrationRegistration(releaseId: string): Integration const render = capability(context.interfaces, 'render.v1'); const messages = capability(context.interfaces, 'messages.v1'); if ( + typeof render.bindArtifactGuard !== 'function' || typeof render.registerRenderer !== 'function' || typeof render.publisherOrigin !== 'string' || + typeof render.hostPositions !== 'object' || + render.hostPositions === null || + !Object.isFrozen(render.hostPositions) || + typeof render.hostPositions.bindOwned !== 'function' || + typeof render.hostPositions.inherit !== 'function' || + typeof render.hostPositions.claim !== 'function' || + typeof render.hostPositions.current !== 'function' || + typeof render.hostPositions.release !== 'function' || typeof messages.messaging !== 'object' || messages.messaging === null || typeof messages.registerApsValidation !== 'function' @@ -76,6 +92,7 @@ export function createApsIntegrationRegistration(releaseId: string): Integration active && renderDirectApsAttempt({ attempt, + bindArtifactGuard: render.bindArtifactGuard, bootstrapNonces: render.bootstrapNonces, container, messaging: messages.messaging, @@ -86,7 +103,9 @@ export function createApsIntegrationRegistration(releaseId: string): Integration active && renderPucApsAttempt({ ...input, + bindArtifactGuard: render.bindArtifactGuard, bootstrapNonces: render.bootstrapNonces, + hostPositions: render.hostPositions, messaging: messages.messaging, nonces: render.rendererNonces, publisherOrigin: render.publisherOrigin, 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 4f0caf97b..6f36ad48b 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -2,12 +2,14 @@ import type { ApsRendererV1 } from '../../core/types'; import { validateApsRenderer } from '../../core/contracts/aps_renderer'; import type { MessagingAdapter, MessagingPort } from '../../adapters/messaging'; import type { + ArtifactHostPositionLeaseRegistry, BootstrapNonceRegistry, CommittedRenderArtifact, RenderAttempt, RenderFailureReason, RendererNonceRegistry, } from '../../services/render'; +import type { ApsSlotMountBinding } from '../../services/slots'; import { APS_PERMANENT_SANDBOX, generateApsDataDocumentsV1 } from './documents'; @@ -31,6 +33,9 @@ const directIframePrototype = directDomAvailable ? HTMLIFrameElement.prototype : const documentCreateElementIntrinsic = directDomAvailable ? Document.prototype.createElement : undefined; +const documentGetElementByIdIntrinsic = directDomAvailable + ? Document.prototype.getElementById + : undefined; const nodeAppendChildIntrinsic = directDomAvailable ? Node.prototype.appendChild : undefined; const nodeRemoveChildIntrinsic = directDomAvailable ? Node.prototype.removeChild : undefined; const elementRemoveIntrinsic = directDomAvailable ? Element.prototype.remove : undefined; @@ -65,6 +70,9 @@ const elementNamespaceGetter = directDomAvailable const elementChildrenGetter = directDomAvailable ? Object.getOwnPropertyDescriptor(Element.prototype, 'children')?.get : undefined; +const elementIdGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(Element.prototype, 'id')?.get + : undefined; const htmlCollectionLengthGetter = directDomAvailable ? Object.getOwnPropertyDescriptor(HTMLCollection.prototype, 'length')?.get : undefined; @@ -99,6 +107,10 @@ export function prepareApsRenderSource( export interface DirectApsAttemptOptions { readonly attempt: RenderAttempt; + readonly bindArtifactGuard: ( + artifact: CommittedRenderArtifact, + current: () => boolean + ) => boolean; readonly bootstrapNonces: BootstrapNonceRegistry; readonly container: HTMLElement; readonly messaging: MessagingAdapter; @@ -108,6 +120,8 @@ export interface DirectApsAttemptOptions { export interface PucApsAttemptOptions extends DirectApsAttemptOptions { readonly baseArtifact: CommittedRenderArtifact; + readonly bindArtifact: ApsSlotMountBinding['bindArtifact']; + readonly hostPositions: ArtifactHostPositionLeaseRegistry; readonly isBindingCurrent: () => boolean; readonly onArtifactTransferred: () => void; } @@ -224,6 +238,7 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole !directRenderDocument || !directIframePrototype || typeof documentCreateElementIntrinsic !== 'function' || + typeof documentGetElementByIdIntrinsic !== 'function' || typeof nodeAppendChildIntrinsic !== 'function' || typeof nodeRemoveChildIntrinsic !== 'function' || typeof elementRemoveIntrinsic !== 'function' || @@ -238,6 +253,7 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole typeof elementLocalNameGetter !== 'function' || typeof elementNamespaceGetter !== 'function' || typeof elementChildrenGetter !== 'function' || + typeof elementIdGetter !== 'function' || typeof htmlCollectionLengthGetter !== 'function' || typeof iframeContentWindowGetter !== 'function' || typeof iframeSourceGetter !== 'function' @@ -246,6 +262,7 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole } let attempt: RenderAttempt; + let bindArtifactGuard: DirectApsAttemptOptions['bindArtifactGuard']; let bootstrapNonces: BootstrapNonceRegistry; let rendererNonces: RendererNonceRegistry; let messaging: MessagingAdapter; @@ -259,10 +276,14 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole let ownerDocument: Document; let mode: ApsTopPageAttemptOptions['mode']; let baseArtifact: CommittedRenderArtifact | undefined; + let bindArtifact: ApsSlotMountBinding['bindArtifact'] | undefined; + let hostPositions: ArtifactHostPositionLeaseRegistry | undefined; let isBindingCurrent: () => boolean; let onArtifactTransferred: () => void; + let expectedContainerId: string | undefined; try { attempt = options.attempt; + bindArtifactGuard = options.bindArtifactGuard; bootstrapNonces = options.bootstrapNonces; rendererNonces = options.nonces; messaging = options.messaging; @@ -276,9 +297,15 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole ownerDocument = Reflect.apply(nodeOwnerDocumentGetter, container, []) as Document; mode = options.mode; baseArtifact = options.mode === 'puc_overlay' ? options.baseArtifact : undefined; + bindArtifact = options.mode === 'puc_overlay' ? options.bindArtifact : undefined; + hostPositions = options.mode === 'puc_overlay' ? options.hostPositions : undefined; isBindingCurrent = options.mode === 'puc_overlay' ? options.isBindingCurrent : () => true; onArtifactTransferred = options.mode === 'puc_overlay' ? options.onArtifactTransferred : () => undefined; + expectedContainerId = + options.mode === 'puc_overlay' + ? (Reflect.apply(elementIdGetter, container, []) as string) + : undefined; } catch { return false; } @@ -293,7 +320,12 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole } const renderer = prepareApsRenderSource(sourceCandidate, publisherOrigin); const rendererUrl = resolveApsRendererV1Url(publisherOrigin); - if (!exactDocumentOrigin || !renderer || !rendererUrl) { + if ( + !exactDocumentOrigin || + !renderer || + !rendererUrl || + (mode === 'puc_overlay' && !expectedContainerId) + ) { try { attempt.fail('winner_not_renderable'); } catch { @@ -489,9 +521,7 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole let insertionPredecessors: readonly Element[] = []; let baseArtifactDisposed = false; let baseArtifactTransferred = false; - let hostPositionOwned = false; - let previousHostPosition = ''; - let previousHostPositionPriority = ''; + let hostPositionBound = false; const expectedFrameSource = rendererUrl + '#' + bootstrapNonce; const disposeBaseArtifact = (): void => { @@ -505,18 +535,8 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole }; const restoreHostPosition = (): void => { - if (!hostPositionOwned) return; - hostPositionOwned = false; try { - const style = container.style; - if ( - style.getPropertyValue('position') !== 'relative' || - style.getPropertyPriority('position') !== '' - ) { - return; - } - if (previousHostPosition === '') style.removeProperty('position'); - else style.setProperty('position', previousHostPosition, previousHostPositionPriority); + hostPositions?.release(artifact); } catch { // Compare-owned style restoration cannot expand into publisher styles. } @@ -543,8 +563,18 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole } }; + let artifactBinding: + | Readonly<{ + commit: () => boolean; + finalize: () => void; + isCurrent: () => boolean; + previousArtifact: CommittedRenderArtifact | undefined; + release: () => void; + rollback: () => void; + }> + | undefined; const artifact: CommittedRenderArtifact = freeze({ - kind: mode === 'direct' ? ('direct_iframe' as const) : ('puc' as const), + kind: 'aps_mount' as const, attemptId, slot: attemptSlot, navigationGeneration, @@ -564,10 +594,53 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole // DOM removal remains best-effort under a hostile publisher realm. } restoreHostPosition(); + artifactBinding?.release(); disposeBaseArtifact(); }, }); + const persistentFrameCurrent = (): boolean => { + try { + return ( + insertionCommitted && + !disposed && + Reflect.apply(nodeOwnerDocumentGetter, iframe, []) === ownerDocument && + Reflect.apply(nodeParentNodeGetter, iframe, []) === container && + Reflect.apply(nodeIsConnectedGetter, iframe, []) === true && + (mode !== 'puc_overlay' || + (Reflect.apply(elementIdGetter, container, []) === expectedContainerId && + Reflect.apply(documentGetElementByIdIntrinsic, ownerDocument, [expectedContainerId]) === + container)) && + Reflect.apply(iframeContentWindowGetter, iframe, []) === frameSource && + Reflect.apply(elementGetAttributeIntrinsic, iframe, ['src']) === expectedFrameSource && + Reflect.apply(iframeSourceGetter, iframe, []) === expectedFrameSource && + Reflect.apply(elementGetAttributeIntrinsic, iframe, ['sandbox']) === + APS_PERMANENT_SANDBOX && + (mode !== 'puc_overlay' || + !hostPositionBound || + hostPositions?.current(artifact) === true) && + (mode !== 'puc_overlay' || artifactBinding?.isCurrent() === true) + ); + } catch { + return false; + } + }; + if (!bindArtifactGuard(artifact, persistentFrameCurrent)) { + artifact.dispose(); + return fail('internal_error'); + } + if (mode === 'puc_overlay') { + try { + artifactBinding = bindArtifact?.(artifact); + } catch { + artifactBinding = undefined; + } + if (!artifactBinding) { + artifact.dispose(); + return fail('slot_unresolved'); + } + } + const startupFailure = (reason: RenderFailureReason): false => { if (!artifactOwnedByAttempt) artifact.dispose(); return fail(reason); @@ -614,15 +687,35 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole const view = ownerDocument.defaultView; if (!view) return false; const computed = view.getComputedStyle(container); - if (computed.position !== 'static') return true; + if (computed.position !== 'static') { + const previousArtifact = artifactBinding?.previousArtifact; + hostPositionBound = Boolean( + previousArtifact && hostPositions?.inherit(artifact, previousArtifact, container) + ); + return true; + } const style = container.style; - previousHostPosition = style.getPropertyValue('position'); - previousHostPositionPriority = style.getPropertyPriority('position'); + const previousPosition = style.getPropertyValue('position'); + const previousPriority = style.getPropertyPriority('position'); style.setProperty('position', 'relative'); - hostPositionOwned = - style.getPropertyValue('position') === 'relative' && - style.getPropertyPriority('position') === ''; - return hostPositionOwned && isBindingCurrent(); + if ( + style.getPropertyValue('position') !== 'relative' || + style.getPropertyPriority('position') !== '' + ) { + return false; + } + hostPositionBound = + hostPositions?.bindOwned(artifact, container, previousPosition, previousPriority) === true; + return hostPositionBound && isBindingCurrent(); + } catch { + return false; + } + }; + + const claimOverlayPositionLease = (): boolean => { + if (mode !== 'puc_overlay' || !hostPositionBound) return true; + try { + return isBindingCurrent() && hostPositions?.claim(artifact) === true; } catch { return false; } @@ -740,12 +833,38 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole return; } } - if ( - Reflect.apply(acceptMethod, attempt, []) === true && - !disposed && - exactFrameBinding(true) - ) { - if (mode === 'direct') commitContainer(insertionPredecessors); + let artifactAssociationCommitted = false; + if (mode === 'puc_overlay') { + try { + artifactAssociationCommitted = artifactBinding?.commit() === true; + } catch { + artifactAssociationCommitted = false; + } + if (!artifactAssociationCommitted) { + fail('slot_unresolved'); + return; + } + if (!claimOverlayPositionLease()) { + artifactBinding?.rollback(); + fail('slot_unresolved'); + return; + } + } + const accepted = (() => { + try { + return Reflect.apply(acceptMethod, attempt, []) === true; + } catch { + return false; + } + })(); + if (!accepted) { + if (artifactAssociationCommitted) artifactBinding?.rollback(); + if (!disposed) fail('internal_error'); + return; + } + if (artifactAssociationCommitted) artifactBinding?.finalize(); + if (!disposed && exactFrameBinding(true) && mode === 'direct') { + commitContainer(insertionPredecessors); } return; } 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 41f0bf9a7..215008c6b 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -30,6 +30,7 @@ import type { RuntimeCapabilityV1 } from '../../kernel/runtime'; import type { NavigationSession, RenderAttemptScope, RuntimeSession } from '../../kernel/sessions'; import type { IdentityGenerationResult } from '../../kernel/identity'; import type { + CommittedArtifactStore, CommittedRenderArtifact, RenderAttempt, RenderAttemptCreationResult, @@ -267,7 +268,13 @@ export function adoptInitialGptFactsFromHandoff( export function adoptInitialGptSlotsFromHandoff( candidate: unknown, navigationGeneration: object, - service: Pick + service: Pick< + SlotService, + 'adoptCommittedArtifact' | 'adoptGptSlot' | 'adoptRegistrationHighWater' + >, + artifactStore: Pick, + targeting: Pick, + adapter: GoogletagAdapter ): PersistentFirstDisplayAdoptionV1 | undefined { const adoption = snapshotPersistentFirstDisplayAdoptionV1(candidate); if (!adoption) return undefined; @@ -304,12 +311,14 @@ export function adoptInitialGptSlotsFromHandoff( const domId = dataField(placement, 'domId'); const gamPath = dataField(placement, 'gamPath'); const formats = frozenArray(dataField(placement, 'formats')); + const targetingOwnership = frozenArray(dataField(placement, 'targetingOwnership')); if ( typeof slotId !== 'string' || adopted.has(slotId) || (owner !== 'publisher' && owner !== 'trusted_server') || typeof identity !== 'object' || identity === null || + !targetingOwnership || (owner === 'trusted_server' && (typeof domId !== 'string' || typeof gamPath !== 'string' || !formats)) ) { @@ -329,6 +338,75 @@ export function adoptInitialGptSlotsFromHandoff( : { ownership: owner, slot: identity }; const result = service.adoptGptSlot(navigationGeneration, slotId, binding); if (!result.ok) return undefined; + const artifact = artifactStore.current(slotId); + if (!artifact) return undefined; + const releases: TargetingOwnership[] = []; + let observation: ReturnType | undefined; + const releaseTargeting = (): void => { + for (let releaseIndex = releases.length - 1; releaseIndex >= 0; releaseIndex -= 1) { + try { + releases[releaseIndex]?.release(); + } catch { + // Exact artifact retirement contains hostile GPT targeting cleanup. + } + } + releases.length = 0; + try { + observation?.dispose(); + } catch { + // Adapter observation cleanup cannot restore retired ownership. + } + observation = undefined; + }; + try { + if (targetingOwnership.length > 0) { + observation = targeting.observePublisherMutations(identity, adapter); + if (observation.status !== 'present') { + releaseTargeting(); + return undefined; + } + const boundary = synchronousTargetingBoundary(adapter, identity); + for ( + let ownershipIndex = 0; + ownershipIndex < targetingOwnership.length; + ownershipIndex += 1 + ) { + const ownership = targetingOwnership[ownershipIndex]; + const key = dataField(ownership, 'key'); + const installed = dataField(ownership, 'installed'); + const prior = frozenArray(dataField(ownership, 'prior')); + if ( + typeof key !== 'string' || + typeof installed !== 'string' || + !prior || + prior.some((value) => typeof value !== 'string') + ) { + releaseTargeting(); + return undefined; + } + const adopted = targeting.adopt( + identity, + key, + installed, + prior as readonly string[], + artifact.attemptId, + boundary + ); + if (!adopted) { + releaseTargeting(); + return undefined; + } + releases.push(adopted); + } + } + } catch { + releaseTargeting(); + return undefined; + } + if (!service.adoptCommittedArtifact(navigationGeneration, slotId, artifact, releaseTargeting)) { + releaseTargeting(); + return undefined; + } adopted.add(slotId); } return adoption; @@ -428,6 +506,10 @@ interface ProductionRenderCapability { current: (slot: string) => CommittedRenderArtifact | undefined; release: (artifact: CommittedRenderArtifact) => boolean; }>; + readonly bindArtifactRetirement: ( + artifact: CommittedRenderArtifact, + retire: () => void + ) => boolean; readonly createAttempt: ( owner: RenderAttemptScope, parentAttemptId?: string @@ -1596,6 +1678,7 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg typeof runtime.protectFirstDisplayAttemptBatch !== 'function' || typeof slotCapability.attachPhysicalService !== 'function' || typeof render.attachPucGamAttemptRegistrar !== 'function' || + typeof render.bindArtifactRetirement !== 'function' || typeof render.createAttempt !== 'function' || typeof render.createSlotOperation !== 'function' || typeof render.commitPageBids !== 'function' || @@ -1625,9 +1708,10 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg document.defaultView.MutationObserver ); const slots = createSlotService({ - disposeCommittedArtifact: (navigationGeneration, registeredSlotId) => { + bindCommittedArtifactRetirement: render.bindArtifactRetirement, + disposeCommittedArtifact: (navigationGeneration, registeredSlotId, expectedArtifact) => { const artifact = render.artifacts.current(registeredSlotId); - if (artifact?.navigationGeneration === navigationGeneration) + if (artifact === expectedArtifact && artifact.navigationGeneration === navigationGeneration) render.artifacts.release(artifact); }, googletag, @@ -1849,7 +1933,10 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg : adoptInitialGptSlotsFromHandoff( adoptionCandidate, auction.navigation.generation, - slots + slots, + render.artifacts, + targeting, + googletag ); if (adoptionCandidate !== undefined && (!diagnosticsAdoption || !adoption)) { throw new TypeError('GPT first-display adoption is invalid'); diff --git a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts index 305437e1c..ae0dbdff6 100644 --- a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts @@ -58,12 +58,16 @@ import { import { createReservationService, type ReservationService } from '../../services/reservations'; import type { PucGamAttemptInput } from '../../services/puc_bridge'; import { + bindCommittedArtifactGuard, + bindCommittedArtifactRetirement, + createArtifactHostPositionLeaseRegistry, createCommittedArtifactStore, createBootstrapNonceRegistry, createRenderAttempt, createRendererNonceRegistry, createSlotOperation, renderDirectAdmAttempt, + type ArtifactHostPositionLeaseRegistry, type CommittedArtifactStore, type CommittedRenderArtifact, type RenderAttempt, @@ -271,46 +275,148 @@ export function adoptInitialRenderArtifactsFromHandoff( candidate: unknown, navigation: NavigationSession, store: CommittedArtifactStore, + hostPositions: ArtifactHostPositionLeaseRegistry, document: Document ): AdoptedInitialRenderArtifactsV1 | undefined { const adoption = snapshotPersistentFirstDisplayAdoptionV1(candidate); if (!adoption) return undefined; const cycles = adoptionArray(adoptionField(adoption.handoff, 'cycles')); const artifacts = adoptionArray(adoptionField(adoption.handoff, 'artifacts')); - if (!cycles || !artifacts || adoption.identities.length !== cycles.length + artifacts.length) { + const handoffSlots = adoptionArray(adoptionField(adoption.handoff, 'slots')); + if ( + !cycles || + !artifacts || + !handoffSlots || + adoption.identities.length !== cycles.length + artifacts.length || + new Set(adoption.identities).size !== adoption.identities.length + ) { return undefined; } if (artifacts.length === 0) { return Object.freeze({ adoption, arm: () => undefined }); } const Frame = document.defaultView?.HTMLIFrameElement; + const Element = document.defaultView?.HTMLElement; const batch = navigation.createAuctionBatch('first-display-adoption'); - if (!Frame || !batch) return undefined; + if (!Frame || !Element || !batch) return undefined; const promoted: CommittedRenderArtifact[] = []; let armed = false; try { for (let index = 0; index < artifacts.length; index += 1) { const record = artifacts[index]; const slotId = adoptionField(record, 'slotId'); + const kind = adoptionField(record, 'kind'); + const owner = adoptionField(record, 'owner'); + const token = adoptionField(record, 'token'); + const hostPosition = adoptionField(record, 'hostPosition'); + const hostPositionPriority = adoptionField(record, 'hostPositionPriority'); const identity = adoption.identities[cycles.length + index]; - if (typeof slotId !== 'string' || !(identity instanceof Frame)) return undefined; + const matchingSlots = handoffSlots.filter((entry) => adoptionField(entry, 'id') === slotId); + const matchingCycles = cycles.filter((entry) => adoptionField(entry, 'slotId') === slotId); + const domId = adoptionField(matchingSlots[0], 'domId'); + const host = typeof domId === 'string' ? document.getElementById(domId) : null; + if ( + typeof slotId !== 'string' || + (kind !== 'aps' && kind !== 'gpt_adm') || + owner !== 'trusted_server' || + typeof token !== 'string' || + !/^r1_[A-Za-z0-9_-]{22}$/.test(token) || + (hostPosition === null && hostPositionPriority !== null) || + (hostPosition !== null && + (typeof hostPosition !== 'string' || + (hostPositionPriority !== '' && hostPositionPriority !== 'important'))) || + (kind === 'gpt_adm' && hostPosition !== null) || + matchingSlots.length !== 1 || + matchingCycles.length !== 1 || + !(host instanceof Element) || + !(identity instanceof Frame) + ) { + return undefined; + } + const previousHostPosition = hostPosition as string | null; + const previousHostPositionPriority = hostPositionPriority as '' | 'important' | null; + const frame = identity; + const expectedContentWindow = frame.contentWindow; + const expectedSourceAttribute = frame.getAttribute('src'); + const expectedSource = frame.src; + const expectedSourceDocumentAttribute = frame.getAttribute('srcdoc'); + const expectedSourceDocument = frame.srcdoc; + const expectedSandbox = frame.getAttribute('sandbox'); + const expectedStyle = frame.getAttribute('style'); + if ( + !host.isConnected || + host.ownerDocument !== document || + frame.parentNode !== host || + !frame.isConnected || + frame.ownerDocument !== document || + !expectedContentWindow || + (kind === 'aps' && + hostPosition !== null && + (host.style.getPropertyValue('position') !== 'relative' || + host.style.getPropertyPriority('position') !== '')) + ) { + return undefined; + } const attempt = batch.createRenderAttempt(slotId); if (!attempt.ok) return undefined; - const frame = identity; + let disposed = false; const artifact: CommittedRenderArtifact = Object.freeze({ - kind: 'direct_iframe' as const, + kind: kind === 'aps' ? ('aps_mount' as const) : ('direct_iframe' as const), attemptId: attempt.value.id, slot: slotId, navigationGeneration: navigation.generation, dispose: (): void => { + if (disposed) return; + disposed = true; if (!armed) return; try { frame.remove(); } catch { // A publisher DOM replacement wins over persistent artifact retirement. } + hostPositions.release(artifact); }, }); + if ( + kind === 'aps' && + previousHostPosition !== null && + !hostPositions.bindOwned( + artifact, + host, + previousHostPosition, + previousHostPositionPriority ?? '' + ) + ) { + return undefined; + } + if ( + !bindCommittedArtifactGuard(artifact, () => { + try { + return ( + !disposed && + navigation.isCurrent() && + document.getElementById(domId as string) === host && + host.isConnected && + host.ownerDocument === document && + frame.parentNode === host && + frame.isConnected && + frame.ownerDocument === document && + frame.contentWindow === expectedContentWindow && + frame.getAttribute('src') === expectedSourceAttribute && + frame.src === expectedSource && + frame.getAttribute('srcdoc') === expectedSourceDocumentAttribute && + frame.srcdoc === expectedSourceDocument && + frame.getAttribute('sandbox') === expectedSandbox && + frame.getAttribute('style') === expectedStyle && + (kind !== 'aps' || previousHostPosition === null || hostPositions.current(artifact)) + ); + } catch { + return false; + } + }) + ) { + return undefined; + } if (!store.promote(artifact, () => navigation.isCurrent())) return undefined; promoted.push(artifact); } @@ -626,6 +732,21 @@ export function createRenderRuntimeIntegrationRegistration( const artifacts = createCommittedArtifactStore(); scope.onDispose(() => artifacts.dispose()); + const hostPositions = createArtifactHostPositionLeaseRegistry(); + const ArtifactMutationObserver = view.MutationObserver; + if (typeof ArtifactMutationObserver !== 'function') { + throw new TypeError('render_runtime requires artifact DOM observation'); + } + const artifactObserver = new ArtifactMutationObserver(() => { + artifacts.sweep(); + }); + artifactObserver.observe(document, { + attributeFilter: ['id', 'sandbox', 'src', 'srcdoc', 'style'], + attributes: true, + childList: true, + subtree: true, + }); + scope.onDispose(() => artifactObserver.disconnect()); const slots = createRuntimeSlotBroker(); scope.onDispose(() => slots.dispose()); @@ -988,7 +1109,10 @@ export function createRenderRuntimeIntegrationRegistration( }; }, artifacts, + bindArtifactGuard: bindCommittedArtifactGuard, + bindArtifactRetirement: bindCommittedArtifactRetirement, bootstrapNonces, + hostPositions, createAttempt, createSlotOperation, commitPageBids: ( @@ -1085,6 +1209,7 @@ export function createRenderRuntimeIntegrationRegistration( activation.adoption, navigation, artifacts, + hostPositions, document ); if (activation.adoption !== undefined && (!adoptedState || !adoptedArtifacts)) { 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 09d39050f..078909c8d 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -9,7 +9,7 @@ import type { RenderFailureReason, RenderOutcome, } from './render'; -import type { SlotService } from './slots'; +import type { ApsSlotMountBinding, SlotService } from './slots'; import type { ReservationAttempt, ReservationRecognition, @@ -110,6 +110,7 @@ export interface PucBridgeOptions { export interface PucApsMountInput { readonly attempt: RenderAttempt; readonly baseArtifact: CommittedRenderArtifact; + readonly bindArtifact: ApsSlotMountBinding['bindArtifact']; readonly container: HTMLElement; readonly isBindingCurrent: () => boolean; readonly messaging: MessagingAdapter; @@ -1164,6 +1165,7 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { { attempt: binding.attempt as unknown as RenderAttempt, baseArtifact: binding.artifact, + bindArtifact: mountBinding.bindArtifact, container: mountBinding.element as HTMLElement, isBindingCurrent: mountBinding.isCurrent, messaging, diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 6c3bbc123..de86d784c 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -74,6 +74,8 @@ const stringTrimIntrinsic = String.prototype.trim; const textEncoder = new TextEncoder(); const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; const artifactDisposals = new WeakMap(); +const artifactGuards = new WeakMap boolean>(); +const artifactRetirements = new WeakMap void>(); const committedArtifactStores = new WeakSet(); const renderAttempts = new WeakSet(); const ignoreAsyncDisposal = (): void => undefined; @@ -325,7 +327,7 @@ export type RenderAttemptState = RenderAttemptActiveState | 'accepted' | 'no_bid' | 'failed' | 'cancelled'; export interface CommittedRenderArtifact { - readonly kind: 'direct_iframe' | 'puc'; + readonly kind: 'aps_mount' | 'direct_iframe' | 'puc'; readonly attemptId: string; readonly slot: string; readonly navigationGeneration: object; @@ -336,10 +338,172 @@ export interface CommittedArtifactStore { readonly promote: (artifact: CommittedRenderArtifact, stillCurrent?: () => boolean) => boolean; readonly current: (slot: string) => CommittedRenderArtifact | undefined; readonly release: (artifact: CommittedRenderArtifact) => boolean; + /** Retire every committed artifact whose exact DOM/binding guard is no longer current. */ + readonly sweep: () => number; readonly disposeNavigation: (navigationGeneration: object) => void; readonly dispose: () => void; } +/** Shared exact ownership for the inline position required by absolute render overlays. */ +export interface ArtifactHostPositionLeaseRegistry { + readonly bindOwned: ( + artifact: CommittedRenderArtifact, + host: HTMLElement, + previousPosition: string, + previousPriority: string + ) => boolean; + readonly inherit: ( + artifact: CommittedRenderArtifact, + predecessor: CommittedRenderArtifact, + host: HTMLElement + ) => boolean; + readonly claim: (artifact: CommittedRenderArtifact) => boolean; + readonly current: (artifact: CommittedRenderArtifact) => boolean; + readonly release: (artifact: CommittedRenderArtifact) => void; +} + +interface ArtifactHostPositionLease { + readonly host: HTMLElement; + owner: CommittedRenderArtifact | undefined; + readonly previousPosition: string; + readonly previousPriority: string; +} + +interface ArtifactHostPositionBinding { + live: boolean; + readonly lease: ArtifactHostPositionLease; + readonly predecessor?: CommittedRenderArtifact; +} + +/** Create one render-runtime lease registry shared by adoption and later APS mounts. */ +export function createArtifactHostPositionLeaseRegistry(): ArtifactHostPositionLeaseRegistry { + const bindings = new WeakMap(); + + const styleIsOwned = (host: HTMLElement): boolean => { + try { + return ( + host.style.getPropertyValue('position') === 'relative' && + host.style.getPropertyPriority('position') === '' + ); + } catch { + return false; + } + }; + + const restore = (lease: ArtifactHostPositionLease): void => { + try { + if (!styleIsOwned(lease.host)) return; + if (lease.previousPosition === '') lease.host.style.removeProperty('position'); + else { + lease.host.style.setProperty('position', lease.previousPosition, lease.previousPriority); + } + } catch { + // Compare-owned restoration cannot overwrite a publisher style replacement. + } + }; + + const registry: ArtifactHostPositionLeaseRegistry = { + bindOwned(artifact, host, previousPosition, previousPriority): boolean { + try { + if ( + !validArtifact(artifact) || + bindings.has(artifact) || + !styleIsOwned(host) || + typeof previousPosition !== 'string' || + typeof previousPriority !== 'string' + ) { + return false; + } + const lease: ArtifactHostPositionLease = { + host, + owner: artifact, + previousPosition, + previousPriority, + }; + const binding: ArtifactHostPositionBinding = { lease, live: true }; + bindings.set(artifact, binding); + return bindings.get(artifact) === binding; + } catch { + return false; + } + }, + inherit(artifact, predecessor, host): boolean { + try { + if (!validArtifact(artifact) || bindings.has(artifact) || !styleIsOwned(host)) return false; + const previous = bindings.get(predecessor); + if ( + !previous?.live || + previous.lease.host !== host || + previous.lease.owner !== predecessor + ) { + return false; + } + const binding: ArtifactHostPositionBinding = { + lease: previous.lease, + live: true, + predecessor, + }; + bindings.set(artifact, binding); + return bindings.get(artifact) === binding; + } catch { + return false; + } + }, + claim(artifact): boolean { + try { + const binding = bindings.get(artifact); + if (!binding?.live || !styleIsOwned(binding.lease.host)) return false; + if (binding.lease.owner === artifact) return true; + const predecessor = binding.predecessor; + const previous = predecessor ? bindings.get(predecessor) : undefined; + if ( + !predecessor || + !previous?.live || + previous.lease !== binding.lease || + binding.lease.owner !== predecessor + ) { + return false; + } + binding.lease.owner = artifact; + return true; + } catch { + return false; + } + }, + current(artifact): boolean { + try { + const binding = bindings.get(artifact); + return ( + !binding || + (binding.live && binding.lease.owner === artifact && styleIsOwned(binding.lease.host)) + ); + } catch { + return false; + } + }, + release(artifact): void { + let binding: ArtifactHostPositionBinding | undefined; + try { + binding = bindings.get(artifact); + if (!binding?.live) return; + binding.live = false; + if (binding.lease.owner !== artifact) return; + const predecessor = binding.predecessor; + const previous = predecessor ? bindings.get(predecessor) : undefined; + if (predecessor && previous?.live && previous.lease === binding.lease) { + binding.lease.owner = predecessor; + return; + } + binding.lease.owner = undefined; + } catch { + return; + } + restore(binding.lease); + }, + }; + return frozen(registry); +} + export interface RenderDeadline { readonly milliseconds: number; readonly reason: RenderFailureReason; @@ -633,7 +797,9 @@ function validArtifact( } const artifact = value as CommittedRenderArtifact; return ( - (artifact.kind === 'direct_iframe' || artifact.kind === 'puc') && + (artifact.kind === 'aps_mount' || + artifact.kind === 'direct_iframe' || + artifact.kind === 'puc') && validAttemptId(artifact.attemptId) && typeof artifact.slot === 'string' && artifact.slot.length > 0 && @@ -651,6 +817,56 @@ function validArtifact( } } +/** Bind one immutable artifact to one exact synchronous DOM/binding guard. */ +export function bindCommittedArtifactGuard( + artifact: CommittedRenderArtifact, + current: () => boolean +): boolean { + try { + if ( + !validArtifact(artifact) || + typeof current !== 'function' || + weakMapHas(artifactGuards, artifact) + ) { + return false; + } + weakMapSet(artifactGuards, artifact, current); + return weakMapGet(artifactGuards, artifact) === current; + } catch { + return false; + } +} + +/** Bind one exact metadata-release callback to a committed artifact's disposal latch. */ +export function bindCommittedArtifactRetirement( + artifact: CommittedRenderArtifact, + retire: () => void +): boolean { + try { + if ( + !validArtifact(artifact) || + typeof retire !== 'function' || + weakMapHas(artifactRetirements, artifact) || + artifactDisposalStarted(artifact) + ) { + return false; + } + weakMapSet(artifactRetirements, artifact, retire); + return weakMapGet(artifactRetirements, artifact) === retire; + } catch { + return false; + } +} + +function guardedArtifactCurrent(artifact: CommittedRenderArtifact): boolean { + try { + const guard = weakMapGet(artifactGuards, artifact); + return !guard || Reflect.apply(guard, undefined, []) === true; + } catch { + return false; + } +} + function disposeArtifact(artifact: CommittedRenderArtifact | undefined): boolean { if (!artifact) return true; try { @@ -661,6 +877,16 @@ function disposeArtifact(artifact: CommittedRenderArtifact | undefined): boolean } catch { return false; } + let retirementSucceeded = true; + try { + const retire = weakMapGet(artifactRetirements, artifact); + if (retire) { + weakMapDelete(artifactRetirements, artifact); + Reflect.apply(retire, undefined, []); + } + } catch { + retirementSucceeded = false; + } try { const result = Reflect.apply(artifact.dispose, artifact, []) as unknown; if ((typeof result === 'object' || typeof result === 'function') && result !== null) { @@ -687,7 +913,7 @@ function disposeArtifact(artifact: CommittedRenderArtifact | undefined): boolean } if (result !== undefined) return false; weakMapSet(artifactDisposals, artifact, true); - return true; + return retirementSucceeded; } catch { return false; } @@ -766,6 +992,7 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { mutating || !validArtifact(artifact) || artifactDisposalStarted(artifact) || + !guardedArtifactCurrent(artifact) || weakSetHas(disposedNavigations, artifact.navigationGeneration) || !permitsPromotion(stillCurrent) ) { @@ -780,7 +1007,8 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { disposeRequested || weakSetHas(disposedNavigations, artifact.navigationGeneration) || setHas(pendingNavigationDisposals, artifact.navigationGeneration) || - !permitsPromotion(stillCurrent) + !permitsPromotion(stillCurrent) || + !guardedArtifactCurrent(artifact) ) { return false; } @@ -794,6 +1022,7 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { weakSetHas(disposedNavigations, artifact.navigationGeneration) || setHas(pendingNavigationDisposals, artifact.navigationGeneration) || !permitsPromotion(stillCurrent) || + !guardedArtifactCurrent(artifact) || artifactDisposalStarted(artifact) ) { if (existing && mapGet(entries, artifact.slot) === existing) { @@ -842,6 +1071,34 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { } } }, + sweep(): number { + let retired = 0; + let ownsMutation = false; + try { + if (disposed || mutating) return 0; + mutating = true; + ownsMutation = true; + const snapshot = mapEntrySnapshot(entries); + for (let index = 0; index < snapshot.length; index += 1) { + const entry = snapshot[index]; + if (!entry) continue; + const slot = entry[0]; + const artifact = entry[1]; + if (mapGet(entries, slot) !== artifact || guardedArtifactCurrent(artifact)) continue; + mapDelete(entries, slot); + disposeArtifact(artifact); + retired += 1; + } + return retired; + } catch { + return retired; + } finally { + if (ownsMutation) { + mutating = false; + drainDeferredDisposal(); + } + } + }, disposeNavigation(navigationGeneration): void { let ownsMutation = false; try { @@ -1383,7 +1640,12 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp const sourceType = admittedRenderSource?.type; const sourceMatches = next === 'waiting_for_document' ? sourceType === 'aps' : sourceType === 'adm'; - const artifactKind = state === 'rendering_direct' ? 'direct_iframe' : 'puc'; + const artifactKind = + next === 'waiting_for_document' + ? 'aps_mount' + : state === 'rendering_direct' + ? 'direct_iframe' + : 'puc'; if ( pendingArtifact !== undefined || !validArtifact(artifact, slot, navigationGeneration, id) || diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 42f86c917..63e23e66c 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -25,6 +25,7 @@ import type { ProjectionSlotRegistration, ProjectionSlotRegistry, } from './projections'; +import type { CommittedRenderArtifact } from './render'; /** Shared maximum across server-projected and programmatically admitted slots. */ export const MAX_ACTIVE_SLOT_RECORDS = 256; @@ -146,6 +147,16 @@ export interface ApsSlotMountBinding { readonly element: object; readonly physicalSlot: object; readonly isCurrent: () => boolean; + readonly bindArtifact: (artifact: CommittedRenderArtifact) => + | Readonly<{ + commit: () => boolean; + finalize: () => void; + isCurrent: () => boolean; + previousArtifact: CommittedRenderArtifact | undefined; + release: () => void; + rollback: () => void; + }> + | undefined; } /** Runtime-owned slot registry and physical-cycle boundary. */ @@ -161,6 +172,12 @@ export interface SlotService { navigationGeneration: object, nextOrdinal: number ) => boolean; + readonly adoptCommittedArtifact: ( + navigationGeneration: object, + registeredSlotId: string, + artifact: CommittedRenderArtifact, + onRetire?: () => void + ) => boolean; readonly dispose: () => void; readonly handleGptEvent: ( type: GptEventType, @@ -215,9 +232,14 @@ export interface SlotService { } export interface SlotServiceOptions { + readonly bindCommittedArtifactRetirement?: ( + artifact: CommittedRenderArtifact, + retire: () => void + ) => boolean; readonly disposeCommittedArtifact?: ( navigationGeneration: object, - registeredSlotId: string + registeredSlotId: string, + artifact: CommittedRenderArtifact ) => void; readonly googletag: GoogletagAdapter; readonly now?: () => number; @@ -270,7 +292,7 @@ interface PhysicalCycle { interface PhysicalSlot { activeCycle: PhysicalCycle | undefined; apsMountClaim: Readonly<{ cycle: PhysicalCycle; token: object }> | undefined; - artifactRetirementAttempted: boolean; + committedArtifact: CommittedRenderArtifact | undefined; bindingEpoch: object; definition: GoogletagReplacementDefinition | undefined; domElement: object | undefined; @@ -736,6 +758,7 @@ export function createBrowserSlotReconciliationBoundary( /** Construct the document-lifetime slot registry and physical GPT cycle service. */ export function createSlotService(options: SlotServiceOptions): SlotService { + const bindCommittedArtifactRetirement = options.bindCommittedArtifactRetirement; const navigationStates = new Map(); const registeredSlots = new Map(); const adUnitCodes = new Map>(); @@ -1030,10 +1053,15 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; const retireCommittedArtifact = (record: InternalSlotRecord, physical: PhysicalSlot): void => { - if (physical.artifactRetirementAttempted) return; - physical.artifactRetirementAttempted = true; + const artifact = physical.committedArtifact; + if (!artifact) return; + physical.committedArtifact = undefined; try { - disposeCommittedArtifact?.(record.state.owner.generation, record.view.registeredSlotId); + disposeCommittedArtifact?.( + record.state.owner.generation, + record.view.registeredSlotId, + artifact + ); } catch { // Physical retirement remains authoritative when artifact cleanup throws. } @@ -1061,7 +1089,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const physical: PhysicalSlot = { activeCycle: undefined, apsMountClaim: undefined, - artifactRetirementAttempted: false, + committedArtifact: undefined, bindingEpoch: Object.freeze({}), definition, domElement, @@ -1131,7 +1159,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const orphan: PhysicalSlot = { activeCycle: undefined, apsMountClaim: undefined, - artifactRetirementAttempted: true, + committedArtifact: undefined, bindingEpoch: Object.freeze({}), definition: source.definition, domElement: undefined, @@ -1506,10 +1534,11 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } return physical.activeCycle?.traceHandle; } - if (physical.state !== 'live' || physical.activeCycle) return; const record = physical.record; const intent = record?.activeIntent; if ( + physical.state === 'live' && + !physical.activeCycle && intent && !intent.terminal && intent.state === 'active' && @@ -1529,7 +1558,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { ); return cycle.traceHandle; } + if (record) retireCommittedArtifact(record, physical); if (intent && !intent.terminal) settle(intent, failed('cycle_unattributable')); + if (physical.state !== 'live' || physical.activeCycle) return; return openPhysicalCycle(physical, undefined, 'publisher').traceHandle; } @@ -2379,7 +2410,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const physical: PhysicalSlot = { activeCycle: undefined, apsMountClaim: undefined, - artifactRetirementAttempted: false, + committedArtifact: undefined, bindingEpoch: Object.freeze({}), definition, domElement, @@ -2907,6 +2938,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; const applyPublisherIntent = (physical: PhysicalSlot): void => { + if (physical.record) retireCommittedArtifact(physical.record, physical); if (physical.record?.activeIntent) { settle(physical.record.activeIntent, failed('cycle_unattributable')); } @@ -3353,7 +3385,69 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (physical.apsMountClaim?.token === token) physical.apsMountClaim = undefined; return undefined; } + const bindArtifact = ( + artifact: CommittedRenderArtifact + ): + | Readonly<{ + commit: () => boolean; + finalize: () => void; + isCurrent: () => boolean; + previousArtifact: CommittedRenderArtifact | undefined; + release: () => void; + rollback: () => void; + }> + | undefined => { + try { + if ( + !current() || + !Object.isFrozen(artifact) || + artifact.kind !== 'aps_mount' || + artifact.slot !== registeredSlotId || + artifact.navigationGeneration !== navigationGeneration + ) { + return undefined; + } + const previous = physical.committedArtifact; + let phase: 'prepared' | 'committed' | 'finalized' | 'released' | 'rolled_back' = + 'prepared'; + return Object.freeze({ + commit: (): boolean => { + if (phase !== 'prepared' || !current() || physical.committedArtifact !== previous) { + return false; + } + physical.committedArtifact = artifact; + phase = 'committed'; + return true; + }, + finalize: (): void => { + if (phase === 'committed') phase = 'finalized'; + }, + isCurrent: (): boolean => + (phase === 'committed' || phase === 'finalized') && + physical.committedArtifact === artifact && + current(), + previousArtifact: previous, + release: (): void => { + if (phase === 'released' || phase === 'rolled_back') return; + if (physical.committedArtifact === artifact) { + physical.committedArtifact = phase === 'committed' ? previous : undefined; + } + phase = 'released'; + }, + rollback: (): void => { + if (phase !== 'committed' && phase !== 'released') return; + if (phase === 'committed' && physical.committedArtifact === artifact) { + physical.committedArtifact = previous; + } + phase = 'rolled_back'; + }, + }); + } catch { + return undefined; + } + }; return Object.freeze({ + bindArtifact, bindingEpoch, cycle: cycle.traceHandle, element: resolution.element, @@ -3365,12 +3459,61 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } }; + const adoptCommittedArtifact = ( + navigationGeneration: object, + registeredSlotId: string, + artifact: CommittedRenderArtifact, + onRetire?: () => void + ): boolean => { + try { + const state = mapValue(navigationStates, navigationGeneration); + const record = state ? mapValue(state.records, registeredSlotId) : undefined; + const physical = record?.physical; + if ( + !state || + state.disposed || + !state.owner.isCurrent() || + !record || + !physical || + physical.record !== record || + physical.state !== 'live' || + physical.committedArtifact !== undefined || + !Object.isFrozen(artifact) || + (artifact.kind !== 'aps_mount' && artifact.kind !== 'direct_iframe') || + artifact.slot !== registeredSlotId || + artifact.navigationGeneration !== navigationGeneration || + (onRetire !== undefined && typeof onRetire !== 'function') + ) { + return false; + } + physical.committedArtifact = artifact; + if ( + !bindCommittedArtifactRetirement || + !bindCommittedArtifactRetirement(artifact, () => { + if (physical.committedArtifact === artifact) physical.committedArtifact = undefined; + try { + onRetire?.(); + } catch { + // Exact physical retirement remains authoritative over targeting cleanup failure. + } + }) + ) { + physical.committedArtifact = undefined; + return false; + } + return physical.committedArtifact === artifact; + } catch { + return false; + } + }; + const service: SlotService = Object.freeze({ activate: (): void => { if (reconciliationActive) return; activateReconciliation(); }, activateReconciliation, + adoptCommittedArtifact, start: (): GoogletagOperation => { if (activation) return activation; let subscriptions: BindingSubscriptionAdmission | undefined; diff --git a/crates/trusted-server-js/lib/src/services/targeting.ts b/crates/trusted-server-js/lib/src/services/targeting.ts index a00daa8f6..6107d6405 100644 --- a/crates/trusted-server-js/lib/src/services/targeting.ts +++ b/crates/trusted-server-js/lib/src/services/targeting.ts @@ -19,6 +19,14 @@ export interface TargetingInventorySnapshot { /** Owner-aware targeting operations exposed to render services. */ export interface TargetingService { + readonly adopt: ( + slot: object, + key: string, + installed: string, + predecessor: readonly string[], + ownerId: string, + targeting: TargetingBoundary + ) => TargetingOwnership | undefined; readonly dispose: () => void; readonly disposeOwner: (ownerId: string) => void; readonly invalidatePublisherMutation: (slot: object, key?: string) => void; @@ -138,6 +146,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 publisherMutationTokensBySlot = new WeakMap(); const observationsBySlot = new WeakMap(); const liveFrames = new Set(); const observationReleases = new Set<() => void>(); @@ -343,6 +352,7 @@ export function createTargetingService(): TargetingService { }; const invalidatePublisherMutation = (slot: object, key?: string): void => { + setWeakMapValue(publisherMutationTokensBySlot, slot, {}); const slotChains = weakMapValue(chainsBySlot, slot); if (!slotChains) return; if (key !== undefined) { @@ -365,6 +375,86 @@ export function createTargetingService(): TargetingService { for (const [entryKey, chain] of entries) invalidateChain(slot, slotChains, entryKey, chain); }; + const adopt = ( + slot: object, + key: string, + installed: string, + predecessor: readonly string[], + ownerId: string, + targeting: TargetingBoundary + ): TargetingOwnership | undefined => { + if (disposed) return undefined; + if ((typeof slot !== 'object' || slot === null) && typeof slot !== 'function') { + throw new TypeError('GPT slot object required'); + } + if (key.length === 0 || installed.length === 0 || ownerId.length === 0) { + throw new TypeError('Targeting key, value, and owner are required'); + } + const predecessorValues = copyValues(predecessor); + const mutationToken = weakMapValue(publisherMutationTokensBySlot, slot); + const actual = copyValues(targeting.getTargeting(key)); + if (weakMapValue(publisherMutationTokensBySlot, slot) !== mutationToken) return undefined; + if (!exactInstalledValue(actual, installed)) return undefined; + const observation = weakMapValue(observationsBySlot, slot); + if (observation && !observationIsCurrent(observation)) { + invalidatePublisherMutation(slot); + return undefined; + } + if (weakMapValue(publisherMutationTokensBySlot, slot) !== mutationToken) return undefined; + let slotChains = weakMapValue(chainsBySlot, slot); + if (slotChains && mapValue(slotChains, key)) return undefined; + if (!slotChains) slotChains = new Map(); + const chain: TargetingChain = { frames: [] }; + const frame: TargetingFrame = { + alive: true, + kind: 'frame', + predecessor: { kind: 'publisher', values: predecessorValues }, + boundary: targeting, + installed, + key, + observation, + ownerId, + slot, + }; + const wasNewSlot = weakMapValue(chainsBySlot, slot) === undefined; + let publishedWeakMap = false; + let publishedChain = false; + let publishedFrame = false; + try { + if (wasNewSlot) { + setWeakMapValue(chainsBySlot, slot, slotChains); + if (weakMapValue(chainsBySlot, slot) !== slotChains) throw new Error('journal publication'); + publishedWeakMap = true; + slotCount += 1; + } + setMapValue(slotChains, key, chain); + if (mapValue(slotChains, key) !== chain) throw new Error('journal publication'); + publishedChain = true; + chain.frames[0] = frame; + publishedFrame = true; + addLiveFrame(frame); + frameCount += 1; + } catch (error) { + if (publishedFrame) chain.frames.length = 0; + frame.alive = false; + if (deleteLiveFrame(frame) && frameCount > 0) frameCount -= 1; + if (publishedChain || mapValue(slotChains, key) === chain) deleteMapValue(slotChains, key); + if (weakMapValue(chainsBySlot, slot) === slotChains && mapSize(slotChains) === 0) { + deleteWeakMapValue(chainsBySlot, slot); + if (publishedWeakMap && slotCount > 0) slotCount -= 1; + } + throw error; + } + let released = false; + return Object.freeze({ + ownerId, + release: (): void => { + if (released) return; + released = release(frame); + }, + }); + }; + const own = ( slot: object, key: string, @@ -476,6 +566,7 @@ export function createTargetingService(): TargetingService { }; return Object.freeze({ + adopt, dispose: (): void => { if (disposed) return; disposed = true; diff --git a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts index 25f659679..4f091b494 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts @@ -296,6 +296,38 @@ function snapshotStringPairs( return Object.freeze(result); } +function snapshotTargetingOwnership( + value: unknown +): readonly Readonly<{ installed: string; key: string; prior: readonly string[] }>[] | undefined { + const entries = exactArray(value, MAX_TARGETING); + if (!entries) return undefined; + const seen = new Set(); + const result: Array> = []; + for (const entry of entries) { + const fields = exactRecord(entry, ['installed', 'key', 'prior']); + const prior = fields ? exactArray(fields.prior, MAX_TARGETING) : undefined; + if ( + !fields || + !boundedString(fields.key, MAX_PROPERTY_BYTES) || + !boundedString(fields.installed, MAX_STRING_BYTES) || + !prior || + prior.some((value) => !boundedString(value, MAX_STRING_BYTES, true)) || + seen.has(fields.key) + ) { + return undefined; + } + seen.add(fields.key); + result.push( + Object.freeze({ + installed: fields.installed, + key: fields.key, + prior: Object.freeze([...prior] as string[]), + }) + ); + } + return Object.freeze(result); +} + function snapshotSlot(value: unknown): Readonly> | undefined { const fields = exactRecord(value, [ 'id', @@ -306,6 +338,7 @@ function snapshotSlot(value: unknown): Readonly> | undef 'owner', 'outcome', 'targeting', + 'targetingOwnership', 'committedArtifact', 'gptToken', ]); @@ -329,6 +362,7 @@ function snapshotSlot(value: unknown): Readonly> | undef formats.push(Object.freeze([dimensions[0], dimensions[1]])); } const targeting = snapshotStringPairs(fields.targeting, MAX_TARGETING); + const targetingOwnership = snapshotTargetingOwnership(fields.targetingOwnership); if ( !boundedString(fields.id) || !aliases || @@ -337,6 +371,7 @@ function snapshotSlot(value: unknown): Readonly> | undef (fields.owner !== 'trusted_server' && fields.owner !== 'publisher') || !['accepted', 'no_bid', 'failed', 'cancelled'].includes(fields.outcome as string) || !targeting || + !targetingOwnership || !['none', 'gpt_adm', 'aps'].includes(fields.committedArtifact as string) || (fields.gptToken !== null && (!boundedString(fields.gptToken) || !/^gt1_[1-9a-z][0-9a-z]{0,6}$/.test(fields.gptToken))) @@ -352,6 +387,7 @@ function snapshotSlot(value: unknown): Readonly> | undef owner: fields.owner, outcome: fields.outcome, targeting, + targetingOwnership, committedArtifact: fields.committedArtifact, gptToken: fields.gptToken, }); @@ -393,14 +429,27 @@ function snapshotTombstone(value: unknown): Readonly> | } function snapshotArtifact(value: unknown): Readonly> | undefined { - const fields = exactRecord(value, ['slotId', 'kind', 'owner', 'token']); + const fields = exactRecord(value, [ + 'slotId', + 'kind', + 'owner', + 'token', + 'hostPosition', + 'hostPositionPriority', + ]); if ( !fields || !boundedString(fields.slotId) || (fields.kind !== 'gpt_adm' && fields.kind !== 'aps') || (fields.owner !== 'trusted_server' && fields.owner !== 'publisher') || typeof fields.token !== 'string' || - !/^r1_[A-Za-z0-9_-]{22}$/.test(fields.token) + !/^r1_[A-Za-z0-9_-]{22}$/.test(fields.token) || + (fields.hostPosition !== null && !boundedString(fields.hostPosition, MAX_STRING_BYTES, true)) || + (fields.hostPositionPriority !== null && + fields.hostPositionPriority !== '' && + fields.hostPositionPriority !== 'important') || + (fields.hostPosition === null) !== (fields.hostPositionPriority === null) || + (fields.kind === 'gpt_adm' && fields.hostPosition !== null) ) { return undefined; } @@ -982,8 +1031,19 @@ export function snapshotFirstDisplayHandoffV1( slots.some((slot) => { const artifact = artifactBySlot.get(slot.id as string); const cycle = cycleBySlot.get(slot.id as string); + const targetingOwnership = slot.targetingOwnership as readonly Readonly<{ + installed: string; + key: string; + prior: readonly string[]; + }>[]; if (slot.outcome !== 'accepted') { - return slot.committedArtifact !== 'none' || slot.gptToken !== null || artifact || cycle; + return ( + slot.committedArtifact !== 'none' || + slot.gptToken !== null || + targetingOwnership.length !== 0 || + artifact || + cycle + ); } const targeting = slot.targeting as readonly (readonly [string, string])[]; const reservation = targeting.find(([key]) => key === 'hb_adid')?.[1]; @@ -994,7 +1054,12 @@ export function snapshotFirstDisplayHandoffV1( artifact.kind !== slot.committedArtifact || artifact.token !== reservation || !cycle || - cycle.token !== slot.gptToken + cycle.token !== slot.gptToken || + (slot.owner !== 'publisher' && targetingOwnership.length !== 0) || + targetingOwnership.some( + (ownership) => + targeting.find(([key]) => key === ownership.key)?.[1] !== ownership.installed + ) ); }) || cycles.some((cycle) => { diff --git a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts index 1496b3a0e..7f16bd08c 100644 --- a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts @@ -114,6 +114,8 @@ describe('ADM-only first-display render bridge', () => { expect(h.bridge.closeIngress()).toBe(true); expect(h.bridge.captureHandoff()?.artifacts).toEqual([ { + hostPosition: null, + hostPositionPriority: null, identity: frame, kind: 'gpt_adm', owner: 'trusted_server', @@ -126,6 +128,19 @@ describe('ADM-only first-display render bridge', () => { expect(frame?.isConnected).toBe(true); }); + it('retires one accepted ADM frame without repeating terminal settlement', () => { + const h = harness(); + expect(h.bridge.recordGam(h.cycle, 'gam_empty')).toBe(true); + const frame = h.element.querySelector('iframe'); + frame?.dispatchEvent(new h.dom.window.Event('load')); + expect(h.terminal).toHaveBeenCalledOnce(); + + expect(h.bridge.retire(h.cycle)).toBe(true); + expect(h.bridge.retire(h.cycle)).toBe(false); + expect(frame?.isConnected).toBe(false); + expect(h.terminal).toHaveBeenCalledOnce(); + }); + it('fails the owned frame at the bounded load deadline and rejects APS input', () => { const h = harness(); expect(h.bridge.recordGam(h.cycle, 'gam_empty')).toBe(true); diff --git a/crates/trusted-server-js/lib/test/first_display/agent.test.ts b/crates/trusted-server-js/lib/test/first_display/agent.test.ts index 12f21a467..43cf2619b 100644 --- a/crates/trusted-server-js/lib/test/first_display/agent.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/agent.test.ts @@ -183,6 +183,7 @@ function driver(events: string[]): FirstDisplayDriver & { }), closeIngress: () => true, detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, start: ( _outcomes: readonly FirstDisplayBatchOutcomeV1[], onFirstAction: () => boolean, @@ -214,6 +215,7 @@ describe('bounded first-display agent', () => { captureHandoff: () => undefined, closeIngress: () => true, detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, start: (outcomes: readonly FirstDisplayBatchOutcomeV1[]) => { received.push([...outcomes]); }, @@ -364,6 +366,7 @@ describe('bounded first-display agent', () => { captureHandoff: () => undefined, closeIngress: () => true, detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, start: (_outcomes: readonly FirstDisplayBatchOutcomeV1[], onFirstAction: () => boolean) => { events.push('driver:prepared'); action = onFirstAction; @@ -399,6 +402,7 @@ describe('bounded first-display agent', () => { captureHandoff: () => undefined, closeIngress: () => true, detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, start: (_outcomes: readonly FirstDisplayBatchOutcomeV1[], onFirstAction: () => boolean) => { lateAction = onFirstAction; }, @@ -523,6 +527,7 @@ describe('bounded first-display agent', () => { captureHandoff: () => undefined, closeIngress: () => true, detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, start: () => { throw new Error('boom'); }, @@ -599,6 +604,8 @@ describe('bounded first-display agent', () => { return Object.freeze({ artifacts: Object.freeze([ Object.freeze({ + hostPosition: null, + hostPositionPriority: null, identity: artifact, kind: 'gpt_adm' as const, owner: 'trusted_server' as const, @@ -615,6 +622,7 @@ describe('bounded first-display agent', () => { physicalSlot, placement: projectedBatch.projection.slots[0]!, slotId: 'slot-0', + targetingOwnership: Object.freeze([]), traceToken: 'gt1_1', }), ]), @@ -652,6 +660,7 @@ describe('bounded first-display agent', () => { events.push('detach'); return true; }, + sweepCommittedArtifacts: () => 0, dispose: () => events.push('dispose'), }); const agent = createFirstDisplayAgent({ diff --git a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts index 15715947b..abfe5edf9 100644 --- a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts @@ -46,6 +46,7 @@ function handoff(overrides: Record = {}): Record = {}): Record { domId: `div-${index}`, outcome: 'no_bid', committedArtifact: 'none', + targetingOwnership: [], gptToken: null, })); const attempts = Array.from({ length: count }, (_, index) => ({ @@ -456,6 +465,7 @@ describe('first-display immutable contracts', () => { domId: `div-${slotIndex}`, outcome: 'no_bid', committedArtifact: 'none', + targetingOwnership: [], gptToken: null, targeting: Array.from({ length: 32 }, (_, targetingIndex) => [`k${targetingIndex}`, '']), })); diff --git a/crates/trusted-server-js/lib/test/first_display/driver.test.ts b/crates/trusted-server-js/lib/test/first_display/driver.test.ts index 62bd6a054..9306d6fcc 100644 --- a/crates/trusted-server-js/lib/test/first_display/driver.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/driver.test.ts @@ -89,7 +89,10 @@ function harness() { dropCount: 0, })), captureHandoff: vi.fn(() => [cycleOwner.value].filter(Boolean)), - closeIngress: vi.fn(() => true), + closeIngress: vi.fn(() => { + events.push('gpt:close'); + return true; + }), detachCommittedSlots: vi.fn(() => true), dispose: vi.fn(), }; @@ -109,6 +112,8 @@ function harness() { return true; }), recordFailure: vi.fn(() => true), + retire: vi.fn(() => true), + sweepCommittedArtifacts: vi.fn(() => 0), captureHandoff: vi.fn(() => ({ artifacts: [], clockEpochMs: 0, @@ -116,7 +121,10 @@ function harness() { nextTicketOrdinal: 1, tombstones: [], })), - closeIngress: vi.fn(() => true), + closeIngress: vi.fn(() => { + events.push('render:close'); + return true; + }), detachCommittedArtifacts: vi.fn(() => true), sealTsAdmission: vi.fn(() => events.push('render:seal')), dispose: vi.fn(() => events.push('render:dispose')), @@ -176,6 +184,10 @@ describe('projected first-display driver', () => { h.getRenderTerminal()('failed', 'internal_error'); expect(h.events).toEqual(['render:bind', 'action', 'render:gam:nonempty_gam']); expect(h.terminals).toEqual([['slot-1', 'accepted', null]]); + + h.getGptCallbacks().onRetire?.(h.cycle); + h.getGptCallbacks().onRetire?.(Object.freeze({ ...h.cycle, physicalSlot: {} })); + expect(h.renderer.retire).toHaveBeenCalledExactlyOnceWith(h.cycle); }); it('fails an empty or mismatched physical GPT cycle without guessing', () => { @@ -219,6 +231,8 @@ describe('projected first-display driver', () => { h.driver.sealTsAdmission(); expect(h.driver.closeIngress()).toBe(true); + expect(h.gptBatch.closeIngress).toHaveBeenCalledExactlyOnceWith(['slot-1']); + expect(h.events.slice(-2)).toEqual(['gpt:close', 'render:close']); expect(h.driver.captureHandoff()).toEqual({ artifacts: [], clockEpochMs: 0, @@ -275,6 +289,8 @@ describe('projected first-display driver', () => { bind: () => true, recordGam: () => true, recordFailure: () => true, + retire: () => true, + sweepCommittedArtifacts: () => 0, captureHandoff: () => ({ artifacts: [], clockEpochMs: 0, diff --git a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts index 30ec37539..928386276 100644 --- a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts @@ -4,6 +4,8 @@ import { JSDOM } from 'jsdom'; import { createFirstDisplayGoogletagBatch } from '../../src/first_display/adapters/googletag'; import { snapshotFirstDisplayBatchV1 } from '../../src/first_display/leaf/projection'; +const RESERVATION_ID = `r1_${'a'.repeat(22)}`; + function fixture() { return Object.freeze({ version: 1, @@ -35,7 +37,7 @@ function fixture() { cpm: 1.25, currency: 'USD', targeting: Object.freeze({ hb_pb: '1.25' }), - rendererReservationId: `r1_${'a'.repeat(22)}`, + rendererReservationId: RESERVATION_ID, renderSource: Object.freeze({ type: 'adm', version: 1, @@ -49,6 +51,77 @@ function fixture() { }); } +function twoSlotFixture() { + const secondReservationId = `r1_${'b'.repeat(22)}`; + return Object.freeze({ + version: 1, + projectionDigest: 'b'.repeat(64), + projection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([ + Object.freeze({ slot: 'slot-1', outcome: 'winner', candidateId: 'candidate001' }), + Object.freeze({ slot: 'slot-2', outcome: 'winner', candidateId: 'candidate002' }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'slot-1', + gamUnitPath: '/123/example-one', + divId: 'slot-1', + formats: Object.freeze([Object.freeze([300, 250])]), + targeting: Object.freeze({ placement: 'article' }), + }), + Object.freeze({ + slot: 'slot-2', + gamUnitPath: '/123/example-two', + divId: 'slot-2', + formats: Object.freeze([Object.freeze([728, 90])]), + targeting: Object.freeze({ placement: 'sidebar' }), + }), + ]), + bids: Object.freeze([ + Object.freeze({ + candidateId: 'candidate001', + slot: 'slot-1', + provider: 'example', + upstreamBidId: 'upstream-1', + cpm: 1.25, + currency: 'USD', + targeting: Object.freeze({ hb_pb: '1.25' }), + rendererReservationId: RESERVATION_ID, + renderSource: Object.freeze({ + type: 'adm', + version: 1, + adm: '
example one
', + width: 300, + height: 250, + }), + }), + Object.freeze({ + candidateId: 'candidate002', + slot: 'slot-2', + provider: 'example', + upstreamBidId: 'upstream-2', + cpm: 2.5, + currency: 'USD', + targeting: Object.freeze({ hb_pb: '2.50' }), + rendererReservationId: secondReservationId, + renderSource: Object.freeze({ + type: 'adm', + version: 1, + adm: '
example two
', + width: 728, + height: 90, + }), + }), + ]), + }), + }); +} + function protocol() { return Object.freeze({ deadlines: Object.freeze({ @@ -89,6 +162,7 @@ describe('first-display GPT adapter', () => { }); const listeners = new Map void>(); const mutations = vi.fn(() => true); + const retired = vi.fn(); const slot = { clearTargeting: vi.fn(() => undefined), getSlotElementId: () => 'slot-1', @@ -126,6 +200,7 @@ describe('first-display GPT adapter', () => { onFailure: () => undefined, onFirstAction: () => true, onRenderEnded: () => undefined, + onRetire: retired, }); listeners.get('slotRequested')?.({ slot }); listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); @@ -141,8 +216,9 @@ describe('first-display GPT adapter', () => { isEmpty: false, }); expect(mutations).toHaveBeenCalledTimes(5); + expect(retired).toHaveBeenCalledOnce(); - expect(adapter.closeIngress()).toBe(true); + expect(adapter.closeIngress(['slot-1'])).toBe(true); expect(adapter.captureDiagnosticsHandoff()?.cycles).toEqual([ expect.objectContaining({ nextCycleOrdinal: 3, @@ -280,7 +356,13 @@ describe('first-display GPT adapter', () => { removeEventListener: () => undefined, }; const defineSlot = vi.fn().mockReturnValueOnce(slot).mockReturnValueOnce(replacement); - const destroySlots = vi.fn((_slots?: readonly object[]) => true); + let destroyMode: 'throw' | 'false' | 'true' = 'throw'; + const destroySlots = vi.fn((slots?: readonly object[]) => { + if (destroyMode === 'throw') throw new Error('fictional destroy failure'); + if (destroyMode === 'false') return false; + if (Array.isArray(slots)) (slots as object[]).length = 0; + return true; + }); const binding = { cmd: { push: (command: () => void) => command() }, defineSlot, @@ -295,6 +377,7 @@ describe('first-display GPT adapter', () => { }); const batch = snapshotFirstDisplayBatchV1(fixture())!; let bound: { readonly isCurrent: () => boolean } | undefined; + const retired = vi.fn(); const adapter = createFirstDisplayGoogletagBatch({ browser: dom.window as unknown as Window, clearTimer: () => undefined, @@ -310,6 +393,7 @@ describe('first-display GPT adapter', () => { onFailure: () => undefined, onFirstAction: () => true, onRenderEnded: () => undefined, + onRetire: retired, }); expect(bound?.isCurrent()).toBe(true); @@ -317,10 +401,21 @@ describe('first-display GPT adapter', () => { listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); expect(bound?.isCurrent()).toBe(true); - expect(binding.destroySlots([slot])).toBe(true); + expect(() => binding.destroySlots([slot])).toThrow('fictional destroy failure'); + expect(bound?.isCurrent()).toBe(true); + expect(retired).not.toHaveBeenCalled(); + destroyMode = 'false'; + expect(binding.destroySlots([slot])).toBe(false); + expect(bound?.isCurrent()).toBe(true); + expect(retired).not.toHaveBeenCalled(); + destroyMode = 'true'; + const targets = [slot]; + expect(binding.destroySlots(targets)).toBe(true); + expect(targets).toEqual([]); expect(binding.defineSlot('/publisher/replacement', [[300, 250]], 'slot-1')).toBe(replacement); binding.display('slot-1'); expect(bound?.isCurrent()).toBe(false); + expect(retired).toHaveBeenCalledOnce(); }); it('cancels a pending command and compare-restores an adapter-created GPT queue', () => { @@ -413,13 +508,471 @@ describe('first-display GPT adapter', () => { expect(refresh).toHaveBeenCalledWith([slot], { changeCorrelator: false }); expect(firstAction).toHaveBeenCalledOnce(); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); slot.setTargeting('placement', 'article'); + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(adapter.captureHandoff()?.[0]?.targetingOwnership).toEqual([ + { installed: RESERVATION_ID, key: 'hb_adid', prior: [] }, + { installed: '1.25', key: 'hb_pb', prior: ['publisher-original'] }, + ]); adapter.dispose(); expect(targeting.get('placement')).toEqual(['article']); expect(targeting.get('hb_pb')).toEqual(['publisher-original']); expect(targeting.has('hb_adid')).toBe(false); }); + it('rejects targeting handoff after a publisher replaces an observed method', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + const replacement = vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }); + Object.defineProperty(slot, 'setTargeting', { + configurable: true, + value: replacement, + writable: true, + }); + slot.setTargeting('hb_pb', '1.25'); + + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(adapter.captureHandoff()?.[0]?.targetingOwnership).toEqual([]); + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(replacement).toHaveBeenCalledWith('hb_pb', '1.25'); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + }); + + it.each(['close', 'dispose'] as const)( + 'does not restore stale publisher targeting through a replacement during %s cleanup', + (cleanup) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + const replacement = vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }); + Object.defineProperty(slot, 'setTargeting', { + configurable: true, + value: replacement, + writable: true, + }); + slot.setTargeting('hb_pb', '1.25'); + + if (cleanup === 'close') { + expect(adapter.closeIngress([])).toBe(true); + adapter.dispose(); + } else { + adapter.dispose(); + } + expect(replacement).toHaveBeenCalledOnce(); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + } + ); + + it.each(['close', 'dispose'] as const)( + 'honors a same-value publisher write from getTargeting during %s cleanup', + (cleanup) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + let cleanupPhase = false; + let reentrantCleanupCalls = 0; + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: vi.fn((key: string) => { + if (cleanupPhase && key === 'hb_pb' && reentrantCleanupCalls === 0) { + reentrantCleanupCalls += 1; + slot.setTargeting('hb_pb', '1.25'); + } + return targeting.get(key) ?? []; + }), + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + cleanupPhase = true; + if (cleanup === 'close') { + expect(adapter.closeIngress([])).toBe(true); + adapter.dispose(); + } else { + adapter.dispose(); + } + expect(reentrantCleanupCalls).toBe(1); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + } + ); + + it('seals retained targeting while same-value publisher writes are still observed', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + let sealing = false; + let reentrantSealingCalls = 0; + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: vi.fn((key: string) => { + if (sealing && key === 'hb_pb' && reentrantSealingCalls === 0) { + reentrantSealingCalls += 1; + slot.setTargeting('hb_pb', '1.25'); + } + return targeting.get(key) ?? []; + }), + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + sealing = true; + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect( + adapter.captureHandoff()?.[0]?.targetingOwnership.some(({ key }) => key === 'hb_pb') + ).toBe(false); + expect(reentrantSealingCalls).toBe(1); + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + }); + + it('cleans failed TS slots while publisher targeting observation is still continuous', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + const publisherSlot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return publisherSlot; + }), + }; + const failedSlot = { + addService: () => failedSlot, + getSlotElementId: () => 'slot-2', + setTargeting: () => failedSlot, + }; + const listeners = new Map void>(); + const destroySlots = vi.fn((slots: object[]) => { + expect(slots).toEqual([failedSlot]); + slots.length = 0; + publisherSlot.setTargeting('hb_pb', '1.25'); + return true; + }); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [publisherSlot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => failedSlot, + destroySlots, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(twoSlotFixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + for (const slot of [publisherSlot, failedSlot]) { + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + } + + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(destroySlots).toHaveBeenCalledOnce(); + expect( + adapter.captureHandoff()?.[0]?.targetingOwnership.some(({ key }) => key === 'hb_pb') + ).toBe(false); + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(destroySlots).toHaveBeenCalledOnce(); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + }); + + it('cleans failed publisher targeting before handoff with exact nested-write attribution', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const acceptedTargeting = new Map([['hb_pb', ['accepted-original']]]); + const failedTargeting = new Map([['hb_pb', ['failed-original']]]); + let cleanupPhase = false; + let reentrantCleanupCalls = 0; + const acceptedSlot = { + clearTargeting: vi.fn((key: string) => acceptedTargeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => acceptedTargeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + acceptedTargeting.set(key, Array.isArray(value) ? [...value] : [value]); + return acceptedSlot; + }), + }; + const reenterAcceptedSlot = () => { + if (!cleanupPhase || reentrantCleanupCalls > 0) return; + reentrantCleanupCalls += 1; + acceptedSlot.setTargeting('hb_pb', '1.25'); + }; + const failedSlot = { + clearTargeting: vi.fn((key: string) => { + failedTargeting.delete(key); + reenterAcceptedSlot(); + }), + getSlotElementId: () => 'slot-2', + getTargeting: (key: string) => failedTargeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + failedTargeting.set(key, Array.isArray(value) ? [...value] : [value]); + reenterAcceptedSlot(); + return failedSlot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [acceptedSlot, failedSlot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(twoSlotFixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + for (const slot of [acceptedSlot, failedSlot]) { + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + } + + cleanupPhase = true; + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(failedTargeting.get('hb_pb')).toEqual(['failed-original']); + expect(reentrantCleanupCalls).toBe(1); + expect( + adapter.captureHandoff()?.[0]?.targetingOwnership.some(({ key }) => key === 'hb_pb') + ).toBe(false); + const cleanupWrites = + failedSlot.clearTargeting.mock.calls.length + failedSlot.setTargeting.mock.calls.length; + + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(reentrantCleanupCalls).toBe(1); + expect( + failedSlot.clearTargeting.mock.calls.length + failedSlot.setTargeting.mock.calls.length + ).toBe(cleanupWrites); + expect(acceptedTargeting.get('hb_pb')).toEqual(['1.25']); + }); + it('marks refresh as the first request when initial load is disabled', () => { const dom = new JSDOM('
', { url: 'https://publisher.example/', @@ -659,7 +1212,7 @@ describe('first-display GPT adapter', () => { listeners.get('slotRequested')?.({ slot }); listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); - expect(adapter.closeIngress()).toBe(true); + expect(adapter.closeIngress(['slot-1'])).toBe(true); expect(adapter.captureHandoff()).toEqual([ expect.objectContaining({ slotId: 'slot-1', physicalSlot: slot }), ]); @@ -729,7 +1282,7 @@ describe('first-display GPT adapter', () => { listeners.get('slotOnload')?.({ slot }); listeners.get('impressionViewable')?.({ slot }); listeners.get('slotVisibilityChanged')?.({ slot, inViewPercentage: 42 }); - expect(adapter.closeIngress()).toBe(true); + expect(adapter.closeIngress(['slot-1'])).toBe(true); const diagnostics = adapter.captureDiagnosticsHandoff(); expect([...listeners.keys()]).toEqual([ diff --git a/crates/trusted-server-js/lib/test/first_display/handoff.test.ts b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts index e5a43075f..9c6dc7975 100644 --- a/crates/trusted-server-js/lib/test/first_display/handoff.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts @@ -52,6 +52,7 @@ function acceptedHandoff(revision: number): Record { owner: 'trusted_server', outcome: 'accepted', targeting: [['hb_adid', RESERVATION_ID]], + targetingOwnership: [], committedArtifact: 'gpt_adm', gptToken: 'gt1_1', }, @@ -67,6 +68,8 @@ function acceptedHandoff(revision: number): Record { ], artifacts: [ { + hostPosition: null, + hostPositionPriority: null, slotId: 'slot-1', kind: 'gpt_adm', owner: 'trusted_server', @@ -243,6 +246,7 @@ describe('first-display final handoff owner', () => { owner: 'trusted_server', outcome: 'failed', targeting: [], + targetingOwnership: [], committedArtifact: 'none', gptToken: null, }, diff --git a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts index e5df5578a..307cd4dd8 100644 --- a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts @@ -339,6 +339,23 @@ function startApsDocument(h: ReturnType) { return { documentPort, frame, nonce }; } +function acceptPucAps(h: ReturnType): HTMLIFrameElement { + registerOwner(h); + const { documentPort, frame, nonce } = startApsDocument(h); + documentPort.dispatch({ + message: 'TS APS Document Accepted', + version: 1, + nonce, + }); + documentPort.dispatch({ + message: 'TS APS Render Completed', + version: 1, + nonce, + }); + expect(h.terminals).toEqual(['accepted']); + return frame; +} + describe('bounded first-display render bridge', () => { it('observes admitted bridge activity and terminal tombstone expiry', () => { const mutations = vi.fn(() => true); @@ -594,8 +611,71 @@ describe('bounded first-display render bridge', () => { lifecycleTicket: ticket, outcome: 'accepted', }); + expect(h.bridge.retire(h.cycle)).toBe(true); + expect(h.bridge.retire(h.cycle)).toBe(false); + expect(frame.isConnected).toBe(false); + expect(h.element.style.getPropertyValue('position')).toBe(''); + expect(h.element.querySelector('span')?.textContent).toBe('publisher GAM content'); + expect(h.terminals).toEqual(['accepted']); }); + it.each(['removed', 'reparented', 'host_replaced'] as const)( + 'retires an accepted APS overlay before handoff when its DOM is %s', + (mutation) => { + const h = harness('aps'); + h.element.innerHTML = 'publisher'; + const frame = acceptPucAps(h); + const movedHost = h.dom.window.document.createElement('div'); + movedHost.id = 'moved-host'; + h.dom.window.document.body.appendChild(movedHost); + + if (mutation === 'removed') frame.remove(); + if (mutation === 'reparented') movedHost.appendChild(frame); + if (mutation === 'host_replaced') { + const replacement = h.element.cloneNode(false); + h.element.replaceWith(replacement); + } + + expect(h.bridge.sweepCommittedArtifacts()).toBe(1); + expect(h.bridge.sweepCommittedArtifacts()).toBe(0); + expect(frame.isConnected).toBe(false); + expect(h.element.style.getPropertyValue('position')).toBe(''); + expect(h.element.querySelector('span')?.textContent).toBe('publisher'); + expect(h.terminals).toEqual(['accepted']); + + h.bridge.sealTsAdmission(); + expect(h.bridge.closeIngress()).toBe(true); + expect(h.bridge.captureHandoff()?.artifacts).toEqual([]); + expect(h.bridge.detachCommittedArtifacts()).toBe(true); + } + ); + + it.each(['src', 'srcdoc', 'sandbox', 'frame_style', 'host_position'] as const)( + 'retires an accepted APS overlay before handoff when its %s integrity changes', + (mutation) => { + const h = harness('aps'); + h.element.innerHTML = 'publisher'; + const frame = acceptPucAps(h); + + if (mutation === 'src') frame.setAttribute('src', 'https://publisher.example/replaced'); + if (mutation === 'srcdoc') frame.srcdoc = 'Replacement'; + if (mutation === 'sandbox') frame.setAttribute('sandbox', 'allow-scripts'); + if (mutation === 'frame_style') frame.style.setProperty('visibility', 'hidden'); + if (mutation === 'host_position') h.element.style.setProperty('position', 'absolute'); + + expect(h.bridge.sweepCommittedArtifacts()).toBe(1); + expect(h.bridge.sweepCommittedArtifacts()).toBe(0); + expect(frame.isConnected).toBe(false); + expect(h.element.querySelector('span')?.textContent).toBe('publisher'); + expect(h.terminals).toEqual(['accepted']); + + h.bridge.sealTsAdmission(); + expect(h.bridge.closeIngress()).toBe(true); + expect(h.bridge.captureHandoff()?.artifacts).toEqual([]); + expect(h.bridge.detachCommittedArtifacts()).toBe(true); + } + ); + it('renders an attributable empty-GAM ADM fallback directly into the bound element', () => { const h = harness('adm'); expect(h.bridge.recordGam(h.cycle, 'gam_empty')).toBe(true); @@ -839,6 +919,8 @@ describe('bounded first-display render bridge', () => { expect(h.bridge.captureHandoff()).toEqual({ artifacts: [ { + hostPosition: null, + hostPositionPriority: null, identity: frame, kind: 'gpt_adm', owner: 'trusted_server', diff --git a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts index 1e368abd3..41c8d4287 100644 --- a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts @@ -31,6 +31,7 @@ function handoff(revision = 0): Record { owner: 'trusted_server', outcome: 'accepted', targeting: [['hb_adid', RESERVATION_ID]], + targetingOwnership: [], committedArtifact: 'gpt_adm', gptToken: 'gt1_1', }, @@ -47,6 +48,8 @@ function handoff(revision = 0): Record { tombstones: [], artifacts: [ { + hostPosition: null, + hostPositionPriority: null, slotId: 'slot-1', kind: 'gpt_adm', owner: 'trusted_server', diff --git a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts index e7c079a3e..a1772cf23 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts @@ -6,7 +6,10 @@ import type { IntegrationPrepareContext, PreparedIntegration, } from '../../../src/kernel/integration_registry'; -import type { RenderAttempt } from '../../../src/services/render'; +import { + createArtifactHostPositionLeaseRegistry, + type RenderAttempt, +} from '../../../src/services/render'; import type { PucApsMountInput } from '../../../src/services/puc_bridge'; const RELEASE_ID = 'a'.repeat(64); @@ -25,6 +28,9 @@ describe('APS provider', () => { registeredValidation = undefined; }); const render = Object.freeze({ + bindArtifactGuard: vi.fn(() => true), + bootstrapNonces: Object.freeze({}), + hostPositions: createArtifactHostPositionLeaseRegistry(), publisherOrigin: window.location.origin, rendererNonces: Object.freeze({}), registerRenderer: vi.fn( @@ -90,6 +96,9 @@ describe('APS provider', () => { const activationRelease: Array<() => void> = []; const releaseValidation = vi.fn(); const render = Object.freeze({ + bindArtifactGuard: vi.fn(() => true), + bootstrapNonces: Object.freeze({}), + hostPositions: createArtifactHostPositionLeaseRegistry(), publisherOrigin: window.location.origin, rendererNonces: Object.freeze({}), registerRenderer: vi.fn(() => { 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 171a9a908..bb125c70c 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 @@ -33,9 +33,9 @@ import { type CommittedRenderArtifact, type RenderAttempt, } from '../../../src/services/render'; +import { createTargetingService } from '../../../src/services/targeting'; 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)}`; @@ -616,6 +616,16 @@ describe('transactional GPT integration module', () => { const physicalSlot = {}; const frame = {}; const navigationGeneration = {}; + const committedArtifact: CommittedRenderArtifact = Object.freeze({ + attemptId: `a1_${'d'.repeat(22)}`, + dispose: vi.fn(), + kind: 'puc', + navigationGeneration, + slot: 'slot-1', + }); + const artifactStore = createCommittedArtifactStore(); + expect(artifactStore.promote(committedArtifact)).toBe(true); + const adoptCommittedArtifact = vi.fn(() => true); const adoptGptSlot = vi.fn(() => Object.freeze({ ok: true as const })); const adoptRegistrationHighWater = vi.fn(() => true); const adoption = Object.freeze({ @@ -630,6 +640,7 @@ describe('transactional GPT integration module', () => { domId: 'div-1', gamPath: '/123/slot-1', formats: Object.freeze([Object.freeze([300, 250])]), + targetingOwnership: Object.freeze([]), }), ]), cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), @@ -639,10 +650,21 @@ describe('transactional GPT integration module', () => { }); expect( - adoptInitialGptSlotsFromHandoff(adoption, navigationGeneration, { - adoptGptSlot, - adoptRegistrationHighWater, - }) + adoptInitialGptSlotsFromHandoff( + adoption, + navigationGeneration, + { + adoptCommittedArtifact, + adoptGptSlot, + adoptRegistrationHighWater, + }, + artifactStore, + { + adopt: vi.fn(), + observePublisherMutations: vi.fn(), + }, + {} as never + ) ).toBe(adoption); expect(adoptRegistrationHighWater).toHaveBeenCalledExactlyOnceWith(navigationGeneration, 2); expect(adoptGptSlot).toHaveBeenCalledExactlyOnceWith(navigationGeneration, 'slot-1', { @@ -654,8 +676,138 @@ describe('transactional GPT integration module', () => { ownership: 'trusted_server', slot: physicalSlot, }); + expect(adoptCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigationGeneration, + 'slot-1', + committedArtifact, + expect.any(Function) + ); }); + it.each(['unchanged', 'same_publisher_write', 'different_publisher_write'] as const)( + 'transfers first-display targeting ownership and preserves %s semantics', + (mutation) => { + const physicalSlot = {}; + const frame = {}; + const navigationGeneration = {}; + const values = new Map([['hb_adid', ['trusted']]]); + const setTargeting = vi.fn((slot: object, key: string, value: string | readonly string[]) => { + expect(slot).toBe(physicalSlot); + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + const clearTargeting = vi.fn((_slot: object, key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }); + let publisherObserver: + Readonly<{ beforePublisherMutation: (slot: object, key?: string) => void }> | undefined; + const releaseObservation = Object.assign(vi.fn(), { isCurrent: () => true }); + const facade = { + clearTargeting, + getTargeting: (_slot: object, key: string) => Object.freeze([...(values.get(key) ?? [])]), + observeTargeting: ( + slot: object, + observer: Readonly<{ beforePublisherMutation: (slot: object, key?: string) => void }> + ) => { + expect(slot).toBe(physicalSlot); + publisherObserver = observer; + return releaseObservation; + }, + setTargeting, + }; + const adapter = { + run: (command: (gpt: typeof facade) => unknown) => { + const result = command(facade); + return Object.freeze({ + status: 'present' as const, + result: Promise.resolve(result), + dispose: vi.fn(), + }); + }, + } as never; + const artifact: CommittedRenderArtifact = Object.freeze({ + attemptId: `a1_${'e'.repeat(22)}`, + dispose: vi.fn(), + kind: 'aps_mount', + navigationGeneration, + slot: 'slot-1', + }); + const artifactStore = createCommittedArtifactStore(); + expect(artifactStore.promote(artifact)).toBe(true); + let retireTargeting: (() => void) | undefined; + const service = { + adoptCommittedArtifact: vi.fn( + ( + _generation: object, + _slotId: string, + _artifact: CommittedRenderArtifact, + onRetire?: () => void + ) => { + retireTargeting = onRetire; + return true; + } + ), + adoptGptSlot: vi.fn(() => Object.freeze({ ok: true as const })), + adoptRegistrationHighWater: vi.fn(() => true), + }; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + highWater: Object.freeze({ nextSlotRegistrationOrdinal: 2 }), + slots: Object.freeze([ + Object.freeze({ + id: 'slot-1', + owner: 'publisher' as const, + targetingOwnership: Object.freeze([ + Object.freeze({ + installed: 'trusted', + key: 'hb_adid', + prior: Object.freeze(['publisher-original']), + }), + ]), + }), + ]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + const targeting = createTargetingService(); + + expect( + adoptInitialGptSlotsFromHandoff( + adoption, + navigationGeneration, + service, + artifactStore, + targeting, + adapter + ) + ).toBe(adoption); + expect(setTargeting).not.toHaveBeenCalled(); + + if (mutation !== 'unchanged') { + publisherObserver?.beforePublisherMutation(physicalSlot, 'hb_adid'); + values.set( + 'hb_adid', + Object.freeze([mutation === 'same_publisher_write' ? 'trusted' : 'publisher-new']) + ); + } + retireTargeting?.(); + + expect(values.get('hb_adid')).toEqual([ + mutation === 'unchanged' + ? 'publisher-original' + : mutation === 'same_publisher_write' + ? 'trusted' + : 'publisher-new', + ]); + expect(setTargeting).toHaveBeenCalledTimes(mutation === 'unchanged' ? 1 : 0); + expect(releaseObservation).toHaveBeenCalledOnce(); + } + ); + it.each([ { diagnosticsActive: false, adoptInitialDisplay: false, parserStateValid: true }, { diagnosticsActive: true, adoptInitialDisplay: false, parserStateValid: true }, @@ -897,6 +1049,7 @@ describe('transactional GPT integration module', () => { Object.freeze(['hb_adid', RESERVATION_ID] as const), ]), committedArtifact: 'gpt_adm' as const, + targetingOwnership: Object.freeze([]), gptToken: 'gt1_1', }), ]), @@ -912,9 +1065,11 @@ describe('transactional GPT integration module', () => { tombstones: Object.freeze([]), artifacts: Object.freeze([ Object.freeze({ + hostPosition: null, + hostPositionPriority: null, slotId: 'takeover-slot', kind: 'gpt_adm' as const, - owner: 'publisher' as const, + owner: 'trusted_server' as const, token: RESERVATION_ID, }), ]), @@ -1059,7 +1214,7 @@ describe('transactional GPT integration module', () => { }); await vi.waitFor(() => expect(display).toHaveBeenCalledOnce()); expect(protect).not.toHaveBeenCalled(); - expect(activeMutationObservers.size).toBe(2); + expect(activeMutationObservers.size).toBe(3); const firstPhysicalSlot = definedSlots[0]; expect(firstPhysicalSlot).toBeDefined(); takeoverElement.remove(); @@ -1068,10 +1223,13 @@ describe('transactional GPT integration module', () => { document.body.appendChild(replacementElement); await Promise.resolve(); await vi.advanceTimersByTimeAsync(250); - expect(destroySlots).toHaveBeenCalledWith([firstPhysicalSlot]); + expect(destroySlots).toHaveBeenCalledOnce(); + const destroyedPhysicalSlot = destroySlots.mock.calls[0]?.[0]?.[0]; + expect(destroyedPhysicalSlot).toBeDefined(); + expect(destroyedPhysicalSlot).not.toBe(publisherSlot); expect(definedSlots).toHaveLength(1); - expect(definedSlots[0]).not.toBe(firstPhysicalSlot); - expect(activeMutationObservers.size).toBe(2); + expect(definedSlots).not.toContain(destroyedPhysicalSlot); + expect(activeMutationObservers.size).toBe(3); expect(vi.getTimerCount()).toBe(0); vi.useRealTimers(); expect([...providerFacades.keys()]).toEqual([ @@ -1152,7 +1310,7 @@ describe('transactional GPT integration module', () => { expect(refresh).not.toHaveBeenCalled(); const later = gpt.activateLaterLifecycle(); expect(Object.isFrozen(later)).toBe(true); - expect(activeMutationObservers.size).toBe(2); + expect(activeMutationObservers.size).toBe(3); expect(() => gpt.activateLaterLifecycle()).toThrow('unavailable'); const navigationResult = await later.navigate('/spa-production?route=one'); expect(navigationResult).toEqual({ @@ -1261,7 +1419,7 @@ describe('transactional GPT integration module', () => { ((await currentNavigation) as { navigationGeneration: object }).navigationGeneration ); later.release(); - expect(activeMutationObservers.size).toBe(1); + expect(activeMutationObservers.size).toBe(2); await later.navigate('/disposed-owner'); expect(fetchPageBids).toHaveBeenCalledTimes(4); fetchPageBids.mockRestore(); diff --git a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts index 7e7979539..03ce66bfb 100644 --- a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts @@ -15,7 +15,12 @@ import { createRenderRuntimeIntegrationRegistration, } from '../../../src/integrations/render_runtime/module'; import { log } from '../../../src/core/log'; -import { createCommittedArtifactStore, type RenderAttempt } from '../../../src/services/render'; +import { + createArtifactHostPositionLeaseRegistry, + createCommittedArtifactStore, + type CommittedRenderArtifact, + type RenderAttempt, +} from '../../../src/services/render'; import type { NavigationSession } from '../../../src/kernel/sessions'; const RELEASE_ID = 'a'.repeat(64); @@ -126,17 +131,31 @@ describe('render_runtime provider', () => { }); it('adopts transferred DOM artifacts without removing them on rollback, then arms commit ownership', () => { + const host = document.createElement('div'); + host.id = 'div-1'; const frame = document.createElement('iframe'); - document.body.append(frame); + frame.srcdoc = 'Fictional creative'; + host.append(frame); + document.body.append(host); + const physicalSlot = Object.freeze({}); const adoption = Object.freeze({ version: 1 as const, adoptInitialDisplay: true as const, handoff: Object.freeze({ - slots: Object.freeze([Object.freeze({ id: 'slot-1' })]), - cycles: Object.freeze([]), - artifacts: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + kind: 'gpt_adm' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), }), - identities: Object.freeze([frame]), + identities: Object.freeze([physicalSlot, frame]), }); const navigationGeneration = {}; const batch = Object.freeze({ @@ -159,7 +178,13 @@ describe('render_runtime provider', () => { const rollbackStore = createCommittedArtifactStore(); expect( - adoptInitialRenderArtifactsFromHandoff(adoption, navigation, rollbackStore, document) + adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + rollbackStore, + createArtifactHostPositionLeaseRegistry(), + document + ) ).toBeDefined(); rollbackStore.dispose(); expect(frame.isConnected).toBe(true); @@ -169,6 +194,7 @@ describe('render_runtime provider', () => { adoption, navigation, committedStore, + createArtifactHostPositionLeaseRegistry(), document ); expect(committed?.adoption).toBe(adoption); @@ -178,6 +204,215 @@ describe('render_runtime provider', () => { expect(batch.dispose).toHaveBeenCalledTimes(2); }); + it.each(['reparented', 'frame_style'] as const)( + 'retires an adopted APS mount on %s loss and compare-restores only owned style', + (mutation) => { + const host = document.createElement('div'); + host.id = 'div-1'; + host.style.setProperty('position', 'relative'); + const frame = document.createElement('iframe'); + frame.setAttribute('sandbox', 'allow-scripts'); + frame.src = 'https://example.com/renderer'; + host.append(frame); + document.body.append(host); + const physicalSlot = Object.freeze({}); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: vi.fn(() => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }) + ), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: vi.fn(() => batch), + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: '', + hostPositionPriority: '', + kind: 'aps' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + const store = createCommittedArtifactStore(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + createArtifactHostPositionLeaseRegistry(), + document + ); + expect(committed).toBeDefined(); + committed?.arm(); + + if (mutation === 'reparented') { + const publisherContainer = document.createElement('div'); + document.body.append(publisherContainer); + publisherContainer.append(frame); + } else { + frame.style.setProperty('visibility', 'hidden'); + } + + expect(store.sweep()).toBe(1); + expect(frame.isConnected).toBe(false); + expect(host.style.getPropertyValue('position')).toBe(''); + expect(store.sweep()).toBe(0); + } + ); + + it('transfers an adopted APS host-position lease through persistent replacement', () => { + const host = document.createElement('div'); + host.id = 'div-1'; + host.style.setProperty('position', 'relative'); + const frame = document.createElement('iframe'); + frame.src = 'https://example.com/renderer'; + host.append(frame); + document.body.append(host); + const physicalSlot = Object.freeze({}); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: vi.fn(() => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }) + ), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: vi.fn(() => batch), + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: '', + hostPositionPriority: '', + kind: 'aps' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + const store = createCommittedArtifactStore(); + const positions = createArtifactHostPositionLeaseRegistry(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + positions, + document + ); + expect(committed).toBeDefined(); + committed?.arm(); + const predecessor = store.current('slot-1'); + if (!predecessor) throw new Error('should adopt the first-display APS artifact'); + let replacementDisposed = false; + const replacement: CommittedRenderArtifact = Object.freeze({ + attemptId: `a1_${'B'.repeat(22)}`, + kind: 'aps_mount' as const, + navigationGeneration, + slot: 'slot-1', + dispose: () => { + if (replacementDisposed) return; + replacementDisposed = true; + positions.release(replacement); + }, + }); + + expect(positions.inherit(replacement, predecessor, host)).toBe(true); + expect(positions.claim(replacement)).toBe(true); + expect(store.promote(replacement)).toBe(true); + expect(frame.isConnected).toBe(false); + expect(host.style.getPropertyValue('position')).toBe('relative'); + + expect(store.release(replacement)).toBe(true); + expect(host.style.getPropertyValue('position')).toBe(''); + }); + + it('does not restore an adopted APS host style after a publisher replaces it', () => { + const host = document.createElement('div'); + host.id = 'div-1'; + host.style.setProperty('position', 'relative'); + const frame = document.createElement('iframe'); + host.append(frame); + document.body.append(host); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: () => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: () => batch, + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: '', + hostPositionPriority: '', + kind: 'aps' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([Object.freeze({}), frame]), + }); + const store = createCommittedArtifactStore(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + createArtifactHostPositionLeaseRegistry(), + document + ); + expect(committed).toBeDefined(); + committed?.arm(); + + host.style.setProperty('position', 'absolute', 'important'); + expect(store.sweep()).toBe(1); + expect(frame.isConnected).toBe(false); + expect(host.style.getPropertyValue('position')).toBe('absolute'); + expect(host.style.getPropertyPriority('position')).toBe('important'); + }); + it('rolls back prepared resources without unbound disposer failures', () => { const release: Array<() => void> = []; const warn = vi.spyOn(log, 'warn').mockImplementation(() => undefined); diff --git a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts index ab897d6d1..7ce3028c1 100644 --- a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts @@ -146,6 +146,7 @@ describe('integration manifest and registration admission', () => { owner: 'trusted_server', outcome: 'failed', targeting: [], + targetingOwnership: [], committedArtifact: 'none', gptToken: null, }, 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 index 337eb8fac..8177f77dc 100644 --- a/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts @@ -134,6 +134,7 @@ describe('shared integration lifecycle module', () => { owner: 'trusted_server', outcome: 'failed', targeting: [], + targetingOwnership: [], committedArtifact: 'none', gptToken: null, }, 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 00f25aa30..882518012 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -194,6 +194,7 @@ function takeoverHandoff() { owner: 'trusted_server', outcome: 'failed', targeting: [], + targetingOwnership: [], committedArtifact: 'none', gptToken: null, }, 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 285c7f7e8..8bda7e0d3 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 @@ -8,7 +8,12 @@ import { PUC_DYNAMIC_OWNER, type PucBridgeOptions, } from '../../src/services/puc_bridge'; -import type { RenderFailureReason, RenderOutcome } from '../../src/services/render'; +import { + bindCommittedArtifactGuard, + createArtifactHostPositionLeaseRegistry, + type RenderFailureReason, + type RenderOutcome, +} from '../../src/services/render'; import type { ReservationClaimResult, ReservationRecognition, @@ -2755,6 +2760,15 @@ describe('Universal Creative bridge dispatcher', () => { const isCurrent = vi.fn(() => true); const resolveApsMountBinding = vi.fn(() => Object.freeze({ + bindArtifact: () => + Object.freeze({ + commit: () => true, + finalize: () => undefined, + isCurrent: () => true, + previousArtifact: undefined, + release: () => undefined, + rollback: () => undefined, + }), bindingEpoch: Object.freeze({ binding: 1 }), cycle: Object.freeze({ isRetired: () => false }), element: topSlot, @@ -2779,7 +2793,9 @@ describe('Universal Creative bridge dispatcher', () => { mountAps: (input) => renderPucApsAttempt({ ...input, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces: bootstraps, + hostPositions: createArtifactHostPositionLeaseRegistry(), nonces: renderers, publisherOrigin: window.location.origin, }), 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 b4fbd6ed9..89b608fa8 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -16,6 +16,9 @@ import { resolveApsRendererV1Url, } from '../../src/integrations/aps/render'; import { + bindCommittedArtifactGuard, + bindCommittedArtifactRetirement, + createArtifactHostPositionLeaseRegistry, createCommittedArtifactStore, createBootstrapNonceRegistry, createRenderAttempt, @@ -318,6 +321,17 @@ function artifact( }); } +function apsArtifactBinding() { + return Object.freeze({ + commit: vi.fn(() => true), + finalize: vi.fn(), + isCurrent: vi.fn(() => true), + previousArtifact: undefined, + release: vi.fn(), + rollback: vi.fn(), + }); +} + function attempt( scope = owner(), options: Partial[0]> = {} @@ -1195,6 +1209,7 @@ describe('direct APS attempt rendering', () => { expect( renderDirectApsAttempt({ attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces: bootstraps, container: document.getElementById('fictional-slot')!, messaging, @@ -1313,6 +1328,7 @@ describe('direct APS attempt rendering', () => { const container = document.getElementById('fictional-slot')!; const mounted = renderDirectApsAttempt({ attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces: bootstraps, container, messaging, @@ -1653,6 +1669,7 @@ describe('direct APS attempt rendering', () => { expect( renderDirectApsAttempt({ attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces: bootstraps, container, messaging: createBrowserMessagingAdapter(target), @@ -1692,7 +1709,9 @@ describe('direct APS attempt rendering', () => { it('keeps a PUC APS overlay hidden until completion and preserves the slot host', () => { document.body.innerHTML = '
GAM content
'; const scope = owner(); - const render = attempt(scope); + const artifactStore = createCommittedArtifactStore(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); + const render = attempt(scope, { artifacts: artifactStore }); expect(render.beginGamClaim()).toBe(true); expect(render.admitClaimedWinner(claimed(render, scope, DIRECT_APS_SOURCE))).toBe(true); expect(render.ownerClaimed()).toBe(true); @@ -1721,14 +1740,18 @@ describe('direct APS attempt rendering', () => { const rendererPort = browserMessagePort(); const isBindingCurrent = vi.fn(() => true); const onArtifactTransferred = vi.fn(); + const artifactBinding = apsArtifactBinding(); try { expect( renderPucApsAttempt({ attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, baseArtifact, + bindArtifact: () => artifactBinding, bootstrapNonces: bootstraps, container, + hostPositions, isBindingCurrent, messaging, nonces: renderers, @@ -1783,9 +1806,22 @@ describe('direct APS attempt rendering', () => { }); expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(artifactBinding.commit).toHaveBeenCalledOnce(); + expect(artifactBinding.finalize).toHaveBeenCalledOnce(); + expect(artifactBinding.release).not.toHaveBeenCalled(); + expect(artifactBinding.rollback).not.toHaveBeenCalled(); expect(frame.style.visibility).toBe('visible'); expect(container.querySelector('span')?.textContent).toBe('GAM content'); expect(baseArtifact.dispose).not.toHaveBeenCalled(); + + container.style.setProperty('position', 'absolute', 'important'); + expect(artifactStore.sweep()).toBe(1); + expect(frame.isConnected).toBe(false); + expect(container.querySelector('span')?.textContent).toBe('GAM content'); + expect(container.style.getPropertyValue('position')).toBe('absolute'); + expect(container.style.getPropertyPriority('position')).toBe('important'); + expect(baseArtifact.dispose).toHaveBeenCalledOnce(); + expect(artifactBinding.release).toHaveBeenCalledOnce(); } finally { bootstraps.dispose(); renderers.dispose(); @@ -1793,6 +1829,150 @@ describe('direct APS attempt rendering', () => { } }); + it('transfers one host-position lease across consecutive accepted PUC overlays', () => { + document.body.innerHTML = '
publisher
'; + const generation = Object.freeze({}); + const artifactStore = createCommittedArtifactStore(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); + const container = document.getElementById('fictional-slot')!; + let physicalArtifact: CommittedRenderArtifact | undefined; + const resources: Array<{ dispose: () => void }> = []; + + const bindArtifact = (candidate: CommittedRenderArtifact) => { + const previousArtifact = physicalArtifact; + let phase: 'prepared' | 'committed' | 'finalized' | 'released' | 'rolled_back' = 'prepared'; + return Object.freeze({ + commit: (): boolean => { + if (phase !== 'prepared' || physicalArtifact !== previousArtifact) return false; + physicalArtifact = candidate; + phase = 'committed'; + return true; + }, + finalize: (): void => { + if (phase === 'committed') phase = 'finalized'; + }, + isCurrent: (): boolean => + (phase === 'committed' || phase === 'finalized') && physicalArtifact === candidate, + previousArtifact, + release: (): void => { + if (phase === 'released' || phase === 'rolled_back') return; + if (physicalArtifact === candidate) { + physicalArtifact = phase === 'committed' ? previousArtifact : undefined; + } + phase = 'released'; + }, + rollback: (): void => { + if (phase === 'committed' && physicalArtifact === candidate) { + physicalArtifact = previousArtifact; + } + phase = 'rolled_back'; + }, + }); + }; + + const mountAndComplete = (index: number): CommittedRenderArtifact => { + const scope = owner(indexedAttemptId(index), 'fictional-slot', generation); + const render = attempt(scope, { artifacts: artifactStore }); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, DIRECT_APS_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedBootstrapNonce(index) }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(index) }), + }); + resources.push(bootstraps, renderers); + let captureListener: ((event: MessageEvent) => void) | undefined; + const rendererPort = browserMessagePort(); + expect( + renderPucApsAttempt({ + attempt: render, + baseArtifact: artifact(scope, 'puc'), + bindArtifact, + bindArtifactGuard: bindCommittedArtifactGuard, + bootstrapNonces: bootstraps, + container, + hostPositions, + isBindingCurrent: () => true, + messaging: createBrowserMessagingAdapter({ + addEventListener: ( + _type: 'message', + listener: (event: MessageEvent) => void, + _capture: true + ) => { + captureListener = listener; + }, + removeEventListener: vi.fn(), + }), + nonces: renderers, + onArtifactTransferred: vi.fn(), + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frames = [...container.querySelectorAll('iframe')]; + const frame = frames[frames.length - 1]!; + const source = frame.contentWindow!; + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: indexedBootstrapNonce(index), + }), + origin: 'null', + ports: [], + source, + } as unknown as MessageEvent); + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce: indexedBootstrapNonce(index), + rendererNonce: indexedRendererNonce(index), + }), + origin: 'null', + ports: [rendererPort], + source, + } as unknown as MessageEvent); + rendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: indexedRendererNonce(index), + }); + rendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: indexedRendererNonce(index), + }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + const committed = artifactStore.current('fictional-slot'); + expect(committed).toBe(physicalArtifact); + if (!committed) throw new Error('should promote the APS overlay'); + return committed; + }; + + try { + const first = mountAndComplete(51); + expect(container.style.position).toBe('relative'); + const second = mountAndComplete(52); + expect(second).not.toBe(first); + expect(first.dispose).toBeTypeOf('function'); + expect(container.style.position).toBe('relative'); + expect(container.querySelectorAll('iframe')).toHaveLength(1); + expect(container.querySelector('span')?.textContent).toBe('publisher'); + + expect(artifactStore.release(second)).toBe(true); + expect(container.style.position).toBe(''); + expect(container.querySelectorAll('iframe')).toHaveLength(0); + expect(container.querySelector('span')?.textContent).toBe('publisher'); + } finally { + for (const resource of resources) resource.dispose(); + artifactStore.dispose(); + document.body.innerHTML = ''; + } + }); + it('removes only the failed PUC overlay and compare-restores its host position', () => { document.body.innerHTML = '
publisher
'; const scope = owner(); @@ -1809,14 +1989,19 @@ describe('direct APS attempt rendering', () => { mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(42) }), }); const container = document.getElementById('fictional-slot')!; + const artifactBinding = apsArtifactBinding(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); try { expect( renderPucApsAttempt({ attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, baseArtifact, + bindArtifact: () => artifactBinding, bootstrapNonces: bootstraps, container, + hostPositions, isBindingCurrent: () => true, messaging: createBrowserMessagingAdapter({ addEventListener: vi.fn(), @@ -1833,6 +2018,9 @@ describe('direct APS attempt rendering', () => { expect(container.querySelector('span')?.textContent).toBe('publisher'); expect(container.style.position).toBe(''); expect(baseArtifact.dispose).toHaveBeenCalledOnce(); + expect(artifactBinding.commit).not.toHaveBeenCalled(); + expect(artifactBinding.finalize).not.toHaveBeenCalled(); + expect(artifactBinding.release).toHaveBeenCalledOnce(); } finally { bootstraps.dispose(); renderers.dispose(); @@ -2243,7 +2431,7 @@ describe('direct ADM attempt rendering', () => { describe('RenderAttempt state machine', () => { it('implements the exact PUC APS state table and makes invalid/replay transitions inert', () => { const scope = owner(); - const candidate = artifact(scope, 'puc'); + const candidate = artifact(scope, 'aps_mount'); const render = attempt(scope); const observed: RenderAttemptState[] = []; @@ -2313,7 +2501,7 @@ describe('RenderAttempt state machine', () => { expect(directAps.beginDirect()).toBe(true); expect(directAps.beginAdm(artifact(directApsOwner))).toBe(false); expect(directAps.beginApsDocument(artifact(directApsOwner, 'puc'))).toBe(false); - expect(directAps.beginApsDocument(artifact(directApsOwner))).toBe(true); + expect(directAps.beginApsDocument(artifact(directApsOwner, 'aps_mount'))).toBe(true); const directAdmOwner = owner(ATTEMPT_TWO); const directAdm = attempt(directAdmOwner); @@ -2331,7 +2519,7 @@ describe('RenderAttempt state machine', () => { expect(pucAps.ownerRegistered()).toBe(true); expect(pucAps.beginAdm(artifact(pucApsOwner, 'puc'))).toBe(false); expect(pucAps.beginApsDocument(artifact(pucApsOwner))).toBe(false); - expect(pucAps.beginApsDocument(artifact(pucApsOwner, 'puc'))).toBe(true); + expect(pucAps.beginApsDocument(artifact(pucApsOwner, 'aps_mount'))).toBe(true); }); it('admits a claimed winner only through the exact one-shot source/context claim', () => { @@ -2474,12 +2662,12 @@ describe('RenderAttempt state machine', () => { case 'waiting_for_document': render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); render.beginDirect(); - render.beginApsDocument(artifact(scope)); + render.beginApsDocument(artifact(scope, 'aps_mount')); break; case 'waiting_for_aps_completion': render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); render.beginDirect(); - render.beginApsDocument(artifact(scope)); + render.beginApsDocument(artifact(scope, 'aps_mount')); render.apsDocumentAccepted(); break; case 'waiting_for_adm': @@ -2507,7 +2695,6 @@ describe('RenderAttempt state machine', () => { }; const invoke = (render: RenderAttempt, transition: Transition): boolean => { - const kind = render.snapshot().state === 'waiting_for_insertion' ? 'puc' : 'direct_iframe'; switch (transition) { case 'admit_direct': return render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); @@ -2522,9 +2709,14 @@ describe('RenderAttempt state machine', () => { case 'begin_direct': return render.beginDirect(); case 'begin_aps_document': - return render.beginApsDocument(artifact(render, kind)); + return render.beginApsDocument(artifact(render, 'aps_mount')); case 'begin_adm': - return render.beginAdm(artifact(render, kind)); + return render.beginAdm( + artifact( + render, + render.snapshot().state === 'waiting_for_insertion' ? 'puc' : 'direct_iframe' + ) + ); case 'aps_document_accepted': return render.apsDocumentAccepted(); case 'accept': @@ -2562,7 +2754,7 @@ describe('RenderAttempt state machine', () => { expect(render.winnerContext).toBe(WINNER_CONTEXT); expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(false); - const candidate = artifact(scope); + const candidate = artifact(scope, 'aps_mount'); expect(render.beginDirect()).toBe(true); expect(render.beginApsDocument(candidate)).toBe(true); expect(render.apsDocumentAccepted()).toBe(true); @@ -2654,7 +2846,7 @@ describe('RenderAttempt state machine', () => { const documentAttempt = attempt(documentOwner); documentAttempt.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); documentAttempt.beginDirect(); - documentAttempt.beginApsDocument(artifact(documentOwner)); + documentAttempt.beginApsDocument(artifact(documentOwner, 'aps_mount')); vi.advanceTimersByTime(3_000); expect(documentAttempt.snapshot().outcome).toEqual({ outcome: 'failed', @@ -2665,7 +2857,7 @@ describe('RenderAttempt state machine', () => { const completion = attempt(completionOwner); completion.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); completion.beginDirect(); - completion.beginApsDocument(artifact(completionOwner)); + completion.beginApsDocument(artifact(completionOwner, 'aps_mount')); completion.apsDocumentAccepted(); vi.advanceTimersByTime(10_000); expect(completion.snapshot().outcome).toEqual({ @@ -2691,7 +2883,7 @@ describe('RenderAttempt state machine', () => { transitionReference.current = transitionAttempt; transitionAttempt.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); transitionAttempt.beginDirect(); - transitionAttempt.beginApsDocument(artifact(transitionOwner)); + transitionAttempt.beginApsDocument(artifact(transitionOwner, 'aps_mount')); clearReenters = true; expect(transitionAttempt.apsDocumentAccepted()).toBe(true); @@ -2887,6 +3079,27 @@ describe('RenderAttempt state machine', () => { }); describe('committed artifact ownership', () => { + it('retires a lost guarded artifact synchronously and exactly once', () => { + const store = createCommittedArtifactStore(); + const candidate = artifact(owner()); + const retireAssociation = vi.fn(); + let current = true; + expect(bindCommittedArtifactGuard(candidate, () => current)).toBe(true); + expect(bindCommittedArtifactRetirement(candidate, retireAssociation)).toBe(true); + expect(bindCommittedArtifactRetirement(candidate, vi.fn())).toBe(false); + expect(store.promote(candidate)).toBe(true); + + expect(store.sweep()).toBe(0); + current = false; + expect(store.sweep()).toBe(1); + expect(store.current(candidate.slot)).toBeUndefined(); + expect(retireAssociation).toHaveBeenCalledOnce(); + expect(candidate.dispose).toHaveBeenCalledOnce(); + expect(store.sweep()).toBe(0); + expect(candidate.dispose).toHaveBeenCalledOnce(); + expect(retireAssociation).toHaveBeenCalledOnce(); + }); + it('promotes before attempt disposal, preserves accepted DOM, and disposes the prior artifact before replacement', () => { const store = createCommittedArtifactStore(); const generation = Object.freeze({}); @@ -3124,6 +3337,30 @@ describe('committed artifact ownership', () => { expect(store.current('fictional-slot')).toBe(current); }); + it('does not publish a candidate whose exact association is lost during prior disposal', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + let associationCurrent = true; + const currentOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const current = Object.freeze({ + kind: 'aps_mount' as const, + attemptId: currentOwner.id, + slot: currentOwner.slot, + navigationGeneration: generation, + dispose: vi.fn(() => { + associationCurrent = false; + }), + }); + const candidate = artifact(owner(ATTEMPT_TWO, 'fictional-slot', generation), 'aps_mount'); + expect(bindCommittedArtifactGuard(candidate, () => associationCurrent)).toBe(true); + expect(store.promote(current)).toBe(true); + + expect(store.promote(candidate)).toBe(false); + expect(current.dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBeUndefined(); + expect(candidate.dispose).not.toHaveBeenCalled(); + }); + it('never publishes after its navigation generation or whole store is disposed', () => { const generation = Object.freeze({}); const navigationStore = createCommittedArtifactStore(); @@ -3262,7 +3499,7 @@ describe('RenderAttempt diagnostics producer', () => { const renderAttempt = attempt(owner(), { publishDiagnostics }); expect(renderAttempt.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); expect(renderAttempt.beginDirect()).toBe(true); - const committed = artifact(renderAttempt); + const committed = artifact(renderAttempt, 'aps_mount'); expect(renderAttempt.beginApsDocument(committed)).toBe(true); expect(renderAttempt.apsDocumentAccepted()).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 aa6f4d321..b7c3c6628 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -21,11 +21,26 @@ import { type SlotRegistration, type SlotService, } from '../../src/services/slots'; +import { + bindCommittedArtifactRetirement, + createCommittedArtifactStore, + type CommittedRenderArtifact, +} from '../../src/services/render'; function createNavigation(): NavigationSession { return createRuntimeWithNavigation().navigation; } +function committedArtifact(navigationGeneration: object): CommittedRenderArtifact { + return Object.freeze({ + attemptId: `a1_${'c'.repeat(22)}`, + dispose: vi.fn(), + kind: 'aps_mount' as const, + navigationGeneration, + slot: 'slot', + }); +} + function createRuntimeWithNavigation() { const runtime = createRuntimeSession({ createIdentityIssuer: () => @@ -1023,13 +1038,19 @@ describe('navigation-owned DOM reconciliation', () => { afterEach(() => vi.useRealTimers()); async function completedApsMountCycle( - ownership: 'publisher' | 'trusted_server' = 'trusted_server' + ownership: 'publisher' | 'trusted_server' = 'trusted_server', + disposeCommittedArtifact = vi.fn() ) { const gpt = createGptHarness(); const dom = createReconciliationBoundary(); const element = Object.freeze({ element: 'top-slot' }); dom.put('slot-div', element); - const service = createSlotService({ googletag: gpt.adapter, reconciliation: dom.boundary }); + const service = createSlotService({ + bindCommittedArtifactRetirement, + disposeCommittedArtifact, + googletag: gpt.adapter, + reconciliation: dom.boundary, + }); const navigation = createNavigation(); let slot: object; if (ownership === 'trusted_server') { @@ -1068,7 +1089,7 @@ describe('navigation-owned DOM reconciliation', () => { slot, }); await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); - return { dom, element, intentId, navigation, service, slot }; + return { disposeCommittedArtifact, dom, element, intentId, navigation, service, slot }; } it('claims one exact completed TS cycle and invalidates it on DOM replacement', async () => { @@ -1094,6 +1115,70 @@ describe('navigation-owned DOM reconciliation', () => { ).toBeUndefined(); }); + it('retires a committed overlay for a publisher cycle but retains it for an exact TS replacement', async () => { + const publisher = await completedApsMountCycle('publisher'); + const publisherBinding = publisher.service.resolveApsMountBinding( + publisher.navigation.generation, + 'slot', + publisher.intentId + )!; + const publisherArtifact = committedArtifact(publisher.navigation.generation); + expect(publisherBinding.bindArtifact(publisherArtifact)?.commit()).toBe(true); + + publisher.service.handleGptEvent('slotRequested', { slot: publisher.slot }); + expect(publisher.disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + publisher.navigation.generation, + 'slot', + publisherArtifact + ); + + const trusted = await completedApsMountCycle(); + const trustedBinding = trusted.service.resolveApsMountBinding( + trusted.navigation.generation, + 'slot', + trusted.intentId + )!; + const trustedArtifact = committedArtifact(trusted.navigation.generation); + expect(trustedBinding.bindArtifact(trustedArtifact)?.commit()).toBe(true); + const replacement = trusted.service.request({ + expectedSlot: trusted.slot, + intentId: 'exact-ts-replacement', + navigationGeneration: trusted.navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + + trusted.service.handleGptEvent('slotRequested', { slot: trusted.slot }); + expect(trusted.disposeCommittedArtifact).not.toHaveBeenCalled(); + replacement.dispose(); + expect(trusted.disposeCommittedArtifact).not.toHaveBeenCalled(); + }); + + it('restores the prior physical artifact when a provisional replacement is released', async () => { + const h = await completedApsMountCycle(); + const prior = committedArtifact(h.navigation.generation); + expect(h.service.adoptCommittedArtifact(h.navigation.generation, 'slot', prior)).toBe(true); + const binding = h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId)!; + const replacement = Object.freeze({ + ...committedArtifact(h.navigation.generation), + attemptId: `a1_${'d'.repeat(22)}`, + }); + const admission = binding.bindArtifact(replacement)!; + expect(admission.commit()).toBe(true); + + admission.release(); + admission.rollback(); + h.service.handleGptEvent('slotRequested', { slot: h.slot }); + + expect(h.disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + h.navigation.generation, + 'slot', + prior + ); + }); + it('refuses missing, ambiguous, disconnected, and stale APS mount bindings', async () => { const ambiguous = await completedApsMountCycle(); ambiguous.dom.replaceAmbiguously('slot-div', [ @@ -1207,6 +1292,24 @@ describe('navigation-owned DOM reconciliation', () => { ); }); + it('clears an adopted physical artifact association when its store retires the exact artifact', () => { + const gpt = createGptHarness(); + const service = createSlotService({ + bindCommittedArtifactRetirement, + googletag: gpt.adapter, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const store = createCommittedArtifactStore(); + const first = committedArtifact(navigation.generation); + const second = committedArtifact(navigation.generation); + expect(store.promote(first)).toBe(true); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', first)).toBe(true); + + expect(store.release(first)).toBe(true); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', second)).toBe(true); + }); + it('debounces an exact disconnected TS slot through the 249/250 ms boundary', async () => { vi.useFakeTimers(); vi.setSystemTime(0); @@ -1217,6 +1320,7 @@ describe('navigation-owned DOM reconciliation', () => { }); dom.put('slot-div', {}); const service = createSlotService({ + bindCommittedArtifactRetirement, disposeCommittedArtifact, googletag: gpt.adapter, now: () => Date.now(), @@ -1224,6 +1328,8 @@ describe('navigation-owned DOM reconciliation', () => { }); const navigation = createNavigation(); bindTrustedSlot(service, navigation); + const artifact = committedArtifact(navigation.generation); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', artifact)).toBe(true); service.activate(); dom.replace('slot-div', {}); @@ -1239,7 +1345,11 @@ describe('navigation-owned DOM reconciliation', () => { [[300, 250]], 'slot-div' ); - expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigation.generation, + 'slot', + artifact + ); const request = service.request({ intentId: 'after-rebind', @@ -1487,6 +1597,7 @@ describe('navigation-owned DOM reconciliation', () => { const disposeCommittedArtifact = vi.fn(); dom.put('slot-div', {}); const service = createSlotService({ + bindCommittedArtifactRetirement, disposeCommittedArtifact, googletag: gpt.adapter, now: () => Date.now(), @@ -1494,12 +1605,18 @@ describe('navigation-owned DOM reconciliation', () => { }); const navigation = createNavigation(); bindTrustedSlot(service, navigation); + const artifact = committedArtifact(navigation.generation); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', artifact)).toBe(true); service.activate(); dom.disconnect('slot-div'); await vi.advanceTimersByTimeAsync(5_000); - expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigation.generation, + 'slot', + artifact + ); expect(disposeCommittedArtifact.mock.invocationCallOrder[0]).toBeLessThan( gpt.destroySlots.mock.invocationCallOrder[0] as number ); @@ -3247,11 +3364,14 @@ describe('physical GPT cycles', () => { const harness = createGptHarness(); const disposeCommittedArtifact = vi.fn(); const service = createSlotService({ + bindCommittedArtifactRetirement, disposeCommittedArtifact, googletag: harness.adapter, }); const navigation = createNavigation(); const oldSlot = bindTrustedSlot(service, navigation); + const artifact = committedArtifact(navigation.generation); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', artifact)).toBe(true); const first = service.request({ intentId: 'completion-timeout', navigationGeneration: navigation.generation, @@ -3269,7 +3389,11 @@ describe('physical GPT cycles', () => { throw new Error('Expected completion-timeout replacement'); } expect(harness.destroySlots).toHaveBeenCalledExactlyOnceWith([oldSlot]); - expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigation.generation, + 'slot', + artifact + ); service.handleGptEvent('slotRenderEnded', { isEmpty: false, 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 022abf5d3..a3dfd09f2 100644 --- a/crates/trusted-server-js/lib/test/services/targeting.test.ts +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -17,6 +17,63 @@ function createTargetingHarness(initial: Record = {}) } describe('owner-aware targeting journal', () => { + it('adopts an installed first-display value without rewriting it and restores its predecessor', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['trusted'] }); + + const frame = service.adopt( + slot, + 'key', + 'trusted', + Object.freeze(['publisher']), + 'first-display-owner', + targeting + ); + + expect(frame).toBeDefined(); + expect(targeting.setTargeting).not.toHaveBeenCalled(); + frame?.release(); + expect(targeting.values.get('key')).toEqual(['publisher']); + }); + + it('rejects adoption when its targeting read observes a same-value publisher write', async () => { + const values = new Map([['key', ['trusted']]]); + let reenter = true; + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => { + if (reenter) { + reenter = false; + slot.setTargeting(key, 'trusted'); + } + return 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 ownership = await adapter.run((gpt) => + service.adopt(slot, 'key', 'trusted', Object.freeze(['publisher']), 'handoff-owner', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + + expect(ownership).toBeUndefined(); + ownership?.release(); + expect(values.get('key')).toEqual(['trusted']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + it('restores the exact publisher predecessor after the current TS owner releases', () => { const service = createTargetingService(); const slot = {}; From e66677fffaf28ad82e9c11b55d10e03064535890 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:59:11 -0700 Subject: [PATCH 524/844] Hard-cut APS materialization and gate page delivery --- .../tests/routes.rs | 16 +- .../tests/routes.rs | 16 +- .../trusted-server-adapter-fastly/src/app.rs | 18 +- .../tests/routes.rs | 16 +- .../src/ad_templates/expected.rs | 12 +- .../src/commands/audit/ad_templates.rs | 3 +- .../src/commands/audit/generate/mod.rs | 8 +- .../src/commands/audit/generate/slot_toml.rs | 29 +- .../tests/config_env_overlay.rs | 9 +- .../benches/html_processor_bench.rs | 1 + .../src/auction/endpoints.rs | 90 ++++++ .../src/creative_opportunities.rs | 50 +++- .../src/integrations/aps.rs | 57 ++-- .../generated/aps_renderer_bootstrap_v1.html | 66 ----- .../generated/aps_renderer_bootstrap_v2.html | 126 ++++++++ .../src/integrations/gpt.rs | 35 +++ .../src/integrations/registry.rs | 14 +- crates/trusted-server-core/src/publisher.rs | 277 +++++++++++++++--- crates/trusted-server-core/src/settings.rs | 2 +- crates/trusted-server-core/src/tsjs.rs | 25 +- .../browser/helpers/gam-test-network.ts | 2 +- .../tests/shared/aps-puc-lifecycle.spec.ts | 4 +- .../browser/tests/shared/aps-real-gam.spec.ts | 2 +- .../browser/tests/shared/aps-renderer.spec.ts | 103 ++++--- .../tests/aps_runner_proxy.rs | 20 +- .../tests/environments/cloudflare.rs | 2 +- .../tests/parity.rs | 6 +- crates/trusted-server-js/build.rs | 1 - crates/trusted-server-js/lib/build-all.mjs | 17 +- .../lib/scripts/check-bundle-budgets.mjs | 8 +- .../scripts/check-hard-cutover-absence.mjs | 4 +- .../lib/src/adapters/messaging.ts | 34 +-- .../lib/src/composition/browser_test.ts | 8 +- .../src/first_display/adapters/googletag.ts | 92 +++--- .../lib/src/first_display/agent.ts | 33 ++- .../src/first_display/leaf/aps_protocol.ts | 83 ++++-- .../lib/src/first_display/render_bridge.ts | 143 +++++---- .../lib/src/integrations/aps/module.ts | 4 +- .../lib/src/integrations/aps/render.ts | 37 +-- .../lib/src/integrations/gpt/later.ts | 2 + .../src/integrations/render_runtime/module.ts | 2 +- .../src/kernel/contracts/message_protocol.ts | 2 +- .../lib/src/kernel/release_catalog.ts | 4 - .../lib/src/shared/aps_documents.ts | 2 + .../shared/integration_config_validators.ts | 16 +- .../lib/test/adapters/messaging.test.ts | 62 ++-- .../lib/test/build/release-v1.test.mjs | 4 +- .../lib/test/composition/browser.test.ts | 7 +- .../test/composition/maximal-runtime.test.ts | 4 +- .../test/contract/aps-renderer-es5.test.mjs | 143 ++++----- .../test/first_display/render_bridge.test.ts | 44 +-- .../lib/test/first_display/slices.test.ts | 2 +- .../lib/test/integrations/aps/module.test.ts | 2 +- .../lib/test/integrations/gpt/later.test.ts | 25 +- .../lib/test/integrations/gpt/module.test.ts | 2 +- .../render_runtime/module.test.ts | 2 +- .../lib/test/kernel/runtime.test.ts | 4 +- .../lib/test/services/puc_bridge.test.ts | 2 +- .../lib/test/services/render.test.ts | 36 +-- docs/guide/auction-orchestration.md | 2 +- docs/guide/configuration.md | 15 +- docs/guide/integrations/aps.md | 6 +- ...8-04-aps-tsjs-resilience-implementation.md | 63 ++-- ...s-render-fix-and-tsjs-resilience-design.md | 106 ++++--- scripts/generate-aps-renderer-contract.mjs | 150 ++++++++-- 65 files changed, 1437 insertions(+), 745 deletions(-) delete mode 100644 crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html create mode 100644 crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index b857d9866..07c56d7e6 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -267,7 +267,7 @@ async fn tsjs_wrong_methods_are_local_no_store_404s() { async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = Request::builder() .method("GET") - .uri("/integrations/aps/renderer/v1") + .uri("/integrations/aps/renderer/v2") .header("authorization", "Bearer must-not-reach-publisher") .body(AxumBody::empty()) .expect("should build APS renderer request"); @@ -293,18 +293,18 @@ async fn aps_cutover_renderer_and_family_failures_are_local() { .expect("renderer body should be bounded"); let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); assert!(body.contains("TS APS Bootstrap Ready")); - assert!(!body.contains("/integrations/aps/runner.js")); - assert!(!body.contains("aaxResponse")); - assert!(!body.contains("creativeUrl")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); assert!(!body.contains("client.aps.amazon-adsystem.com")); for (method, path, expected) in [ ("POST", "/integrations/aps/runner.js", 405), - ("TRACE", "/integrations/aps/renderer/v1", 405), - ("CONNECT", "/integrations/aps/renderer/v1", 405), - ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("TRACE", "/integrations/aps/renderer/v2", 405), + ("CONNECT", "/integrations/aps/renderer/v2", 405), + ("PROPFIND", "/integrations/aps/renderer/v2", 405), ("GET", "/integrations/aps/renderer", 404), - ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/renderer/v1", 404), ("GET", "/integrations/aps/runner/v1.js", 404), ("GET", "/integrations/aps", 404), ] { diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 419f53b3d..a816c397f 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -124,7 +124,7 @@ fn routes_build_without_panic() { async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = request_builder() .method("GET") - .uri("/integrations/aps/renderer/v1") + .uri("/integrations/aps/renderer/v2") .header("authorization", "Bearer must-not-reach-publisher") .body(edgezero_core::body::Body::empty()) .expect("should build APS renderer request"); @@ -142,18 +142,18 @@ async fn aps_cutover_renderer_and_family_failures_are_local() { let body = response.into_body().into_bytes().unwrap_or_default(); let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); assert!(body.contains("TS APS Bootstrap Ready")); - assert!(!body.contains("/integrations/aps/runner.js")); - assert!(!body.contains("aaxResponse")); - assert!(!body.contains("creativeUrl")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); assert!(!body.contains("client.aps.amazon-adsystem.com")); for (method, path, expected) in [ ("POST", "/integrations/aps/runner.js", 405), - ("TRACE", "/integrations/aps/renderer/v1", 405), - ("CONNECT", "/integrations/aps/renderer/v1", 405), - ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("TRACE", "/integrations/aps/renderer/v2", 405), + ("CONNECT", "/integrations/aps/renderer/v2", 405), + ("PROPFIND", "/integrations/aps/renderer/v2", 405), ("GET", "/integrations/aps/renderer", 404), - ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/renderer/v1", 404), ("GET", "/integrations/aps/runner/v1.js", 404), ("GET", "/integrations/aps", 404), ] { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index a3d6eca09..ddb4401a9 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1313,10 +1313,8 @@ mod tests { }; use bytes::Bytes; use edgezero_core::body::Body; - use edgezero_core::context::RequestContext; use edgezero_core::http::{Method, Response, StatusCode, header, request_builder}; use edgezero_core::key_value_store::NoopKvStore; - use edgezero_core::params::PathParams; use edgezero_core::router::RouterService; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Mutex; @@ -1471,7 +1469,7 @@ mod tests { #[cfg(feature = "aps-runner-proxy-integration-test")] #[test] fn aps_cutover_renderer_and_family_failures_are_local() { - let response = route_reserved(empty_request(Method::GET, "/integrations/aps/renderer/v1")); + let response = route_reserved(empty_request(Method::GET, "/integrations/aps/renderer/v2")); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::CONTENT_TYPE], @@ -1485,9 +1483,9 @@ mod tests { let body = response.into_body().into_bytes().unwrap_or_default(); let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); assert!(body.contains("TS APS Bootstrap Ready")); - assert!(!body.contains("/integrations/aps/runner.js")); - assert!(!body.contains("aaxResponse")); - assert!(!body.contains("creativeUrl")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); assert!(!body.contains("client.aps.amazon-adsystem.com")); for (method, path, expected) in [ @@ -1498,17 +1496,17 @@ mod tests { ), ( Method::TRACE, - "/integrations/aps/renderer/v1", + "/integrations/aps/renderer/v2", StatusCode::METHOD_NOT_ALLOWED, ), ( Method::CONNECT, - "/integrations/aps/renderer/v1", + "/integrations/aps/renderer/v2", StatusCode::METHOD_NOT_ALLOWED, ), ( Method::from_bytes(b"PROPFIND").expect("PROPFIND should be a valid method"), - "/integrations/aps/renderer/v1", + "/integrations/aps/renderer/v2", StatusCode::METHOD_NOT_ALLOWED, ), ( @@ -1518,7 +1516,7 @@ mod tests { ), ( Method::GET, - "/integrations/aps/renderer/v2", + "/integrations/aps/renderer/v1", StatusCode::NOT_FOUND, ), ( diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 32a53a5b5..bdf7b673b 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -78,7 +78,7 @@ fn routes_build_without_panic() { async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = request_builder() .method("GET") - .uri("/integrations/aps/renderer/v1") + .uri("/integrations/aps/renderer/v2") .header("authorization", "Bearer must-not-reach-publisher") .body(edgezero_core::body::Body::empty()) .expect("should build APS renderer request"); @@ -96,18 +96,18 @@ async fn aps_cutover_renderer_and_family_failures_are_local() { let body = response.into_body().into_bytes().unwrap_or_default(); let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); assert!(body.contains("TS APS Bootstrap Ready")); - assert!(!body.contains("/integrations/aps/runner.js")); - assert!(!body.contains("aaxResponse")); - assert!(!body.contains("creativeUrl")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); assert!(!body.contains("client.aps.amazon-adsystem.com")); for (method, path, expected) in [ ("POST", "/integrations/aps/runner.js", 405), - ("TRACE", "/integrations/aps/renderer/v1", 405), - ("CONNECT", "/integrations/aps/renderer/v1", 405), - ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("TRACE", "/integrations/aps/renderer/v2", 405), + ("CONNECT", "/integrations/aps/renderer/v2", 405), + ("PROPFIND", "/integrations/aps/renderer/v2", 405), ("GET", "/integrations/aps/renderer", 404), - ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/renderer/v1", 404), ("GET", "/integrations/aps/runner/v1.js", 404), ("GET", "/integrations/aps", 404), ] { diff --git a/crates/trusted-server-cli/src/ad_templates/expected.rs b/crates/trusted-server-cli/src/ad_templates/expected.rs index 549ec0a57..e747ddf32 100644 --- a/crates/trusted-server-cli/src/ad_templates/expected.rs +++ b/crates/trusted-server-cli/src/ad_templates/expected.rs @@ -161,7 +161,8 @@ mod tests { .collect::>() .join(", "); let toml = format!( - "gam_network_id = \"123\"\n\ + "enabled = true\n\ + gam_network_id = \"123\"\n\ \n\ [[slot]]\n\ id = \"atf\"\n\ @@ -211,7 +212,8 @@ mod tests { #[test] fn expected_slots_default_resolution_without_overrides() { - let toml = "gam_network_id = \"42\"\n\ + let toml = "enabled = true\n\ + gam_network_id = \"42\"\n\ \n\ [[slot]]\n\ id = \"footer\"\n\ @@ -232,7 +234,8 @@ mod tests { #[test] fn expected_slots_render_section_templates_per_path() { - let toml = "gam_network_id = \"99999\"\n\ + let toml = "enabled = true\n\ + gam_network_id = \"99999\"\n\ section_root = \"homepage\"\n\ \n\ [[slot]]\n\ @@ -267,7 +270,8 @@ mod tests { // A `{section}` template that renders past GAM's 100-byte unit-path // limit. `validate_runtime` rejects this config, so the verifier reports // the slot as unconfirmable rather than matching a truncated path. - let toml = "gam_network_id = \"99999\"\n\ + let toml = "enabled = true\n\ + gam_network_id = \"99999\"\n\ section_root = \"homepage\"\n\ \n\ [[slot]]\n\ diff --git a/crates/trusted-server-cli/src/commands/audit/ad_templates.rs b/crates/trusted-server-cli/src/commands/audit/ad_templates.rs index 8250e1cde..ab8612931 100644 --- a/crates/trusted-server-cli/src/commands/audit/ad_templates.rs +++ b/crates/trusted-server-cli/src/commands/audit/ad_templates.rs @@ -489,7 +489,8 @@ mod tests { } fn news_config() -> CreativeOpportunitiesConfig { - let toml = "gam_network_id = \"123\"\n\ + let toml = "enabled = true\n\ + gam_network_id = \"123\"\n\ \n\ [[slot]]\n\ id = \"atf\"\n\ diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs index 2a2d4b11b..96697184c 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs @@ -1355,7 +1355,7 @@ mod tests { let config_path = temp.path().join("trusted-server.toml"); fs::write( &config_path, - "[creative_opportunities]\ngam_network_id = \"111\"\n", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n", ) .expect("should write config"); // The requested URL redirects; slots are scraped from the final page. @@ -1408,7 +1408,7 @@ mod tests { fn update_slots_rejects_invalid_page_pattern_without_touching_config() { let temp = TempDir::new().expect("should create temp dir"); let config_path = temp.path().join("trusted-server.toml"); - let original = "[creative_opportunities]\ngam_network_id = \"111\"\n"; + let original = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n"; fs::write(&config_path, original).expect("should write config"); let collector = FakeCollector::new(collected_page_with_header_slot()); let mut out = Vec::new(); @@ -1448,7 +1448,7 @@ mod tests { let config_path = temp.path().join("trusted-server.toml"); fs::write( &config_path, - "[creative_opportunities]\ngam_network_id = \"111\"\n", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n", ) .expect("should write config"); let collector = FakeCollector::new(collected_page_with_header_slot()); @@ -1484,7 +1484,7 @@ mod tests { let config_path = temp.path().join("trusted-server.toml"); fs::write( &config_path, - "[creative_opportunities]\ngam_network_id = \"111\"\n", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n", ) .expect("should write config"); let collector = FakeCollector::new(collected_page_with_header_slot()); diff --git a/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs b/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs index 38e32ce5f..61a0f8ebf 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs @@ -793,7 +793,7 @@ mod tests { #[test] fn splice_replaces_slots_and_preserves_other_sections() { let existing = "[publisher]\ndomain = \"x\"\n\n\ - [creative_opportunities]\ngam_network_id = \"111\"\nprice_granularity = \"dense\"\n\n\ + [creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\nprice_granularity = \"dense\"\n\n\ [[creative_opportunities.slot]]\nid = \"old\"\ndiv_id = \"old\"\n\ gam_unit_path = \"/111/old\"\npage_patterns = [\"/\"]\n\ formats = [{ width = 300, height = 250 }]\n\n\ @@ -831,7 +831,7 @@ mod tests { // A quoted header is valid TOML but the line-based splice does not // recognise it; appending a second `[creative_opportunities]` would // produce a document that no longer parses. - let existing = "[\"creative_opportunities\"]\ngam_network_id = \"111\"\n"; + let existing = "[\"creative_opportunities\"]\nenabled = true\ngam_network_id = \"111\"\n"; let error = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect_err("should refuse an unrecognised section form"); @@ -844,7 +844,7 @@ mod tests { #[test] fn splice_rejects_top_level_inline_creative_opportunities_table() { - let existing = "creative_opportunities = { gam_network_id = \"111\" }\n"; + let existing = "creative_opportunities = { enabled = true, gam_network_id = \"111\" }\n"; let error = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect_err("should refuse a top-level inline table"); @@ -872,7 +872,7 @@ mod tests { fn splice_inserts_section_policy_keys_a_config_does_not_have_yet() { // The whole point of `upsert`: every config predating templating lacks // these keys, so a replace-only writer could never add them. - let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n\n\ + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\n\ [auction]\nenabled = true\n"; let out = splice_creative_slots( @@ -896,7 +896,7 @@ mod tests { #[test] fn splice_replaces_section_policy_keys_that_are_already_present() { - let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n\ + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\ section_root = \"old\"\nsection_segment = 2\n"; let out = splice_creative_slots( @@ -922,7 +922,7 @@ mod tests { // `section_root`/`section_segment` are `deny_unknown_fields` additions: // writing them into a config that does not need them would make it // unloadable by an older binary for no benefit. - let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n"; + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect("should splice"); @@ -955,7 +955,7 @@ mod tests { fn upsert_keeps_an_inserted_key_inside_the_section_scalar_block() { // Appending at the end of the section would land the key after a // subtable, where TOML reads it as part of that subtable instead. - let document = "[creative_opportunities]\ngam_network_id = \"111\"\n\n\ + let document = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\n\ [[creative_opportunities.slot]]\nid = \"a\"\n\ page_patterns = [\"/\"]\nformats = [{ width = 1, height = 1 }]\n"; @@ -1015,6 +1015,7 @@ mod tests { // Mirrors the templated operator shape: section policy scalars in the // head block and a per-slot prebid provider subtable. let existing = "[creative_opportunities]\n\ + enabled = true\n\ gam_network_id = \"111\"\n\ auction_timeout_ms = 2000\n\ section_root = \"homepage\"\n\n\ @@ -1030,6 +1031,7 @@ mod tests { let existing_config = existing_config( &existing .replace("[creative_opportunities]\n", "") + .replacen("enabled = true\n", "", 1) .replace("[[creative_opportunities.slot]]", "[[slot]]") .replace("[creative_opportunities.slot.", "[slot.") .replace("\n[auction]\nenabled = true\n", ""), @@ -1071,7 +1073,7 @@ mod tests { #[test] fn splice_preserves_crlf_line_endings() { - let existing = "[creative_opportunities]\r\ngam_network_id = \"111\"\r\n\r\n\ + let existing = "[creative_opportunities]\r\nenabled = true\r\ngam_network_id = \"111\"\r\n\r\n\ [auction]\r\nenabled = true\r\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) @@ -1167,7 +1169,7 @@ mod tests { fn splice_recognizes_inline_commented_section_header() { // `[creative_opportunities] # comment` is valid TOML; the splice must // update it in place instead of appending a duplicate section. - let existing = "[creative_opportunities] # ad templates\ngam_network_id = \"111\"\n\n\ + let existing = "[creative_opportunities] # ad templates\nenabled = true\ngam_network_id = \"111\"\n\n\ [auction] # flags\nenabled = true\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) @@ -1199,8 +1201,7 @@ mod tests { #[test] fn splice_inserts_when_no_existing_slots() { - let existing = - "[creative_opportunities]\ngam_network_id = \"111\"\n\n[auction]\nenabled = true\n"; + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\n[auction]\nenabled = true\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect("should splice"); @@ -1225,6 +1226,7 @@ mod tests { #[test] fn splice_replaces_inline_slot_array() { let existing = "[creative_opportunities]\n\ + enabled = true\n\ gam_network_id = \"111\"\n\ slot = [{ id = \"old\", div_id = \"old\", gam_unit_path = \"/111/old\", page_patterns = [\"/\"], formats = [{ width = 300, height = 250 }] }]\n\n\ [auction]\nenabled = true\n"; @@ -1248,6 +1250,7 @@ mod tests { #[test] fn splice_replaces_inline_slot_map() { let existing = "[creative_opportunities]\n\ + enabled = true\n\ gam_network_id = \"111\"\n\ slot = { \"0\" = { id = \"old\", div_id = \"old\", gam_unit_path = \"/111/old\", page_patterns = [\"/\"], formats = [{ width = 300, height = 250 }] } }\n"; @@ -1479,7 +1482,7 @@ mod tests { #[test] fn replace_key_handles_inline_commented_headers() { - let document = "[creative_opportunities] # managed\ngam_network_id = \"111\"\n\n\ + let document = "[creative_opportunities] # managed\nenabled = true\ngam_network_id = \"111\"\n\n\ [auction] # flags\nenabled = true\n"; let updated = replace_key_in_section( @@ -1516,7 +1519,7 @@ mod tests { false, ); let doc = format!( - "[creative_opportunities]\ngam_network_id = \"1\"\n{}", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"1\"\n{}", render_slots(&merged) ); diff --git a/crates/trusted-server-cli/tests/config_env_overlay.rs b/crates/trusted-server-cli/tests/config_env_overlay.rs index 35263c0eb..95c787e3f 100644 --- a/crates/trusted-server-cli/tests/config_env_overlay.rs +++ b/crates/trusted-server-cli/tests/config_env_overlay.rs @@ -44,11 +44,12 @@ fn migrated_legacy_project() -> MigratedProject { let mut document = LEGACY_CONFIG .parse::() .expect("should parse legacy integration config"); - // EdgeZero v0.0.4 environment overlays cannot create missing TOML leaves, - // so a migrated config must carry both creative-processing leaves for the - // corresponding environment variables to take effect. + // EdgeZero environment overlays cannot create missing TOML leaves, so a + // migrated config must carry every leaf that an environment variable can + // override. document["auction"]["rewrite_creatives"] = value(true); document["auction"]["sanitize_creatives"] = value(false); + document["integrations"]["gpt"]["gam_attribution_enabled"] = value(false); fs::write(&config_path, document.to_string()).expect("should write migrated config"); fs::write(&manifest_path, MANIFEST).expect("should write test manifest"); MigratedProject { @@ -152,7 +153,7 @@ fn migrated_legacy_config_applies_gam_attribution_environment_override() { assert_eq!( envelope["data"]["integrations"]["gpt"]["gam_attribution_enabled"], serde_json::Value::Bool(true), - "pushed config should contain the GAM attribution environment override" + "pushed config should contain the GAM attribution environment override: {envelope}" ); } diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 598a6e3da..8fdaffb59 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -17,6 +17,7 @@ fn make_config() -> HtmlProcessorConfig { gpt_diagnostics: None, render_trace_overlay: false, suppress_datadome_client_side_tag: false, + body_close: BodyCloseInjection::None, } } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index d0c498aa8..6659cd09d 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -759,6 +759,96 @@ mod tests { } } + struct CallRecordingProvider { + called: Arc>, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for CallRecordingProvider { + fn provider_name(&self) -> &'static str { + "call_recording_provider" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + *self.called.lock().expect("should lock provider call flag") = true; + Err(Report::new(TrustedServerError::Auction { + message: "call recorded".to_string(), + })) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("parse_response must not run when the launch returns an error"); + } + + fn timeout_ms(&self) -> u32 { + 100 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some("call-recording-backend".to_string()) + } + } + + #[tokio::test] + async fn disabled_creative_opportunities_do_not_disable_direct_auction() { + let mut settings = create_test_settings(); + settings.creative_opportunities = Some( + toml::from_str("enabled = false\ngam_network_id = \"12345\"\n") + .expect("should build disabled creative opportunity config"), + ); + let config = AuctionConfig { + enabled: true, + providers: vec!["call_recording_provider".to_string()], + timeout_ms: 2_000, + mediator: None, + ..Default::default() + }; + let called = Arc::new(Mutex::new(false)); + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(CallRecordingProvider { + called: Arc::clone(&called), + })); + let services = noop_services(); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let body = json!({ + "adUnits": [{ + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + }] + }); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize body"), + )) + .expect("should build auction request"); + + let _ = handle_auction( + &settings, + &orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await; + + assert!( + *called.lock().expect("should lock provider call flag"), + "direct /auction must remain live when publisher template delivery is disabled" + ); + } + #[tokio::test] async fn auction_endpoint_consent_gate_returns_no_bid_without_contacting_providers() { // GDPR/unknown jurisdiction lacking effective TCF Purpose 1 must not run diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index ea251b824..868c8740d 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -1854,14 +1854,13 @@ mod tests { #[test] fn validate_runtime_allows_blank_network_id_when_no_slots_configured() { - // An empty slot list disables the feature, so the id is never rendered. - // Failing startup there would break a deploy over an unused value. + // With no slot definitions, the network id is not consumed. let mut config = make_config_with_section_template(Some("home")); config.gam_network_id = String::new(); config.slot.clear(); config .validate_runtime() - .expect("a disabled creative_opportunities stack should not fail on a blank id"); + .expect("an unused creative_opportunities network id should not fail validation"); } #[test] @@ -2216,6 +2215,51 @@ mod tests { } } + #[test] + fn disabled_creative_opportunities_still_validate_every_field() { + let invalid_slot: CreativeOpportunitiesConfig = toml::from_str( + r#" + enabled = false + gam_network_id = "99999" + + [[slot]] + id = "invalid-pattern" + page_patterns = ["["] + formats = [{ width = 300, height = 250 }] + "#, + ) + .expect("disabled slot shape should deserialize before runtime validation"); + assert!( + invalid_slot.validate_runtime().is_err(), + "disabled delivery must not bypass slot validation" + ); + + let invalid_cache: CreativeOpportunitiesConfig = toml::from_str( + r#" + enabled = false + gam_network_id = "99999" + template_cache_vary = ["not a header"] + "#, + ) + .expect("disabled cache shape should deserialize before runtime validation"); + assert!( + invalid_cache.validate_runtime().is_err(), + "disabled delivery must not bypass cache-field validation" + ); + + assert!( + toml::from_str::( + r#" + enabled = false + gam_network_id = "99999" + assembly_mode = "client_fill" + "#, + ) + .is_err(), + "disabled delivery must not bypass assembly-mode parsing" + ); + } + #[test] fn assembly_mode_deserializes_each_variant() { for (raw, expected) in [("inline", AssemblyMode::Inline), ("esi", AssemblyMode::Esi)] { diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 91e80e0fd..9dd1c772f 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -40,7 +40,7 @@ use crate::platform::{ use crate::settings::{IntegrationConfig, Settings}; const APS_INTEGRATION_ID: &str = "aps"; -pub const APS_RENDERER_V1_ROUTE: &str = "/integrations/aps/renderer/v1"; +pub const APS_RENDERER_V2_ROUTE: &str = "/integrations/aps/renderer/v2"; pub const APS_RUNNER_ROUTE: &str = "/integrations/aps/runner.js"; pub const APS_RUNNER_UPSTREAM_URL: &str = "https://client.aps.amazon-adsystem.com/prebid-creative.js"; @@ -68,11 +68,11 @@ pub const APS_RUNNER_BLOCKING_READ_TIMEOUT: Duration = Duration::from_millis(250 pub fn is_aps_family_path(path: &str) -> bool { path == "/integrations/aps" || path.starts_with("/integrations/aps/") } -const APS_RENDERER_V1_CSP: &str = "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';"; +const APS_RENDERER_V2_CSP: &str = "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';"; -// This document is served with an immutable v1 URL. Any semantic change must -// ship at a new versioned route so cached v1 bytes retain their contract. -const APS_RENDERER_V1_DOCUMENT: &str = include_str!("generated/aps_renderer_bootstrap_v1.html"); +// This document is served with an immutable v2 URL. Any semantic change must +// ship at a new versioned route so cached v2 bytes retain their contract. +const APS_RENDERER_V2_DOCUMENT: &str = include_str!("generated/aps_renderer_bootstrap_v2.html"); /// Configuration for the APS `OpenRTB` integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] @@ -1251,11 +1251,11 @@ impl ApsV1Integration { .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .header("x-content-type-options", "nosniff") .header("referrer-policy", "no-referrer") - .header(header::CONTENT_SECURITY_POLICY, APS_RENDERER_V1_CSP) - .body(EdgeBody::from(APS_RENDERER_V1_DOCUMENT)) + .header(header::CONTENT_SECURITY_POLICY, APS_RENDERER_V2_CSP) + .body(EdgeBody::from(APS_RENDERER_V2_DOCUMENT)) .change_context(TrustedServerError::Integration { integration: APS_INTEGRATION_ID.to_string(), - message: "Failed to build APS renderer v1 response".to_string(), + message: "Failed to build APS renderer v2 response".to_string(), }) .map(Self::mark_exact_headers) } @@ -1462,7 +1462,7 @@ impl IntegrationProxy for ApsV1Integration { return Self::local_status(StatusCode::METHOD_NOT_ALLOWED, true); } match path { - APS_RENDERER_V1_ROUTE => Self::renderer_response(), + APS_RENDERER_V2_ROUTE => Self::renderer_response(), APS_RUNNER_ROUTE => match Self::runner_response(services).await { Ok(response) => Ok(response), Err(reason) => { @@ -3276,7 +3276,7 @@ mod tests { let settings = create_test_settings(); let services = noop_services(); - for path in [APS_RENDERER_V1_ROUTE, APS_RUNNER_ROUTE] { + for path in [APS_RENDERER_V2_ROUTE, APS_RUNNER_ROUTE] { let disabled_get = http::Request::builder() .method(Method::GET) .uri(path) @@ -3334,9 +3334,9 @@ mod tests { for path in [ "/integrations/aps/renderer", - "/integrations/aps/renderer/v2", + "/integrations/aps/renderer/v1", "/integrations/aps/runner/v1.js", - "/integrations/aps/renderer/v1/extra", + "/integrations/aps/renderer/v2/extra", "/integrations/aps/not-a-route", ] { let request = http::Request::builder() @@ -3355,7 +3355,7 @@ mod tests { #[test] fn aps_family_classifier_has_an_exact_segment_boundary() { assert!(is_aps_family_path("/integrations/aps")); - assert!(is_aps_family_path("/integrations/aps/renderer/v1")); + assert!(is_aps_family_path("/integrations/aps/renderer/v2")); assert!(!is_aps_family_path("/integrations/apsx")); assert!(!is_aps_family_path("/integrations/ap")); } @@ -3365,7 +3365,7 @@ mod tests { let integration = ApsV1Integration { enabled: true }; let request = http::Request::builder() .method(Method::GET) - .uri(APS_RENDERER_V1_ROUTE) + .uri(APS_RENDERER_V2_ROUTE) .body(EdgeBody::empty()) .expect("should build versioned renderer request"); let response = futures::executor::block_on(integration.handle( @@ -3388,11 +3388,11 @@ mod tests { assert_eq!(response.headers()["referrer-policy"], "no-referrer"); assert_eq!( response.headers()[header::CONTENT_SECURITY_POLICY], - APS_RENDERER_V1_CSP + APS_RENDERER_V2_CSP ); assert!(!response.headers().contains_key("x-frame-options")); assert_eq!( - APS_RENDERER_V1_CSP, + APS_RENDERER_V2_CSP, "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';" ); assert_eq!( @@ -3406,23 +3406,20 @@ mod tests { .into_bytes_bounded(APS_RUNNER_MAX_RESPONSE_BYTES), ) .expect("renderer body should stay within the runner cap"); - assert_eq!(body.as_ref(), APS_RENDERER_V1_DOCUMENT.as_bytes()); - assert!(APS_RENDERER_V1_DOCUMENT.contains("TS APS Bootstrap Ready")); - assert!(APS_RENDERER_V1_DOCUMENT.contains("TS APS Bootstrap Navigate")); - assert!(APS_RENDERER_V1_DOCUMENT.contains("data:text/html;charset=utf-8,")); + assert_eq!(body.as_ref(), APS_RENDERER_V2_DOCUMENT.as_bytes()); + assert!(APS_RENDERER_V2_DOCUMENT.contains("TS APS Bootstrap Ready")); + assert!(APS_RENDERER_V2_DOCUMENT.contains("TS APS Bootstrap Configure")); + assert!(APS_RENDERER_V2_DOCUMENT.contains("data:text/html;charset=utf-8,")); + assert!(APS_RENDERER_V2_DOCUMENT.contains("/integrations/aps/runner.js")); for forbidden in [ - "runner", - "aaxResponse", - "accountId", - "bidId", - "creativeId", - "creativeUrl", - "createElement", - "iframe", + "client.aps.amazon-adsystem.com", + "example-account", + "bid-123", + "creative.example/render", ] { assert!( - !APS_RENDERER_V1_DOCUMENT.contains(forbidden), - "renderer bootstrap should not contain {forbidden}" + !APS_RENDERER_V2_DOCUMENT.contains(forbidden), + "renderer bootstrap should not contain a concrete descriptor or vendored runner value: {forbidden}" ); } } diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html deleted file mode 100644 index ab1f3ac91..000000000 --- a/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html +++ /dev/null @@ -1,66 +0,0 @@ - - - diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html new file mode 100644 index 000000000..6cf1e995a --- /dev/null +++ b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html @@ -0,0 +1,126 @@ + + + diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 9c6ccf2bd..21ccea703 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -394,9 +394,14 @@ pub fn register( #[serde(rename_all = "camelCase")] struct GptBrowserConfigV1 { gam_attribution_enabled: bool, + page_bids_enabled: bool, } let browser_config = GptBrowserConfigV1 { gam_attribution_enabled: integration.config.gam_attribution_enabled, + page_bids_enabled: settings + .creative_opportunities + .as_ref() + .is_some_and(|config| config.enabled), }; Ok(Some( @@ -574,6 +579,36 @@ mod tests { assert!(enabled.gam_attribution_enabled); } + #[test] + fn browser_config_projects_creative_opportunity_delivery_state() { + for enabled in [false, true] { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt", &serde_json::json!({ "enabled": true })) + .expect("should enable GPT"); + settings.creative_opportunities = Some( + toml::from_str(&format!( + "enabled = {enabled}\ngam_network_id = \"12345\"\n" + )) + .expect("should build creative opportunity config"), + ); + + let registration = register(&settings) + .expect("GPT registration should succeed") + .expect("GPT should be enabled"); + + assert_eq!( + registration.browser_config_v1, + Some(serde_json::json!({ + "gamAttributionEnabled": false, + "pageBidsEnabled": enabled, + })), + "GPT browser config must carry the exact publisher delivery switch" + ); + } + } + // -- URL detection -- #[test] diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 80ead538b..4ac70fbdd 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -2107,7 +2107,7 @@ mod tests { let request = Request::builder() .method(Method::GET) - .uri("/integrations/aps/renderer/v1") + .uri("/integrations/aps/renderer/v2") .header(HEADER_X_TS_EC.clone(), "caller-controlled") .body(EdgeBody::empty()) .expect("should build reserved APS request"); @@ -2756,7 +2756,7 @@ mod tests { } #[test] - fn tsjs_static_transport_precomputes_every_configuration_permitted_first_display_mask() { + fn tsjs_static_transport_precomputes_every_size_admitted_first_display_mask() { let mut settings = crate::test_support::tests::create_test_settings(); settings .integrations @@ -2786,14 +2786,8 @@ mod tests { let prebid = bit("prebid_initial"); assert_eq!( masks, - vec![ - fixed, - fixed | gpt, - fixed | gpt | aps, - fixed | gpt | prebid, - fixed | gpt | aps | prebid, - ], - "registry should enumerate every permitted participation combination" + vec![fixed, fixed | gpt, fixed | gpt | aps, fixed | gpt | prebid,], + "registry should enumerate every configuration-reachable mask admitted by the fixed transfer ceiling" ); for mask in masks { let selected = trusted_server_js::all_first_display_ids() diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 1f307e168..0f963503c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1071,6 +1071,7 @@ fn configured_assembly_mode(settings: &Settings) -> AssemblyMode { settings .creative_opportunities .as_ref() + .filter(|config| config.enabled) .map(CreativeOpportunitiesConfig::assembly_mode) .unwrap_or_default() } @@ -2630,17 +2631,15 @@ pub(crate) fn should_run_server_side_ad_stack( is_navigation: bool, is_prefetch: bool, is_bot: bool, - has_matched_slots: bool, + has_active_matched_slots: bool, consent_allows_auction: bool, auction_enabled: bool, - ad_templates_enabled: bool, ) -> bool { - ad_templates_enabled - && is_get + is_get && is_navigation && !is_prefetch && !is_bot - && has_matched_slots + && has_active_matched_slots && consent_allows_auction && auction_enabled } @@ -3393,6 +3392,7 @@ pub async fn handle_publisher_request( settings .creative_opportunities .as_ref() + .filter(|co_config| co_config.enabled) .map_or_else(Vec::new, |co_config| { match_renderable_slots(auction.slots, co_config, &request_path) }) @@ -3413,10 +3413,6 @@ pub async fn handle_publisher_request( !matched_slots.is_empty(), consent_allows_auction, auction.orchestrator.is_enabled(), - settings - .creative_opportunities - .as_ref() - .is_some_and(|config| config.enabled), ); let should_run_auction = should_run_ad_stack; // Diagnostic: shows which gate suppresses the server-side auction. Pair with @@ -3949,19 +3945,11 @@ pub async fn handle_publisher_request( if is_html_content_type(origin_content_type) { if should_run_ad_stack { enforce_synthesized_html_cache_privacy(&mut response); - } else { + } else if is_get && response.status().is_success() { // Server-side ad templates are inactive for this response, so the // document carries no per-navigation auction state and stays // cacheable — unless the origin already marked it private. - let origin_cache_control = response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()) - .map(str::to_ascii_lowercase); - if !origin_cache_control - .as_deref() - .is_some_and(|value| value.contains("private") || value.contains("no-store")) - { + if !response_cache_control_is_private_or_no_store(&response) { response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static("max-age=60"), @@ -5168,11 +5156,13 @@ pub async fn handle_page_bids( let path_param = normalize_page_bids_path(&requested_page); let page_path_and_query = normalize_page_bids_path_and_query(&requested_page); - let matched_slots = match_renderable_slots(auction.slots, co_config, &path_param); + let matched_slots = if co_config.enabled { + match_renderable_slots(auction.slots, co_config, &path_param) + } else { + Vec::new() + }; let browser_slots = build_browser_slots_v1(&matched_slots, co_config, &path_param); - let matched_slots = match_renderable_slots(auction.slots, co_config, &path_param); - let request_info = crate::http_util::RequestInfo::from_request(&req, services.client_info()); let ec_id = ec_context.ec_value().filter(|_| ec_context.ec_allowed()); let consent_context = ec_context.consent(); @@ -8204,6 +8194,16 @@ mod tests { .expect("should parse settings with auction and creative opportunities enabled") } + fn settings_with_disabled_creative_opportunities() -> Settings { + let toml = format!( + "{}\n[auction]\nenabled = true\n\n\ + [creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\nassembly_mode = \"esi\"\n", + crate_test_settings_str() + ); + Settings::from_toml(&toml) + .expect("should parse settings with creative opportunities disabled") + } + fn settings_with_dispatching_provider() -> Settings { let toml = format!( "{}\n[auction]\nenabled = true\nproviders = [\"{UNEXPECTED_304_PROVIDER}\"]\n\n\ @@ -8514,6 +8514,184 @@ mod tests { } } + #[tokio::test] + async fn disabled_creative_opportunities_keep_matching_navigation_inactive() { + let settings = settings_with_disabled_creative_opportunities(); + assert_eq!( + configured_assembly_mode(&settings), + AssemblyMode::Inline, + "disabled template delivery must not activate configured assembly machinery" + ); + 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 response = run_with_slots( + &settings, + &services, + &slots, + conditional_navigation_request(), + ) + .await; + let response_head = response_head(response); + + assert_eq!( + stub.recorded_cache_bypass_flags(), + vec![false], + "disabled template delivery must not bypass the publisher cache" + ); + assert_eq!( + response_head + .headers + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("max-age=60"), + "disabled template delivery should use the inactive HTML policy" + ); + assert_eq!( + response_head.headers.get(header::ETAG), + Some(&HeaderValue::from_static(ORIGIN_ETAG)), + "disabled template delivery must preserve the origin validator" + ); + } + + #[tokio::test] + async fn inactive_html_failure_preserves_origin_cache_policy() { + let settings = settings_with_enabled_auction_and_creative_opportunities(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 500, + b"origin failure".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", "public, max-age=300"), + ("etag", ORIGIN_ETAG), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + + let response = + run_with_slots(&settings, &services, &[], conditional_navigation_request()).await; + let response_head = response_head(response); + + assert_eq!(response_head.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + response_head + .headers + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, max-age=300"), + "failed publisher HTML must retain the origin cache policy" + ); + assert_eq!( + response_head.headers.get(header::ETAG), + Some(&HeaderValue::from_static(ORIGIN_ETAG)), + "failed publisher HTML must retain origin validators" + ); + } + + #[tokio::test] + async fn inactive_html_post_preserves_origin_cache_policy() { + 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 = HttpRequest::builder() + .method(Method::POST) + .uri("https://ts.example.com/article") + .header(header::HOST, "ts.example.com") + .body(EdgeBody::empty()) + .expect("should build publisher POST request"); + + let response = run_with_slots(&settings, &services, &[], req).await; + let response_head = response_head(response); + + assert_eq!(response_head.status, StatusCode::OK); + assert_eq!( + response_head + .headers + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, max-age=300"), + "non-GET publisher HTML must retain the origin cache policy" + ); + } + + #[tokio::test] + async fn inactive_html_preserves_case_insensitive_private_origin_policy() { + let settings = settings_with_enabled_auction_and_creative_opportunities(); + + for origin_policy in ["PuBlIc, PrIvAtE, max-age=300", "public, NO-STORE"] { + 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", origin_policy), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + + let response = + run_with_slots(&settings, &services, &[], conditional_navigation_request()) + .await; + let response_head = response_head(response); + + assert_eq!( + response_head + .headers + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some(origin_policy), + "inactive HTML must preserve origin private/no-store directives" + ); + } + } + + #[tokio::test] + async fn inactive_html_preserves_private_policy_on_a_repeated_header_line() { + let settings = settings_with_enabled_auction_and_creative_opportunities(); + 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"), + ("cache-control", "PrIvAtE"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + + let response = + run_with_slots(&settings, &services, &[], conditional_navigation_request()).await; + let response_head = response_head(response); + let policies = response_head + .headers + .get_all(header::CACHE_CONTROL) + .iter() + .map(|value| value.to_str().expect("cache policy should be ASCII")) + .collect::>(); + + assert_eq!( + policies, + ["public, max-age=300", "PrIvAtE"], + "a private directive on any origin header line must prevent the inactive rewrite" + ); + } + #[tokio::test] async fn eligible_navigation_rejects_unexpected_origin_304() { for content_type in [None, Some("text/html; charset=utf-8")] { @@ -9032,7 +9210,12 @@ mod tests { body.contains(r#""renderTraceOverlay":true"#), "the exact server-owned trace cookie must populate DiagnosticsBootV1: {body}" ); - assert_eq!(body.matches(r#""renderTraceOverlay""#).count(), 1); + assert_eq!( + body.matches(r#""diagnostics":{"version":1,"renderTraceOverlay":true"#) + .count(), + 1, + "the immutable server boot must carry one active diagnostics object" + ); } #[tokio::test] @@ -9676,42 +9859,38 @@ mod tests { #[test] fn server_side_ad_stack_runs_only_when_all_auction_gates_pass() { assert!( - should_run_server_side_ad_stack(true, true, false, false, true, true, true, true), + should_run_server_side_ad_stack(true, true, false, false, true, true, true), "GET, real navigation, matched slots, and consent should run TS ad stack" ); assert!( - !should_run_server_side_ad_stack(false, true, false, false, true, true, true, true), + !should_run_server_side_ad_stack(false, true, false, false, true, true, true), "non-GET requests should skip TS ad stack" ); assert!( - !should_run_server_side_ad_stack(true, false, false, false, true, true, true, true), + !should_run_server_side_ad_stack(true, false, false, false, true, true, true), "non-document requests should skip TS ad stack" ); assert!( - !should_run_server_side_ad_stack(true, true, true, false, true, true, true, true), + !should_run_server_side_ad_stack(true, true, true, false, true, true, true), "prefetch requests should skip TS ad stack and injection" ); assert!( - !should_run_server_side_ad_stack(true, true, false, true, true, true, true, true), + !should_run_server_side_ad_stack(true, true, false, true, true, true, true), "bot requests should skip TS ad stack and injection" ); assert!( - !should_run_server_side_ad_stack(true, true, false, false, false, true, true, true), + !should_run_server_side_ad_stack(true, true, false, false, false, true, true), "requests with no matching slots should skip TS ad stack" ); assert!( - !should_run_server_side_ad_stack(true, true, false, false, true, false, true, true), + !should_run_server_side_ad_stack(true, true, false, false, true, false, true), "requests without required consent should skip TS ad stack and injection" ); assert!( - !should_run_server_side_ad_stack(true, true, false, false, true, true, false, true), + !should_run_server_side_ad_stack(true, true, false, false, true, true, false), "disabled [auction].enabled kill switch should skip TS ad stack and injection" ); - assert!( - !should_run_server_side_ad_stack(true, true, false, false, true, true, true, false), - "disabled creative-opportunity delivery should skip publisher template work" - ); } #[test] @@ -12892,6 +13071,15 @@ mod tests { Settings::from_toml(&toml).expect("should parse settings with creative_opportunities") } + fn settings_with_co_disabled() -> Settings { + let toml = format!( + "{}\n[auction]\nenabled = true\n\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", + crate_test_settings_str() + ); + Settings::from_toml(&toml) + .expect("should parse settings with creative opportunity delivery disabled") + } + async fn run_page_bids( settings: &Settings, orchestrator: &AuctionOrchestrator, @@ -13103,8 +13291,7 @@ mod tests { #[tokio::test] 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. + // An enabled configuration with no matching definitions has no work. let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let req = make_page_bids_request("/2024/01/my-article/"); @@ -13122,6 +13309,22 @@ mod tests { assert_eq!(body["slots"], serde_json::json!([])); } + #[tokio::test] + async fn disabled_creative_opportunities_return_an_exact_empty_projection() { + let settings = settings_with_co_disabled(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let slots = article_slot(); + let req = make_page_bids_request("/2024/01/my-article/"); + + let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, 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"], serde_json::json!([])); + assert_eq!(body["bids"], serde_json::json!([])); + } + #[tokio::test] async fn bot_user_agent_returns_a_terminal_projection_without_bids() { // Crawlers should get slot definitions (so HTML structure is unchanged) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 0e4224c6d..b6f3c5788 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2896,7 +2896,7 @@ impl Settings { ) -> Result, Report> { const REPRESENTATIVE_PATHS: &[&str] = &[ "/integrations/aps", - "/integrations/aps/renderer/v1", + "/integrations/aps/renderer/v2", "/integrations/aps/runner.js", ]; let mut patterns = Vec::new(); diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 1687da05e..70549d0b5 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -477,7 +477,10 @@ pub fn tsjs_bootstrap_fixture_fragment_v1( .map(|product_id| { let config = match product_id { "didomi" => serde_json::json!({ "proxyPath": "/integrations/didomi/" }), - "gpt" => serde_json::json!({ "gamAttributionEnabled": false }), + "gpt" => serde_json::json!({ + "gamAttributionEnabled": false, + "pageBidsEnabled": false, + }), "prebid" => serde_json::json!({ "accountId": "fixture", "timeout": 1_000, @@ -985,7 +988,13 @@ mod tests { ("datadome", serde_json::json!({})), ("didomi", serde_json::json!({ "proxyPath": "/consent/" })), ("google_tag_manager", serde_json::json!({})), - ("gpt", serde_json::json!({ "gamAttributionEnabled": true })), + ( + "gpt", + serde_json::json!({ + "gamAttributionEnabled": true, + "pageBidsEnabled": true, + }), + ), ("lockr", serde_json::json!({})), ("osano", serde_json::json!({})), ("permutive", serde_json::json!({})), @@ -997,7 +1006,7 @@ mod tests { assert_eq!( serde_json::to_string(&configs).expect("carrier should serialize"), - r#"{"version":1,"entries":[{"id":"aps","config":{}},{"id":"datadome","config":{}},{"id":"didomi","config":{"proxyPath":"/consent/"}},{"id":"google_tag_manager","config":{}},{"id":"gpt","config":{"gamAttributionEnabled":true}},{"id":"lockr","config":{}},{"id":"osano","config":{}},{"id":"permutive","config":{}},{"id":"prebid","config":{"accountId":"publisher"}},{"id":"sourcepoint","config":{"rewriteSdk":true}},{"id":"testlight","config":{}}]}"#, + r#"{"version":1,"entries":[{"id":"aps","config":{}},{"id":"datadome","config":{}},{"id":"didomi","config":{"proxyPath":"/consent/"}},{"id":"google_tag_manager","config":{}},{"id":"gpt","config":{"gamAttributionEnabled":true,"pageBidsEnabled":true}},{"id":"lockr","config":{}},{"id":"osano","config":{}},{"id":"permutive","config":{}},{"id":"prebid","config":{"accountId":"publisher"}},{"id":"sourcepoint","config":{"rewriteSdk":true}},{"id":"testlight","config":{}}]}"#, ); } @@ -1246,7 +1255,10 @@ mod tests { fn first_display_outline_binds_the_canonical_integration_config_digest() { let configs = IntegrationConfigsV1::new(vec![( "gpt", - serde_json::json!({ "gamAttributionEnabled": false }), + serde_json::json!({ + "gamAttributionEnabled": false, + "pageBidsEnabled": false, + }), )]) .expect("GPT browser config should be admitted"); let canonical = serde_json::to_string(&configs).expect("carrier should serialize"); @@ -1321,7 +1333,10 @@ mod tests { ], integration_configs: &IntegrationConfigsV1::new(vec![( "gpt", - serde_json::json!({ "gamAttributionEnabled": false }), + serde_json::json!({ + "gamAttributionEnabled": false, + "pageBidsEnabled": false, + }), )]) .expect("GPT browser config should be admitted"), auction_projection_json: VALID_BROWSER_AUCTION_PROJECTION_JSON, diff --git a/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts b/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts index 3ba8f4173..5b7802f42 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts @@ -14,7 +14,7 @@ import { type BrowserName = "chromium" | "firefox" | "webkit"; const RELEASE_ID_PATTERN = /^[0-9a-f]{64}$/u; -const APS_RENDERER_PATH = "/integrations/aps/renderer/v1"; +const APS_RENDERER_PATH = "/integrations/aps/renderer/v2"; const APS_RUNNER_PATH = "/integrations/aps/runner.js"; const EVIDENCE_ROOT = resolve(__dirname, "../real-gam-evidence"); const TERMINAL_STATE = "terminal"; diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts index 182efd617..073bc6871 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts @@ -12,7 +12,7 @@ const GPT_FIXTURE = runtimeTsjsFixture(["render_runtime", "aps", "gpt"]); const SLOT = "puc-lifecycle-slot"; const RESERVATION_ID = "r1_AAAAAAAAAAAAAAAAAAAAAA"; -const APS_RENDERER_URL = "https://puc.test/integrations/aps/renderer/v1"; +const APS_RENDERER_URL = "https://puc.test/integrations/aps/renderer/v2"; const APS_RUNNER_URL = "https://puc.test/integrations/aps/runner.js"; const FICTIONAL_APS_RUNNER = readFileSync( resolve(__dirname, "../../fixtures/fictional-aps-runner.js"), @@ -104,7 +104,7 @@ async function openLifecyclePage( ): Promise { await installGptStub(page); const rendererResponse = await page.request.get( - runtimeUrl("/integrations/aps/renderer/v1"), + runtimeUrl("/integrations/aps/renderer/v2"), ); expect(rendererResponse.status()).toBe(200); const rendererDocument = await rendererResponse.text(); diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-real-gam.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-real-gam.spec.ts index ab4fd8ca4..1974dceaf 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-real-gam.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-real-gam.spec.ts @@ -83,7 +83,7 @@ test.describe("protected real-GAM test network", () => { ).toBe("third-party"); expect( classifyRealGamNetworkUrl( - "https://third-party.example/integrations/aps/renderer/v1", + "https://third-party.example/integrations/aps/renderer/v2", pageOrigin, ), ).toBe("third-party"); 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 c9ec1d4e2..840a11181 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 @@ -5,10 +5,11 @@ import { runtimeUrl } from "../../helpers/state.js"; const IFRAME_CREATIVE_URL = "https://creative.example/iframe"; const APS_TEST_ORIGIN = "https://aps-renderer.test"; -const APS_TEST_RENDERER_URL = `${APS_TEST_ORIGIN}/integrations/aps/renderer/v1`; +const APS_TEST_RENDERER_URL = `${APS_TEST_ORIGIN}/integrations/aps/renderer/v2`; const APS_TEST_RUNNER_URL = `${APS_TEST_ORIGIN}/integrations/aps/runner.js`; const SANDBOX = "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation"; +const PERMANENT_SANDBOX = `${SANDBOX} allow-same-origin`; const FICTIONAL_APS_RUNNER = readFileSync( resolve(__dirname, "../../fixtures/fictional-aps-runner.js"), "utf8", @@ -48,13 +49,13 @@ function descriptor() { }; } -test.describe("APS renderer v1 protocol", () => { +test.describe("APS renderer v2 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/renderer/v1", "/integrations/aps/runner/v1.js", ]) { const response = await page.request.get(runtimeUrl(path)); @@ -66,7 +67,7 @@ test.describe("APS renderer v1 protocol", () => { page, }) => { const rendererResponse = await page.request.get( - runtimeUrl("/integrations/aps/renderer/v1"), + runtimeUrl("/integrations/aps/renderer/v2"), ); expect(rendererResponse.status()).toBe(200); expect(rendererResponse.headers()["content-type"]).toBe( @@ -82,14 +83,13 @@ test.describe("APS renderer v1 protocol", () => { expect(rendererResponse.headers()["x-frame-options"]).toBeUndefined(); const csp = rendererResponse.headers()["content-security-policy"]; expect(csp).toBe( - "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' 'self' https:; connect-src https:; frame-src https: data: blob:; img-src https: data: blob:; media-src https: data: blob:; style-src 'unsafe-inline' https:; font-src https: data:; worker-src https: blob:; form-action https:;", + "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';", ); const rendererDocument = await rendererResponse.text(); - // WebKit correctly treats `self` inside the sandbox as the iframe's opaque - // origin. Fulfil the exact served document at an HTTPS test origin so all - // engines exercise the production `https:` runner allowance; no CSP or - // sandbox relaxation is needed for the local HTTP transport. + // Fulfil the exact served document and its parent at one HTTPS test origin so + // frame-ancestors 'self', parent-origin authentication, and the live runner + // proxy URL all exercise the production shape over the local test transport. await page.route(APS_TEST_RENDERER_URL, (route) => route.fulfill({ status: 200, @@ -118,51 +118,74 @@ test.describe("APS renderer v1 protocol", () => { body: FICTIONAL_APS_RUNNER, }); }); - await page.route(runtimeUrl("/aps-v1-protocol-test"), (route) => + await page.route(`${APS_TEST_ORIGIN}/aps-v2-protocol-test`, (route) => route.fulfill({ status: 200, contentType: "text/html", headers: { "content-security-policy": - "default-src 'none'; script-src 'unsafe-inline'; frame-src https:", + "default-src 'none'; script-src 'unsafe-inline'; frame-src 'self' data: https://creative.example", }, body: `
`, }), ); - await page.goto(runtimeUrl("/aps-v1-protocol-test")); + await page.goto(`${APS_TEST_ORIGIN}/aps-v2-protocol-test`); const makeDescriptor = (bidId: string) => { const value = descriptor(); @@ -182,37 +205,39 @@ window.startApsV1 = function(options) { bidId: string, rendererOverrides: Record = {}, ) => { - const nonce = `n1_${slotId.padEnd(22, "x").slice(0, 22)}`; + const bootstrapNonce = `b1_${slotId.padEnd(22, "b").slice(0, 22)}`; + const rendererNonce = `n1_${slotId.padEnd(22, "n").slice(0, 22)}`; await page.evaluate( - ({ slotId, nonce, renderer }) => { + ({ slotId, bootstrapNonce, rendererNonce, renderer }) => { ( window as unknown as { - startApsV1(options: Record): void; + startApsV2(options: Record): void; } - ).startApsV1({ slotId, nonce, renderer }); + ).startApsV2({ slotId, bootstrapNonce, rendererNonce, renderer }); }, { slotId, - nonce, + bootstrapNonce, + rendererNonce, renderer: { ...makeDescriptor(bidId), ...rendererOverrides, }, }, ); - return nonce; + return rendererNonce; }; const messages = (slotId: string) => page.evaluate( (id) => ( window as unknown as { - apsV1Records: Record< + apsV2Records: Record< string, { messages: Array> } >; } - ).apsV1Records[id]?.messages ?? [], + ).apsV2Records[id]?.messages ?? [], slotId, ); 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 2f14981a6..b7506b5c0 100644 --- a/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs +++ b/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs @@ -27,11 +27,11 @@ const SUCCESS_HEADERS: [&str; 5] = [ // 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); -const RENDERER_V1_PATH: &str = "/integrations/aps/renderer/v1"; +const RENDERER_V2_PATH: &str = "/integrations/aps/renderer/v2"; const CLOUDFLARE_READINESS_TIMEOUT: Duration = Duration::from_secs(30); -const RENDERER_V1_CSP: &str = "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';"; -const RENDERER_V1_DOCUMENT: &str = include_str!( - "../../trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html" +const RENDERER_V2_CSP: &str = "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';"; +const RENDERER_V2_DOCUMENT: &str = include_str!( + "../../trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html" ); struct CorpusCase { @@ -520,7 +520,7 @@ fn wait_for_cloudflare_renderer_readiness(client: &Client, base_url: &str) { let exact = client .request( reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND should be valid"), - format!("{base_url}{RENDERER_V1_PATH}"), + format!("{base_url}{RENDERER_V2_PATH}"), ) .send() .is_ok_and(exact_renderer_method_response); @@ -565,7 +565,7 @@ fn actual_adapter_proxy_corpus() { let response = client .request( reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND should be valid"), - format!("{}{}", process.base_url, RENDERER_V1_PATH), + format!("{}{}", process.base_url, RENDERER_V2_PATH), ) .header("authorization", "Bearer must-not-reach-publisher") .send() @@ -573,7 +573,7 @@ fn actual_adapter_proxy_corpus() { assert_exact_local_failure(response, 405, true); let renderer = client - .get(format!("{}{}", process.base_url, RENDERER_V1_PATH)) + .get(format!("{}{}", process.base_url, RENDERER_V2_PATH)) .send() .expect("renderer request should complete"); assert_eq!(renderer.status().as_u16(), 200); @@ -589,7 +589,7 @@ fn actual_adapter_proxy_corpus() { assert_eq!(renderer.headers()["referrer-policy"], "no-referrer"); assert_eq!( renderer.headers()["content-security-policy"], - RENDERER_V1_CSP + RENDERER_V2_CSP ); assert!(!renderer.headers().contains_key("x-frame-options")); let semantic_headers: BTreeSet<&str> = renderer @@ -618,11 +618,11 @@ fn actual_adapter_proxy_corpus() { .bytes() .expect("renderer body should be readable") .as_ref(), - RENDERER_V1_DOCUMENT.as_bytes() + RENDERER_V2_DOCUMENT.as_bytes() ); for path in [ - "/integrations/aps/renderer/v2", + "/integrations/aps/renderer/v1", "/integrations/aps/runner/v1.js", ] { let response = client diff --git a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs index d3424c144..7c99fcbd7 100644 --- a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs +++ b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs @@ -191,7 +191,7 @@ fn wait_for_aps_route_ready(base_url: &str, options: super::ReadyCheckOptions) - let renderer_url = format!( "{}{}", base_url, - trusted_server_core::integrations::aps::APS_RENDERER_V1_ROUTE + trusted_server_core::integrations::aps::APS_RENDERER_V2_ROUTE ); let probe_method = reqwest::Method::from_bytes(b"PROPFIND").expect("should parse PROPFIND readiness method"); diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index 610be70b7..b22b7c35d 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -19,7 +19,7 @@ use tower::{Service as _, ServiceExt as _}; use trusted_server_adapter_axum::app::TrustedServerApp as AxumApp; use trusted_server_adapter_cloudflare::app::TrustedServerApp as CloudflareApp; use trusted_server_adapter_spin::app::TrustedServerApp as SpinApp; -use trusted_server_core::integrations::aps::APS_RENDERER_V1_ROUTE; +use trusted_server_core::integrations::aps::APS_RENDERER_V2_ROUTE; use trusted_server_core::settings::Settings; /// Shared test settings for all adapters. @@ -779,7 +779,7 @@ fn canonical_headers(headers: &HeaderMap) -> BTreeMap> { fn aps_renderer_request() -> edgezero_core::http::Request { request_builder() .method("GET") - .uri(APS_RENDERER_V1_ROUTE) + .uri(APS_RENDERER_V2_ROUTE) .body(edgezero_core::body::Body::empty()) .expect("should build APS renderer request") } @@ -795,7 +795,7 @@ fn response_parts(response: edgezero_core::http::Response) -> (u16, HeaderMap, b } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn aps_renderer_v1_response_is_exact_across_portable_adapters() { +async fn aps_renderer_v2_response_is_exact_across_portable_adapters() { let axum = trusted_server_adapter_axum::app::dispatch_reserved_with_settings( test_settings(), aps_renderer_request(), diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index b1ce0b222..ebcaffd4f 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -514,7 +514,6 @@ fn copy_bundle(filename: &str, crate_dir: &Path, dist_dir: &Path, out_dir: &Path }); return; } - return; } panic!("tsjs: bundle {filename} was not generated"); } diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 46cd22f52..b9d137f74 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -35,10 +35,15 @@ const bootstrapPrivateProperties = const firstDisplayBasePrivateProperties = /^(?:options|stateValue|agentBatch|slotResults|handoffOwner|mutationObserver|observedMutationRevision|displayWasCommitted|sealed|failed|pending|reasons|actionStarted|disposedDriver|handoffFinalized|committedArtifactsDetached|lastTimingMs|firstActionAtMs|terminalAtMs|paintAtMs|nextTraceSequence|acceptedTrace|recordTerminal|recordFirstAction|scheduleProtectedPaint|readTiming|captureHandoffData|disposeDriver|installNativeMutationIngress|observeDomMutations|isOwnedRuntimeInsertion|closeNativeMutationIngress|disposeNativeMutationIngress|claimTimer|completionTimer|controlRelease|directFrame|documentAccepted|documentAcceptancePending|documentRelease|documentTimer|documentTransferred|insertionTimer|pendingDocumentTerminal|ownerSource|ownerTicket|rendererNonce|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|recordFailure)$/; +// APS adds one private render state machine inside its independently built slice. +// These fields never enter registration, message, or handoff objects. +const firstDisplayApsPrivateProperties = + /^(?:options|stateValue|agentBatch|slotResults|handoffOwner|mutationObserver|observedMutationRevision|displayWasCommitted|sealed|failed|pending|reasons|actionStarted|disposedDriver|handoffFinalized|committedArtifactsDetached|lastTimingMs|firstActionAtMs|terminalAtMs|paintAtMs|nextTraceSequence|acceptedTrace|recordTerminal|recordFirstAction|scheduleProtectedPaint|readTiming|captureHandoffData|disposeDriver|installNativeMutationIngress|observeDomMutations|isOwnedRuntimeInsertion|closeNativeMutationIngress|disposeNativeMutationIngress|claimTimer|completionTimer|controlRelease|directFrame|documentAccepted|documentAcceptancePending|documentRelease|documentTimer|documentTransferred|insertionTimer|pendingDocumentTerminal|ownerSource|ownerTicket|rendererNonce|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|recordFailure|hostPositionOwned|previousHostPosition|previousHostPositionPriority|frameAttributes|frameContentWindow|frameSource|frameSourceDocument|bootstrapNavigated|bootstrapPolicy|bootstrapSource|originalCount|isReservationId|isLifecycleTicket|isBootstrapNonce|isRendererNonce|parseDocumentMessage|rendererUrl|sandbox|permanentSandbox|deadlines|insertionMs|documentAcceptanceMs|completionMs|ownerSettlementMs)$/; + // These names are private to the GPT initial IIFE and never cross its protocol // receipt or handoff boundary. Keep the cross-artifact protocol keys authored. const firstDisplayGptPrivateProperties = - /^(?:binding|command|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting)$/; + /^(?:options|binding|command|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restoreTargetingObservers|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting|sealedTargetingOwnership|targetingWrites|ingressClosing|diagnosticRecords|nextDiagnosticCycleOrdinal|requestTimer|completionTimer|requestOperation|requestInvoked|bindingState|operations|requested|settled|encodedBytes|readPhysicalElementId|retireCycle|snapshotDestroyedCycles|invalidateCyclesForElement|invalidateCyclesForPublisherCall|consumeTargetingWrite|invalidateStaleTargetingObservers|captureRetainedTargeting)$/; fs.rmSync(distributionDirectory, { recursive: true, force: true }); fs.mkdirSync(distributionDirectory, { recursive: true }); @@ -199,11 +204,13 @@ async function buildArtifact(artifact) { root: libDirectory, ...(artifact.role === 'bootstrap' ? { esbuild: { mangleProps: bootstrapPrivateProperties } } - : ['first_display', 'aps_initial'].includes(artifact.id) + : artifact.id === 'first_display' ? { esbuild: { mangleProps: firstDisplayBasePrivateProperties } } - : artifact.id === 'gpt_initial' - ? { esbuild: { mangleProps: firstDisplayGptPrivateProperties } } - : {}), + : artifact.id === 'aps_initial' + ? { esbuild: { mangleProps: firstDisplayApsPrivateProperties } } + : artifact.id === 'gpt_initial' + ? { esbuild: { mangleProps: firstDisplayGptPrivateProperties } } + : {}), define: { __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify(RELEASE_SENTINEL), __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: JSON.stringify(catalogIds), diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs index f31d5bd47..1f9426aa9 100644 --- a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -177,7 +177,7 @@ const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/auction.ts': Object.freeze(['core']), 'src/core/config.ts': Object.freeze(['bootstrap', 'core']), - 'src/core/contracts/aps_renderer.ts': Object.freeze(['bootstrap', 'core', 'aps_initial', 'aps']), + 'src/core/contracts/aps_renderer.ts': Object.freeze(['bootstrap', 'core', 'aps']), 'src/core/contracts/auction_projection.ts': Object.freeze([ 'bootstrap', 'core', @@ -187,14 +187,8 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/contracts/generated/renderer_validator_v1.ts': Object.freeze([ 'bootstrap', 'core', - 'aps_initial', 'aps', ]), - 'src/core/contracts/generated/renderer_validator_document_v1.ts': Object.freeze([ - 'aps_initial', - 'aps', - ]), - 'src/shared/aps_documents.ts': Object.freeze(['aps_initial', 'aps']), 'src/core/contracts/request_ads.ts': Object.freeze(['bootstrap', 'core']), 'src/core/index.ts': Object.freeze(['core']), 'src/core/log.ts': Object.freeze([ diff --git a/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs index c917e11f2..9629e4691 100644 --- a/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs +++ b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs @@ -178,7 +178,7 @@ forbid(routeAndConfigFiles, 'deprecated page-bids route', /\/__ts\/page-bids/g); forbid( routeAndConfigFiles, 'non-canonical APS renderer route', - /\/integrations\/aps\/renderer(?!\/v1(?![A-Za-z0-9_./-]))/g + /\/integrations\/aps\/renderer(?!\/v2(?![A-Za-z0-9_./-]))/g ); const apsIntegrationFile = path.join( repositoryRoot, @@ -189,7 +189,7 @@ forbidSource( apsIntegrationFile, apsIntegrationSource.split('\n#[cfg(test)]')[0] ?? apsIntegrationSource, 'non-canonical APS renderer route', - /\/integrations\/aps\/renderer(?!\/v1(?![A-Za-z0-9_./-]))/g + /\/integrations\/aps\/renderer(?!\/v2(?![A-Za-z0-9_./-]))/g ); if ( !apsIntegrationSource.includes( diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index be4e8b4d9..8cc141cfb 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -135,11 +135,11 @@ export const PROTOCOL_MESSAGE_SCHEMAS_V1 = Object.freeze({ message: TSJS_MESSAGE_PROTOCOL_V1.message.apsBootstrapReady, version: 1, }), - apsBootstrapNavigate: schema( + apsBootstrapConfigure: schema( 'global-json', - ['message', 'version', 'bootstrapNonce', 'containerUrl'], - { message: TSJS_MESSAGE_PROTOCOL_V1.message.apsBootstrapNavigate, version: 1 }, - 196_800 + ['message', 'version', 'bootstrapNonce', 'rendererNonce', 'creativeOrigin', 'tagType'], + { message: TSJS_MESSAGE_PROTOCOL_V1.message.apsBootstrapConfigure, version: 2 }, + 16_384 ), apsInnerReady: schema('global-json', ['message', 'version', 'rendererNonce'], { message: TSJS_MESSAGE_PROTOCOL_V1.message.apsInnerReady, @@ -378,25 +378,6 @@ function exactHttpOrigin(value: unknown): value is string { } } -function apsContainerUrl(value: unknown, bootstrapNonce: unknown): value is string { - if ( - !capability(bootstrapNonce, 'bootstrapNonce') || - !boundedString(value, 196_663, { controls: true }) || - !value.startsWith('data:text/html;charset=utf-8,') - ) { - return false; - } - const suffix = `#${bootstrapNonce}`; - if (!value.endsWith(suffix)) return false; - const encoded = value.slice('data:text/html;charset=utf-8,'.length, -suffix.length); - if (encoded.length === 0 || encoded.includes('#')) return false; - try { - return encodeURIComponent(decodeURIComponent(encoded)) === encoded; - } catch { - return false; - } -} - function skipWhitespace(source: string, start: number): number { let index = start; while (index < source.length && /\s/.test(source[index] ?? '')) index += 1; @@ -680,10 +661,13 @@ function validProtocolFields( ); case 'apsBootstrapReady': return capability(record['bootstrapNonce'], 'bootstrapNonce'); - case 'apsBootstrapNavigate': + case 'apsBootstrapConfigure': return ( capability(record['bootstrapNonce'], 'bootstrapNonce') && - apsContainerUrl(record['containerUrl'], record['bootstrapNonce']) + capability(record['rendererNonce'], 'nonce') && + exactHttpOrigin(record['creativeOrigin']) && + (record['creativeOrigin'] as string).startsWith('https://') && + (record['tagType'] === 'iframe' || record['tagType'] === 'script') ); case 'apsInnerReady': case 'apsInnerBind': diff --git a/crates/trusted-server-js/lib/src/composition/browser_test.ts b/crates/trusted-server-js/lib/src/composition/browser_test.ts index 18488c056..8a6de3652 100644 --- a/crates/trusted-server-js/lib/src/composition/browser_test.ts +++ b/crates/trusted-server-js/lib/src/composition/browser_test.ts @@ -48,7 +48,7 @@ import { } from '../core/registry'; import { prepareAdmIframe } from '../core/render'; import { - APS_RENDERER_V1_PATH, + APS_RENDERER_V2_PATH, renderDirectApsAttempt, renderPucApsAttempt, } from '../integrations/aps/render'; @@ -317,7 +317,7 @@ function testConfigProduct(id: string): string | undefined { function defaultBrowserTestConfig(id: string): Readonly> { if (id === 'didomi') return { proxyPath: '/integrations/didomi/consent/' }; - if (id === 'gpt') return { gamAttributionEnabled: false }; + if (id === 'gpt') return { gamAttributionEnabled: false, pageBidsEnabled: true }; if (id === 'prebid') { return { accountId: 'test', @@ -762,7 +762,7 @@ export function createBrowserComposition( const expectedPublisherOrigin = window.location.origin; return { expectedPublisherOrigin, - expectedRendererUrl: new URL('/integrations/aps/renderer/v1', expectedPublisherOrigin).href, + expectedRendererUrl: new URL('/integrations/aps/renderer/v2', expectedPublisherOrigin).href, validateApsRenderer: defaultValidator, ...options.messagingValidation, }; @@ -1592,7 +1592,7 @@ export function createTestBrowserRuntimeComposition( // to redirect the APS endpoint by predefining that creative-only stamp. const publisherOrigin = trustedDocumentHttpOrigin(window.location.origin); if (!publisherOrigin) throw new Error('Trusted publisher origin is unavailable'); - const rendererUrl = new URL(APS_RENDERER_V1_PATH, publisherOrigin).href; + const rendererUrl = new URL(APS_RENDERER_V2_PATH, publisherOrigin).href; const renderDirectAdm = Object.freeze( (attempt: RenderAttempt, container: HTMLElement): boolean => { try { diff --git a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts index efa5f4116..95faf1d82 100644 --- a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts @@ -14,19 +14,29 @@ const MAX_DIAGNOSTIC_FACTS = 512; const MAX_DIAGNOSTIC_FACT_BYTES = 1_000; const MAX_DIAGNOSTIC_SECTION_BYTES = 512 * 1024; const MAX_U32 = 4_294_967_295; +const SLOT_REQUESTED = 'slotRequested'; +const SLOT_RESPONSE_RECEIVED = 'slotResponseReceived'; +const SLOT_RENDER_ENDED = 'slotRenderEnded'; +const SLOT_ONLOAD = 'slotOnload'; +const IMPRESSION_VIEWABLE = 'impressionViewable'; +const SLOT_VISIBILITY_CHANGED = 'slotVisibilityChanged'; +const OVERLAPPING_REQUEST_CYCLES = 'overlapping_request_cycles'; +const INVALID_EVENT_ORDER = 'invalid_event_order'; +const GPT_REQUEST_FAILED = 'gpt_request_failed'; +const SLOT_UNRESOLVED = 'slot_unresolved'; const DIAGNOSTIC_ONLY_EVENTS = Object.freeze([ - 'slotResponseReceived', - 'slotOnload', - 'impressionViewable', - 'slotVisibilityChanged', + SLOT_RESPONSE_RECEIVED, + SLOT_ONLOAD, + IMPRESSION_VIEWABLE, + SLOT_VISIBILITY_CHANGED, ] as const); const DIAGNOSTIC_EVENT_ORDER: readonly FirstDisplayGptDiagnosticEventV1[] = Object.freeze([ - 'slotRequested', - 'slotResponseReceived', - 'slotRenderEnded', - 'slotOnload', - 'impressionViewable', - 'slotVisibilityChanged', + SLOT_REQUESTED, + SLOT_RESPONSE_RECEIVED, + SLOT_RENDER_ENDED, + SLOT_ONLOAD, + IMPRESSION_VIEWABLE, + SLOT_VISIBILITY_CHANGED, ]); export type FirstDisplayGptRenderResult = 'gam_empty' | 'nonempty_gam'; @@ -35,9 +45,9 @@ export type FirstDisplayGptFailureReason = | 'external_artifact_incompatible' | 'external_ready_timeout' | 'gpt_completion_timeout' - | 'gpt_request_failed' + | typeof GPT_REQUEST_FAILED | 'gpt_request_timeout' - | 'slot_unresolved'; + | typeof SLOT_UNRESOLVED; export interface FirstDisplayGptBoundCycleV1 { readonly bid: FirstDisplayProjectionBidV1; @@ -572,7 +582,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (this.disposed) return; const element = resolveElement(this.options.document, row.placement); if (!element) { - callbacks.onFailure(row.placement.slot, 'slot_unresolved'); + callbacks.onFailure(row.placement.slot, SLOT_UNRESOLVED); continue; } const matches = existing.filter((candidate) => @@ -583,7 +593,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { : false ); if (matches.length > 1) { - callbacks.onFailure(row.placement.slot, 'slot_unresolved'); + callbacks.onFailure(row.placement.slot, SLOT_UNRESOLVED); continue; } const publisherSlot = physicalSlot(matches[0]); @@ -597,7 +607,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { ]) ); if (!slot) { - callbacks.onFailure(row.placement.slot, 'slot_unresolved'); + callbacks.onFailure(row.placement.slot, SLOT_UNRESOLVED); continue; } const ownership = publisherSlot ? 'publisher' : 'trusted_server'; @@ -611,12 +621,12 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze({ initialLoadDisabled: disabled, ownership }) ); if (!plan) { - callbacks.onFailure(row.placement.slot, 'gpt_request_failed'); + callbacks.onFailure(row.placement.slot, GPT_REQUEST_FAILED); continue; } const traceTokenOrdinal = this.nextTraceTokenOrdinal; if (traceTokenOrdinal > 4_294_967_295) { - callbacks.onFailure(row.placement.slot, 'gpt_request_failed'); + callbacks.onFailure(row.placement.slot, GPT_REQUEST_FAILED); continue; } const traceToken = `gt1_${traceTokenOrdinal.toString(36)}`; @@ -687,7 +697,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } catch { if (cycle.settled) continue; - this.failCycle(cycle, callbacks, 'gpt_request_failed'); + this.failCycle(cycle, callbacks, GPT_REQUEST_FAILED); } } } @@ -709,7 +719,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } if (cycle.settled) { this.retireCycle(cycle, callbacks); - this.captureDiagnosticFact('slotRequested', cycle, event); + this.captureDiagnosticFact(SLOT_REQUESTED, cycle, event); return; } if (!cycle.requestInvoked) return; @@ -718,7 +728,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { return; } cycle.requested = true; - this.captureDiagnosticFact('slotRequested', cycle, event); + this.captureDiagnosticFact(SLOT_REQUESTED, cycle, event); if (cycle.requestTimer !== undefined) this.clearOwnedTimer(cycle.requestTimer); }; const renderListener = (event: unknown): void => { @@ -728,7 +738,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { const cycle = slot ? this.cycles.get(slot) : undefined; if (!cycle) return; if (cycle.settled) { - this.captureDiagnosticFact('slotRenderEnded', cycle, event); + this.captureDiagnosticFact(SLOT_RENDER_ENDED, cycle, event); return; } if (!cycle.requested) return; @@ -736,10 +746,10 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze({ isEmpty: member(event, 'isEmpty') }) ); if (!result) { - this.failCycle(cycle, callbacks, 'gpt_request_failed'); + this.failCycle(cycle, callbacks, GPT_REQUEST_FAILED); return; } - this.captureDiagnosticFact('slotRenderEnded', cycle, event); + this.captureDiagnosticFact(SLOT_RENDER_ENDED, cycle, event); cycle.settled = true; if (cycle.requestTimer !== undefined) this.clearOwnedTimer(cycle.requestTimer); if (cycle.completionTimer !== undefined) this.clearOwnedTimer(cycle.completionTimer); @@ -759,10 +769,10 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { }; this.requestedListener = requestedListener; this.renderListener = renderListener; - this.diagnosticListeners.set('slotRequested', requestedListener); - this.diagnosticListeners.set('slotRenderEnded', renderListener); - call(service, 'addEventListener', ['slotRequested', requestedListener]); - call(service, 'addEventListener', ['slotRenderEnded', renderListener]); + this.diagnosticListeners.set(SLOT_REQUESTED, requestedListener); + this.diagnosticListeners.set(SLOT_RENDER_ENDED, renderListener); + call(service, 'addEventListener', [SLOT_REQUESTED, requestedListener]); + call(service, 'addEventListener', [SLOT_RENDER_ENDED, renderListener]); if (this.options.diagnosticsActive === true) { for (const eventType of DIAGNOSTIC_ONLY_EVENTS) { const listener = (event: unknown): void => { @@ -1075,19 +1085,19 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { let disposition: FirstDisplayGptFactV1['disposition'] = 'matched'; let issueReason: FirstDisplayGptFactV1['issueReason'] = null; let diagnosticCycle: FirstDisplayDiagnosticCycleRecord | undefined; - if (eventType === 'slotRequested') { + if (eventType === SLOT_REQUESTED) { if (cycle.diagnosticRecords.some((record) => record.state === 'open')) { disposition = 'ambiguous'; - issueReason = 'overlapping_request_cycles'; + issueReason = OVERLAPPING_REQUEST_CYCLES; } else if (cycle.nextDiagnosticCycleOrdinal > MAX_U32) { disposition = 'unmatched'; - issueReason = 'invalid_event_order'; + issueReason = INVALID_EVENT_ORDER; } else { if (cycle.diagnosticRecords.length >= 10) { const pruneIndex = cycle.diagnosticRecords.findIndex((record) => record.state !== 'open'); if (pruneIndex < 0) { disposition = 'ambiguous'; - issueReason = 'overlapping_request_cycles'; + issueReason = OVERLAPPING_REQUEST_CYCLES; } else { cycle.diagnosticRecords.splice(pruneIndex, 1); cycle.unknownPriorCycle = true; @@ -1097,7 +1107,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { diagnosticCycle = { ordinal: cycle.nextDiagnosticCycleOrdinal, ...(responseIdentifier === undefined ? {} : { responseIdentifier }), - seen: new Set(['slotRequested']), + seen: new Set([SLOT_REQUESTED]), state: 'open', }; cycle.nextDiagnosticCycleOrdinal += 1; @@ -1132,7 +1142,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { diagnosticCycle = candidates[0]; } else if (candidates.length > 1) { disposition = 'ambiguous'; - issueReason = 'overlapping_request_cycles'; + issueReason = OVERLAPPING_REQUEST_CYCLES; } else { const duplicate = cycle.diagnosticRecords.find( (record) => @@ -1143,18 +1153,18 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { ); if (duplicate) { diagnosticCycle = duplicate; - issueReason = 'invalid_event_order'; + issueReason = INVALID_EVENT_ORDER; } else { disposition = 'unmatched'; issueReason = cycle.unknownPriorCycle ? 'unknown_prior_cycle' : 'no_request_cycle'; } } - if (diagnosticCycle && issueReason !== 'invalid_event_order') { + if (diagnosticCycle && issueReason !== INVALID_EVENT_ORDER) { diagnosticCycle.seen.add(eventType); if (diagnosticCycle.responseIdentifier === undefined && responseIdentifier !== undefined) { diagnosticCycle.responseIdentifier = responseIdentifier; } - if (eventType === 'slotRenderEnded') diagnosticCycle.state = 'completed'; + if (eventType === SLOT_RENDER_ENDED) diagnosticCycle.state = 'completed'; } } if (this.options.diagnosticsActive !== true) return; @@ -1171,7 +1181,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } const size = member(event, 'size'); const renderedSize = - eventType === 'slotRenderEnded' && + eventType === SLOT_RENDER_ENDED && Array.isArray(size) && size.length === 2 && size.every( @@ -1199,13 +1209,13 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { capturedAtMs: observedAtMs, elementId: cycle.elementId, adUnitPath: cycle.placement.gamUnitPath, - isEmpty: eventType === 'slotRenderEnded' ? optionalBoolean('isEmpty') : null, + isEmpty: eventType === SLOT_RENDER_ENDED ? optionalBoolean('isEmpty') : null, renderedSize, - isBackfill: eventType === 'slotRenderEnded' ? optionalBoolean('isBackfill') : null, + isBackfill: eventType === SLOT_RENDER_ENDED ? optionalBoolean('isBackfill') : null, slotContentChanged: - eventType === 'slotRenderEnded' ? optionalBoolean('slotContentChanged') : null, + eventType === SLOT_RENDER_ENDED ? optionalBoolean('slotContentChanged') : null, visibilityPercent: - eventType === 'slotVisibilityChanged' && + eventType === SLOT_VISIBILITY_CHANGED && typeof visibility === 'number' && Number.isFinite(visibility) && visibility >= 0 && diff --git a/crates/trusted-server-js/lib/src/first_display/agent.ts b/crates/trusted-server-js/lib/src/first_display/agent.ts index 75d395801..363e68f52 100644 --- a/crates/trusted-server-js/lib/src/first_display/agent.ts +++ b/crates/trusted-server-js/lib/src/first_display/agent.ts @@ -48,6 +48,7 @@ import { import type { FirstDisplayRenderBridgeOptionsV1 } from './render_bridge'; const MAX_U32 = 4_294_967_295; +const BUNDLE_PARTIAL: BootFailureReason = 'bundle_partial'; const AUCTION_PROTOCOLS = ['aps', 'gpt', 'prebid'] as const; const ACTION_KINDS = new Set(['gpt_adm', 'aps']); const TERMINAL_RESULTS = new Set(['accepted', 'failed', 'cancelled']); @@ -431,7 +432,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { ); return true; } catch { - return this.fail('bundle_partial'); + return this.fail(BUNDLE_PARTIAL); } } @@ -457,7 +458,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { this.observedMutationRevision = this.handoffOwner.mutationRevision; return observed; } - if (this.observedMutationRevision >= MAX_U32) return this.fail('bundle_partial'); + if (this.observedMutationRevision >= MAX_U32) return this.fail(BUNDLE_PARTIAL); this.observedMutationRevision += 1; return true; } @@ -520,7 +521,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { private settle(slotId: string, result: FirstDisplayTerminalResult, reason: string | null): void { if (!this.actionStarted) { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } if (this.stateValue !== 'active') return; @@ -531,17 +532,17 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { ? reason !== null : typeof reason !== 'string' || reason.length === 0 || reason.length > 256) ) { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } if (!this.pending.has(slotId)) { - if (!this.slotResults.has(slotId)) this.fail('bundle_partial'); + if (!this.slotResults.has(slotId)) this.fail(BUNDLE_PARTIAL); return; } if (result === 'accepted') { const atMs = this.readTiming(); if (atMs === undefined || this.nextTraceSequence > 4_294_967_295) { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } this.acceptedTrace.set( @@ -562,7 +563,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { this.stateValue = 'terminal'; this.terminalAtMs = this.readTiming(); if (this.terminalAtMs === undefined) { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } this.options.bootstrap.settle(); @@ -571,9 +572,9 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { } private recordFirstAction(): boolean { - if (this.stateValue !== 'active' || this.actionStarted) return this.fail('bundle_partial'); + if (this.stateValue !== 'active' || this.actionStarted) return this.fail(BUNDLE_PARTIAL); if (!this.options.bootstrap.startAction()) { - if (this.options.bootstrap.state !== 'failed') return this.fail('bundle_partial'); + if (this.options.bootstrap.state !== 'failed') return this.fail(BUNDLE_PARTIAL); this.failed = true; this.stateValue = 'failed'; this.disposeDriver(); @@ -582,7 +583,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { } this.actionStarted = true; const firstDisplayMs = this.readTiming(); - if (firstDisplayMs === undefined) return this.fail('bundle_partial'); + if (firstDisplayMs === undefined) return this.fail(BUNDLE_PARTIAL); this.firstActionAtMs = firstDisplayMs; this.mark('tsjs:first-display'); try { @@ -607,28 +608,28 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { try { this.options.driver.sealTsAdmission(); } catch { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } this.sealed = true; this.stateValue = 'painted'; this.paintAtMs = this.readTiming(); if (this.paintAtMs === undefined) { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } this.mark('tsjs:first-display-paint'); try { this.options.onProtectedPaint(); } catch { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); } }; try { if (this.options.paint.hidden()) this.options.paint.scheduleHidden(next); else this.options.paint.requestFrame(next); } catch { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); } } @@ -885,7 +886,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { }); this.mutationObserver = observer; } catch { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); } } @@ -894,7 +895,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { try { this.options.driver.sweepCommittedArtifacts(); } catch { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } } diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts index 574bfe0b4..1f2caeea8 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts @@ -1,5 +1,4 @@ import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; -import { generateApsDataDocumentsV1 } from '../../shared/aps_documents'; import { createFirstDisplayRenderBridge, type FirstDisplayRenderBridgeOptionsV1, @@ -14,8 +13,9 @@ export type FirstDisplayApsDocumentMessageV1 = reason: 'descriptor_invalid' | 'runner_no_load' | 'runner_failed'; }>; -export interface FirstDisplayApsDocumentsV1 { - readonly outerUrl: string; +export interface FirstDisplayApsBootstrapPolicyV2 { + readonly creativeOrigin: string; + readonly tagType: 'iframe' | 'script'; } export interface FirstDisplayApsProtocolV1 { @@ -35,11 +35,9 @@ export interface FirstDisplayApsProtocolV1 { readonly isLifecycleTicket: (candidate: unknown) => candidate is string; readonly isBootstrapNonce: (candidate: unknown) => candidate is string; readonly isRendererNonce: (candidate: unknown) => candidate is string; - readonly generateDocuments: ( - renderer: unknown, - bootstrapNonce: string, - rendererNonce: string - ) => Readonly | undefined; + readonly bootstrapPolicy: ( + renderer: unknown + ) => Readonly | undefined; readonly parseDocumentMessage: ( candidate: unknown, expectedNonce: string @@ -172,6 +170,63 @@ function parseDocumentMessage( return undefined; } +function bootstrapPolicy( + candidate: unknown, + publisherOrigin: string +): Readonly | undefined { + try { + const renderer = exactRecord( + candidate, + Object.prototype.hasOwnProperty.call(candidate, 'creativeId') + ? [ + 'aaxResponse', + 'accountId', + 'bidId', + 'creativeId', + 'creativeUrl', + 'height', + 'tagType', + 'type', + 'version', + 'width', + ] + : [ + 'aaxResponse', + 'accountId', + 'bidId', + 'creativeUrl', + 'height', + 'tagType', + 'type', + 'version', + 'width', + ] + ); + if ( + !renderer || + renderer.type !== 'aps' || + renderer.version !== 1 || + (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') || + typeof renderer.creativeUrl !== 'string' + ) { + return undefined; + } + const creative = new URL(renderer.creativeUrl); + if ( + creative.protocol !== 'https:' || + creative.hostname === '' || + creative.username !== '' || + creative.password !== '' || + creative.origin === publisherOrigin + ) { + return undefined; + } + return Object.freeze({ creativeOrigin: creative.origin, tagType: renderer.tagType }); + } catch { + return undefined; + } +} + /** Register the exact APS reservation identity and renderer-document protocol. */ export function installApsInitial( candidate: unknown, @@ -179,7 +234,7 @@ export function installApsInitial( ): Readonly<{ version: 1; id: 'aps' }> { const value = bindings(candidate); if (!value || typeof own !== 'function') throw new TypeError('tsjs'); - const rendererUrl = new URL('/integrations/aps/renderer/v1', value.publisherOrigin).href; + const rendererUrl = new URL('/integrations/aps/renderer/v2', value.publisherOrigin).href; const protocol: FirstDisplayApsProtocolV1 = Object.freeze({ version: 1, id: 'aps', @@ -197,15 +252,7 @@ export function installApsInitial( isLifecycleTicket: (input: unknown): input is string => exactOpaqueId(input, 't1_'), isBootstrapNonce: (input: unknown): input is string => exactOpaqueId(input, 'b1_'), isRendererNonce: (input: unknown): input is string => exactOpaqueId(input, 'n1_'), - generateDocuments: (renderer: unknown, bootstrapNonce: string, rendererNonce: string) => { - const documents = generateApsDataDocumentsV1({ - renderer, - publisherOrigin: value.publisherOrigin, - bootstrapNonce, - rendererNonce, - }); - return documents ? Object.freeze({ outerUrl: documents.outerUrl }) : undefined; - }, + bootstrapPolicy: (renderer: unknown) => bootstrapPolicy(renderer, value.publisherOrigin), parseDocumentMessage, createRenderBridge: (options: Omit) => createFirstDisplayRenderBridge({ ...options, getAps: () => protocol }), diff --git a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts index 559a0eb6f..5c834e151 100644 --- a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts @@ -9,6 +9,13 @@ import type { FirstDisplayApsProtocolV1 } from './leaf/aps_protocol'; const ADM_SANDBOX = 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; +const RENDERER_DOCUMENT_NO_LOAD = 'renderer_document_no_load'; +const INTERNAL_ERROR = 'internal_error'; +const RUNNER_FAILED = 'runner_failed'; +const ADM_DOCUMENT_NO_LOAD = 'adm_document_no_load'; +const WINNER_NOT_RENDERABLE = 'winner_not_renderable'; +const NAVIGATION_DISPOSED = 'navigation_disposed'; +const SLOT_UNRESOLVED = 'slot_unresolved'; const CLAIM_DEADLINE_MS = 3_000; const ADM_LOAD_DEADLINE_MS = 5_000; const TICKET_TTL_MS = 3_000; @@ -62,7 +69,6 @@ interface Attempt { readonly onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void; readonly reservationId: string; active: boolean; - apsContainerUrl: string | undefined; bootstrapNavigated: boolean; bootstrapNonce: string | undefined; bootstrapSource: object | undefined; @@ -89,7 +95,11 @@ interface Attempt { rendererNonce: string | undefined; ownerSource: object | undefined; pendingDocumentTerminal: - 'completed' | 'winner_not_renderable' | 'runner_no_load' | 'runner_failed' | undefined; + | 'completed' + | typeof WINNER_NOT_RENDERABLE + | 'runner_no_load' + | typeof RUNNER_FAILED + | undefined; phaseValue: | 'waiting_for_gam_and_claim' | 'waiting_for_owner' @@ -839,7 +849,6 @@ export function createFirstDisplayRenderBridge( attempt.bootstrapNavigated = false; attempt.bootstrapNonce = undefined; attempt.bootstrapSource = undefined; - attempt.apsContainerUrl = undefined; attempt.rendererNonce = undefined; retireTicket(attempt); retireReservation(attempt); @@ -880,7 +889,7 @@ export function createFirstDisplayRenderBridge( const settle = ( attempt: Attempt, result: 'accepted' | 'failed' | 'cancelled', - reason = result === 'cancelled' ? 'navigation_disposed' : 'internal_error' + reason = result === 'cancelled' ? NAVIGATION_DISPOSED : INTERNAL_ERROR ): boolean => { if (!attempt.active) return false; attempt.active = false; @@ -1023,33 +1032,36 @@ export function createFirstDisplayRenderBridge( const inspection = inspectPorts(event, messageEventPrototype); if (!inspection?.exact || inspection.originalCount !== 0 || inspection.ports.length !== 0) { for (const port of inspection?.ports ?? []) closePort(port); - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } const aps = apsProtocol(); if (!aps || attempt.bootstrapNavigated || !exactApsFrame(attempt, false)) { - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } - const containerUrl = attempt.apsContainerUrl; - if (!containerUrl) { - fail(attempt, 'winner_not_renderable'); + const rendererNonce = attempt.rendererNonce; + const policy = aps.bootstrapPolicy(attempt.cycle.bid.renderSource); + if (!rendererNonce || !policy) { + fail(attempt, WINNER_NOT_RENDERABLE); return true; } try { attempt.directFrame?.setAttribute('sandbox', aps.permanentSandbox); } catch { - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } const navigation = JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl, + rendererNonce, + creativeOrigin: policy.creativeOrigin, + tagType: policy.tagType, }); if (!exactApsFrame(attempt, true) || !postWindow(source, navigation)) { - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } attempt.bootstrapNavigated = true; @@ -1095,17 +1107,17 @@ export function createFirstDisplayRenderBridge( !exactApsFrame(attempt, true) ) { for (const candidate of inspection?.ports ?? []) closePort(candidate); - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } attempt.documentPort = port; attempt.documentRelease = listen( port, (portEvent) => handleApsDocument(attempt, portEvent), - () => fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load') + () => fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD) ); if (!attempt.documentRelease) { - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } bootstrapNonces.delete(bootstrapNonce); @@ -1120,7 +1132,7 @@ export function createFirstDisplayRenderBridge( }) || !exactApsFrame(attempt, true) ) { - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); } return true; }; @@ -1129,12 +1141,12 @@ export function createFirstDisplayRenderBridge( const aps = apsProtocol(); if (!attempt.active || !attempt.rendererNonce || !aps) return; if (!exactApsFrame(attempt, true)) { - fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); + fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } const inspection = inspectPorts(event, messageEventPrototype); if (!inspection?.exact || inspection.originalCount !== 0 || inspection.ports.length !== 0) { - fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); + fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } const parsed = aps.parseDocumentMessage( @@ -1142,7 +1154,7 @@ export function createFirstDisplayRenderBridge( attempt.rendererNonce ); if (!parsed) { - fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); + fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } const acceptDocument = (): void => { @@ -1154,10 +1166,7 @@ export function createFirstDisplayRenderBridge( attempt.documentAcceptancePending = false; clearOwnedTimer(attempt.documentTimer); attempt.documentTimer = undefined; - attempt.completionTimer = arm( - () => fail(attempt, 'runner_failed'), - aps.deadlines.completionMs - ); + attempt.completionTimer = arm(() => fail(attempt, RUNNER_FAILED), aps.deadlines.completionMs); const pending = attempt.pendingDocumentTerminal; attempt.pendingDocumentTerminal = undefined; if (pending === 'completed') completeAps(attempt); @@ -1176,36 +1185,36 @@ export function createFirstDisplayRenderBridge( if (attempt.documentAccepted) completeAps(attempt); else if (attempt.documentAcceptancePending && !attempt.pendingDocumentTerminal) { attempt.pendingDocumentTerminal = 'completed'; - } else fail(attempt, 'renderer_document_no_load'); + } else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return; } const failureReason = - parsed.reason === 'descriptor_invalid' ? 'winner_not_renderable' : parsed.reason; + parsed.reason === 'descriptor_invalid' ? WINNER_NOT_RENDERABLE : parsed.reason; if (attempt.documentAccepted) fail(attempt, failureReason); else if (attempt.documentAcceptancePending && !attempt.pendingDocumentTerminal) { attempt.pendingDocumentTerminal = failureReason; - } else fail(attempt, 'renderer_document_no_load'); + } else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); } function completeAps(attempt: Attempt): void { if (!attempt.active || !exactApsFrame(attempt, true)) { - fail(attempt, 'slot_unresolved'); + fail(attempt, SLOT_UNRESOLVED); return; } if (attempt.overlay) { try { const frame = attempt.directFrame; if (!frame) { - fail(attempt, 'slot_unresolved'); + fail(attempt, SLOT_UNRESOLVED); return; } frame.style.setProperty('visibility', 'visible'); if (frame.style.getPropertyValue('visibility') !== 'visible') { - fail(attempt, 'internal_error'); + fail(attempt, INTERNAL_ERROR); return; } } catch { - fail(attempt, 'internal_error'); + fail(attempt, INTERNAL_ERROR); return; } } @@ -1218,10 +1227,7 @@ export function createFirstDisplayRenderBridge( clearOwnedTimer(attempt.insertionTimer); attempt.insertionTimer = undefined; if (attempt.cycle.bid.renderSource.type === 'adm') { - attempt.documentTimer = arm( - () => fail(attempt, 'adm_document_no_load'), - ADM_LOAD_DEADLINE_MS - ); + attempt.documentTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); } else if (attempt.documentAcceptancePending) { const nonce = attempt.rendererNonce; if (nonce) { @@ -1233,7 +1239,7 @@ export function createFirstDisplayRenderBridge( } else { const aps = apsProtocol(); attempt.documentTimer = arm( - () => fail(attempt, 'renderer_document_no_load'), + () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD), aps?.deadlines.documentAcceptanceMs ?? 3_000 ); } @@ -1243,7 +1249,7 @@ export function createFirstDisplayRenderBridge( if (!attempt.active) return; const ports = inspectPorts(event, messageEventPrototype); if (!ports?.exact || ports.originalCount !== 0 || ports.ports.length !== 0) { - fail(attempt, 'internal_error'); + fail(attempt, INTERNAL_ERROR); return; } const data = eventField(event, 'data', messageEventPrototype); @@ -1259,7 +1265,7 @@ export function createFirstDisplayRenderBridge( return; } if (attempt.cycle.bid.renderSource.type !== 'adm') { - fail(attempt, 'internal_error'); + fail(attempt, INTERNAL_ERROR); return; } const loaded = exactRecord(data, ['message', 'version', 'lifecycleTicket']); @@ -1277,10 +1283,10 @@ export function createFirstDisplayRenderBridge( loaded.version === 1 && loaded.lifecycleTicket === ticket ) { - fail(attempt, 'adm_document_no_load'); + fail(attempt, ADM_DOCUMENT_NO_LOAD); return; } - fail(attempt, 'adm_document_no_load'); + fail(attempt, ADM_DOCUMENT_NO_LOAD); }; const startOwner = (attempt: Attempt): boolean => { @@ -1300,7 +1306,7 @@ export function createFirstDisplayRenderBridge( source: attempt.cycle.bid.renderSource, }); } - if (!aps) return fail(attempt, 'winner_not_renderable'); + if (!aps) return fail(attempt, WINNER_NOT_RENDERABLE); attempt.overlay = true; if (!renderAps(attempt)) return false; return ( @@ -1308,7 +1314,7 @@ export function createFirstDisplayRenderBridge( message: 'TS APS Top Mount Started', version: 1, lifecycleTicket: ticket, - }) || fail(attempt, 'internal_error') + }) || fail(attempt, INTERNAL_ERROR) ); }; @@ -1356,7 +1362,7 @@ export function createFirstDisplayRenderBridge( } catch { post(responsePort, ownerRefused(attempt.reservationId)); closePort(responsePort); - fail(attempt, 'internal_error'); + fail(attempt, INTERNAL_ERROR); return; } attempt.ownerTicket = ticket; @@ -1367,7 +1373,7 @@ export function createFirstDisplayRenderBridge( () => fail( attempt, - attempt.cycle.bid.renderSource.type === 'adm' ? 'adm_document_no_load' : 'internal_error' + attempt.cycle.bid.renderSource.type === 'adm' ? ADM_DOCUMENT_NO_LOAD : INTERNAL_ERROR ) ); attempt.phaseValue = 'waiting_for_insertion'; @@ -1382,7 +1388,7 @@ export function createFirstDisplayRenderBridge( closePort(responsePort); closePort(channel.port2); if (!posted || !startOwner(attempt)) { - if (attempt.active) fail(attempt, 'internal_error'); + if (attempt.active) fail(attempt, INTERNAL_ERROR); } }; @@ -1463,7 +1469,7 @@ export function createFirstDisplayRenderBridge( const posted = utf8Length(response) <= MAX_RESPONSE_BYTES && post(claim.port, response); closePort(claim.port); if (!posted || !attempt.active) { - if (attempt.active) fail(attempt, 'internal_error'); + if (attempt.active) fail(attempt, INTERNAL_ERROR); return false; } attempt.ownerSource = claim.source; @@ -1537,17 +1543,14 @@ export function createFirstDisplayRenderBridge( settle(attempt, 'accepted'); } }; - frame.onerror = () => fail(attempt, 'adm_document_no_load'); + frame.onerror = () => fail(attempt, ADM_DOCUMENT_NO_LOAD); frame.srcdoc = intended; attempt.directFrame = frame; - attempt.documentTimer = arm( - () => fail(attempt, 'adm_document_no_load'), - ADM_LOAD_DEADLINE_MS - ); + attempt.documentTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); attempt.cycle.element.appendChild(frame); return true; } catch { - return fail(attempt, 'adm_document_no_load'); + return fail(attempt, ADM_DOCUMENT_NO_LOAD); } }; @@ -1561,42 +1564,33 @@ export function createFirstDisplayRenderBridge( aps.deadlines.insertionMs ); } - if (attempt.insertionTimer === undefined) return fail(attempt, 'internal_error'); + if (attempt.insertionTimer === undefined) return fail(attempt, INTERNAL_ERROR); const bootstrapNonce = issueBootstrapNonce(attempt); const rendererNonce = issueRendererNonce(attempt); if (!bootstrapNonce || !rendererNonce) return fail(attempt, 'capability_registry_full'); - let documents: ReturnType; + let policy: ReturnType; try { - documents = aps.generateDocuments(source, bootstrapNonce, rendererNonce); + policy = aps.bootstrapPolicy(source); } catch { - documents = undefined; - } - if ( - !documents || - typeof documents.outerUrl !== 'string' || - !documents.outerUrl.startsWith('data:text/html;charset=utf-8,') || - !documents.outerUrl.endsWith('#' + bootstrapNonce) || - utf8Length(documents.outerUrl) > 196_663 - ) { - return fail(attempt, 'winner_not_renderable'); + policy = undefined; } + if (!policy) return fail(attempt, WINNER_NOT_RENDERABLE); try { const frame = options.document.createElement('iframe'); configureFrame(frame, source.width, source.height, aps.sandbox, attempt.overlay); const intended = aps.rendererUrl + '#' + bootstrapNonce; - frame.onerror = () => fail(attempt, 'renderer_document_no_load'); + frame.onerror = () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD); frame.src = intended; - attempt.apsContainerUrl = documents.outerUrl; attempt.directFrame = frame; - if (!acquireOverlayPosition(attempt)) return fail(attempt, 'slot_unresolved'); + if (!acquireOverlayPosition(attempt)) return fail(attempt, SLOT_UNRESOLVED); attempt.cycle.element.appendChild(frame); const intendedWindow = frame.contentWindow; - if (!intendedWindow) return fail(attempt, 'renderer_document_no_load'); + if (!intendedWindow) return fail(attempt, RENDERER_DOCUMENT_NO_LOAD); attempt.bootstrapSource = intendedWindow; ownerInserted(attempt); - return exactApsFrame(attempt, false) || fail(attempt, 'renderer_document_no_load'); + return exactApsFrame(attempt, false) || fail(attempt, RENDERER_DOCUMENT_NO_LOAD); } catch { - return fail(attempt, 'renderer_document_no_load'); + return fail(attempt, RENDERER_DOCUMENT_NO_LOAD); } }; @@ -1697,7 +1691,6 @@ export function createFirstDisplayRenderBridge( } const attempt: Attempt = { active: true, - apsContainerUrl: undefined, bootstrapNavigated: false, bootstrapNonce: undefined, bootstrapSource: undefined, @@ -1756,7 +1749,7 @@ export function createFirstDisplayRenderBridge( if (result === 'gam_empty') return renderDirectFallback(attempt); if (attempt.claim) return join(attempt); attempt.claimTimer = arm(() => fail(attempt, 'bridge_claim_timeout'), CLAIM_DEADLINE_MS); - return attempt.claimTimer !== undefined || fail(attempt, 'internal_error'); + return attempt.claimTimer !== undefined || fail(attempt, INTERNAL_ERROR); }, recordFailure: (cycle: FirstDisplayGptBoundCycleV1): boolean => { const attempt = attempts.get(cycle.bid.rendererReservationId); @@ -1861,7 +1854,7 @@ export function createFirstDisplayRenderBridge( } } for (const attempt of [...attempts.values()]) { - if (attempt.active) settle(attempt, 'cancelled', 'navigation_disposed'); + if (attempt.active) settle(attempt, 'cancelled', NAVIGATION_DISPOSED); } for (const handle of [...timers]) clearOwnedTimer(handle); if (!committedArtifactsDetached) { diff --git a/crates/trusted-server-js/lib/src/integrations/aps/module.ts b/crates/trusted-server-js/lib/src/integrations/aps/module.ts index 821168fb1..596e34fc1 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/module.ts @@ -18,7 +18,7 @@ import type { PucApsMountInput } from '../../services/puc_bridge'; import { renderDirectApsAttempt, renderPucApsAttempt, - resolveApsRendererV1Url, + resolveApsRendererV2Url, validateApsRenderer, } from './render'; @@ -85,7 +85,7 @@ export function createApsIntegrationRegistration(releaseId: string): Integration ) { throw new TypeError('APS capability graph is malformed'); } - const rendererUrl = resolveApsRendererV1Url(render.publisherOrigin); + const rendererUrl = resolveApsRendererV2Url(render.publisherOrigin); if (!rendererUrl) throw new TypeError('APS publisher origin is invalid'); let active = false; const renderer = (attempt: RenderAttempt, container: HTMLElement): boolean => 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 6f36ad48b..414314c52 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -11,8 +11,6 @@ import type { } from '../../services/render'; import type { ApsSlotMountBinding } from '../../services/slots'; -import { APS_PERMANENT_SANDBOX, generateApsDataDocumentsV1 } from './documents'; - const objectFreezeIntrinsic = Object.freeze; const objectGetPrototypeOfIntrinsic = Object.getPrototypeOf; const regexpTestIntrinsic = RegExp.prototype.test; @@ -84,11 +82,12 @@ const iframeSourceGetter = directDomAvailable : undefined; export { parseApsRendererDescriptor, validateApsRenderer } from '../../core/contracts/aps_renderer'; -export { APS_PERMANENT_SANDBOX, generateApsDataDocumentsV1 }; -export const APS_RENDERER_V1_PATH = '/integrations/aps/renderer/v1'; +export const APS_RENDERER_V2_PATH = '/integrations/aps/renderer/v2'; export const APS_RENDERER_SANDBOX = 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; +export const APS_PERMANENT_SANDBOX = + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation'; /** Validate, copy, and freeze one APS tagged render source. */ export function prepareApsRenderSource( @@ -134,7 +133,7 @@ function freeze(value: Value): Readonly { return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; } -export function resolveApsRendererV1Url(publisherOrigin: string): string | undefined { +export function resolveApsRendererV2Url(publisherOrigin: string): string | undefined { try { const origin = new URL(publisherOrigin); const loopbackHttp = @@ -150,10 +149,10 @@ export function resolveApsRendererV1Url(publisherOrigin: string): string | undef ) { return undefined; } - const rendererUrl = new URL(APS_RENDERER_V1_PATH, origin); + const rendererUrl = new URL(APS_RENDERER_V2_PATH, origin); if ( rendererUrl.origin !== origin.origin || - rendererUrl.pathname !== APS_RENDERER_V1_PATH || + rendererUrl.pathname !== APS_RENDERER_V2_PATH || rendererUrl.search !== '' || rendererUrl.hash !== '' ) { @@ -319,11 +318,18 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole exactDocumentOrigin = false; } const renderer = prepareApsRenderSource(sourceCandidate, publisherOrigin); - const rendererUrl = resolveApsRendererV1Url(publisherOrigin); + const rendererUrl = resolveApsRendererV2Url(publisherOrigin); + let creativeOrigin: string | undefined; + try { + creativeOrigin = renderer ? new URL(renderer.creativeUrl).origin : undefined; + } catch { + creativeOrigin = undefined; + } if ( !exactDocumentOrigin || !renderer || !rendererUrl || + !creativeOrigin || (mode === 'puc_overlay' && !expectedContainerId) ) { try { @@ -445,13 +451,6 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole const bootstrapNonce = issuedBootstrap.nonce; const rendererNonce = issuedRenderer.nonce; - const documents = generateApsDataDocumentsV1({ - renderer, - publisherOrigin, - bootstrapNonce, - rendererNonce, - }); - if (!documents) return fail('winner_not_renderable'); let iframe: HTMLIFrameElement; try { @@ -922,10 +921,12 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole return; } const navigation = JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: documents.outerUrl, + rendererNonce, + creativeOrigin, + tagType: renderer.tagType, }); if ( Reflect.apply(postWindowMethod, messaging, [frameSource, navigation, '*', []]) !== true || diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/later.ts b/crates/trusted-server-js/lib/src/integrations/gpt/later.ts index 6ec9a5f7c..146da8136 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/later.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/later.ts @@ -72,6 +72,7 @@ export function createGptLaterIntegrationRegistration(releaseId: string): Integr ) { throw new TypeError('GPT later capability graph is invalid'); } + const pageBidsEnabled = config.pageBidsEnabled; return Object.freeze({ activate: ({ onDispose }: IntegrationActivationContext) => { const candidateView = runtime.document.defaultView; @@ -176,6 +177,7 @@ export function createGptLaterIntegrationRegistration(releaseId: string): Integr ) { throw new TypeError('GPT later lifecycle owner is invalid'); } + if (!pageBidsEnabled) return; wrappedPushState = wrap(pushState); wrappedReplaceState = wrap(replaceState); Object.defineProperty(history, 'pushState', { diff --git a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts index ae0dbdff6..75c31417e 100644 --- a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts @@ -460,7 +460,7 @@ interface ApsMessagingValidationRegistration { readonly validateApsRenderer: (candidate: unknown) => boolean; } -const APS_RENDERER_PATH = '/integrations/aps/renderer/v1'; +const APS_RENDERER_PATH = '/integrations/aps/renderer/v2'; function exactRuntimeCapability( interfaces: Readonly> diff --git a/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts b/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts index 663c497d5..759449541 100644 --- a/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts +++ b/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts @@ -15,7 +15,7 @@ export const TSJS_MESSAGE_PROTOCOL_V1 = Object.freeze({ admLoaded: 'TS ADM Loaded' as const, admFailed: 'TS ADM Failed' as const, apsBootstrapReady: 'TS APS Bootstrap Ready' as const, - apsBootstrapNavigate: 'TS APS Bootstrap Navigate' as const, + apsBootstrapConfigure: 'TS APS Bootstrap Configure' as const, apsInnerReady: 'TS APS Inner Ready' as const, apsInnerBind: 'TS APS Inner Bind' as const, apsContainerReady: 'TS APS Container Ready' as const, diff --git a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts index d7403b465..79d7c2e8e 100644 --- a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts +++ b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts @@ -148,10 +148,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object id: 'aps_initial', include: 'aps_participates', allowedImports: [ - 'core/contracts/aps_renderer', - 'core/contracts/generated/renderer_validator_document_v1', - 'core/contracts/generated/renderer_validator_v1', - 'shared/aps_documents', 'shared/first_display_contracts', 'first_display/registration_client', 'first_display/slices/definition', diff --git a/crates/trusted-server-js/lib/src/shared/aps_documents.ts b/crates/trusted-server-js/lib/src/shared/aps_documents.ts index 8395256b8..94fd77e52 100644 --- a/crates/trusted-server-js/lib/src/shared/aps_documents.ts +++ b/crates/trusted-server-js/lib/src/shared/aps_documents.ts @@ -72,6 +72,8 @@ export interface ApsDataDocumentsV1 { readonly innerUrl: string; } +// Build-time authority for the renderer-v2 response generator and its contract tests. +// Production TSJS artifacts must not import this module. const INNER_TEMPLATE = ` diff --git a/crates/trusted-server-js/lib/src/shared/integration_config_validators.ts b/crates/trusted-server-js/lib/src/shared/integration_config_validators.ts index a32a80186..4ae83f69a 100644 --- a/crates/trusted-server-js/lib/src/shared/integration_config_validators.ts +++ b/crates/trusted-server-js/lib/src/shared/integration_config_validators.ts @@ -75,9 +75,19 @@ function frozenStringArray(candidate: unknown): candidate is readonly string[] { } } -export function isGptIntegrationConfigV1(candidate: unknown): boolean { - const config = exactFrozenRecord(candidate, ['gamAttributionEnabled']); - return typeof config?.gamAttributionEnabled === 'boolean'; +export interface GptIntegrationConfigV1 { + readonly gamAttributionEnabled: boolean; + readonly pageBidsEnabled: boolean; +} + +export function isGptIntegrationConfigV1( + candidate: unknown +): candidate is Readonly { + const config = exactFrozenRecord(candidate, ['gamAttributionEnabled', 'pageBidsEnabled']); + return ( + typeof config?.gamAttributionEnabled === 'boolean' && + typeof config.pageBidsEnabled === 'boolean' + ); } export function isDidomiIntegrationConfigV1(candidate: unknown): boolean { 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 357121707..88b72a384 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -693,7 +693,7 @@ describe('browser messaging adapter', () => { admLoaded: 'TS ADM Loaded', admFailed: 'TS ADM Failed', apsBootstrapReady: 'TS APS Bootstrap Ready', - apsBootstrapNavigate: 'TS APS Bootstrap Navigate', + apsBootstrapConfigure: 'TS APS Bootstrap Configure', apsInnerReady: 'TS APS Inner Ready', apsInnerBind: 'TS APS Inner Bind', apsContainerReady: 'TS APS Container Ready', @@ -773,13 +773,19 @@ describe('browser messaging adapter', () => { const adapter = createBrowserMessagingAdapter(createTarget()); const bootstrapNonce = `b1_${'b'.repeat(22)}`; const rendererNonce = `n1_${'n'.repeat(22)}`; - const documentSource = '

container

'; - const containerUrl = `data:text/html;charset=utf-8,${encodeURIComponent(documentSource)}#${bootstrapNonce}`; + const creativeOrigin = 'https://creative.example'; const messages = [ ['apsBootstrapReady', { message: 'TS APS Bootstrap Ready', version: 1, bootstrapNonce }], [ - 'apsBootstrapNavigate', - { message: 'TS APS Bootstrap Navigate', version: 1, bootstrapNonce, containerUrl }, + 'apsBootstrapConfigure', + { + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce, + creativeOrigin, + tagType: 'iframe', + }, ], ['apsInnerReady', { message: 'TS APS Inner Ready', version: 1, rendererNonce }], ['apsInnerBind', { message: 'TS APS Inner Bind', version: 1, rendererNonce }], @@ -801,46 +807,46 @@ describe('browser messaging adapter', () => { } expect( adapter.parseProtocolMessage( - 'apsBootstrapNavigate', - `{"message":"TS APS Bootstrap Navigate","version":1,"bootstrapNonce":"${bootstrapNonce}","bootstrapNonce":"${bootstrapNonce}","containerUrl":${JSON.stringify(containerUrl)}}` + 'apsBootstrapConfigure', + `{"message":"TS APS Bootstrap Configure","version":2,"bootstrapNonce":"${bootstrapNonce}","bootstrapNonce":"${bootstrapNonce}","rendererNonce":"${rendererNonce}","creativeOrigin":"${creativeOrigin}","tagType":"iframe"}` ) ).toBeUndefined(); expect( adapter.parseProtocolMessage( - 'apsBootstrapNavigate', + 'apsBootstrapConfigure', JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: `${containerUrl}x`, + rendererNonce, + creativeOrigin: 'http://creative.example', + tagType: 'iframe', }) ) ).toBeUndefined(); - const dataPrefix = 'data:text/html;charset=utf-8,'; - const nonceSuffix = `#${bootstrapNonce}`; - const boundaryUrl = `${dataPrefix}${'a'.repeat( - 196_663 - dataPrefix.length - nonceSuffix.length - )}${nonceSuffix}`; - expect(new TextEncoder().encode(boundaryUrl)).toHaveLength(196_663); expect( adapter.parseProtocolMessage( - 'apsBootstrapNavigate', + 'apsBootstrapConfigure', JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: boundaryUrl, + rendererNonce, + creativeOrigin, + tagType: 'script', }) ) ).toBeDefined(); expect( adapter.parseProtocolMessage( - 'apsBootstrapNavigate', + 'apsBootstrapConfigure', JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: `${boundaryUrl.slice(0, -nonceSuffix.length)}a${nonceSuffix}`, + rendererNonce, + creativeOrigin, + tagType: 'image', }) ) ).toBeUndefined(); @@ -1081,7 +1087,7 @@ describe('browser messaging adapter', () => { expect( adapter.parseProtocolMessage('apsTopMountStarted', { ...message, - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', }) ).toBeUndefined(); }); @@ -1096,7 +1102,7 @@ describe('browser messaging adapter', () => { }); const adapter = createBrowserMessagingAdapter(createTarget(), { expectedPublisherOrigin: 'https://publisher.example', - expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', validateApsRenderer: validator, }); const parsed = adapter.parseProtocolMessage('apsEnvelope', { @@ -1118,7 +1124,7 @@ describe('browser messaging adapter', () => { const validator = vi.fn(() => true); const adapter = createBrowserMessagingAdapter(createTarget(), { expectedPublisherOrigin: 'https://publisher.example', - expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', validateApsRenderer: validator, }); const parse = (renderer: unknown) => 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 018ec6294..8443d401c 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 @@ -436,7 +436,7 @@ test('generated first-display components self-register through one authenticated return function() {}; } }), - config: Object.freeze({gamAttributionEnabled: false}) + config: Object.freeze({gamAttributionEnabled: false, pageBidsEnabled: false}) }); } return undefined; @@ -720,7 +720,7 @@ test('co-bundled render_runtime and independent GPT start one branded display fl version: 1, entries: freeze([freeze({ id: 'gpt', - config: freeze({ gamAttributionEnabled: false }) + config: freeze({ gamAttributionEnabled: false, pageBidsEnabled: false }) })]) }), creative: freeze({ 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 f0ed3446a..75428af83 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1950,7 +1950,7 @@ describe('browser composition', () => { it('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { const releaseId = 'a'.repeat(64); const target = {}; - const gptConfig = Object.freeze({ gamAttributionEnabled: false }); + const gptConfig = Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }); const prebidConfig = Object.freeze({ accountId: 'test', timeout: 1_000, @@ -2079,7 +2079,10 @@ describe('browser composition', () => { integrations: { version: 1, entries: [ - { id: 'gpt', config: { gamAttributionEnabled: false } }, + { + id: 'gpt', + config: { gamAttributionEnabled: false, pageBidsEnabled: true }, + }, { id: 'prebid', config: prebidConfig }, ], }, 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 b34fb61f0..79700c42e 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 @@ -183,7 +183,9 @@ function tracedRegistration( function integrationConfig(id: string): unknown { if (id === 'didomi') return Object.freeze({ proxyPath: '/integrations/didomi/consent/' }); - if (id === 'gpt') return Object.freeze({ gamAttributionEnabled: false }); + if (id === 'gpt') { + return Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }); + } if (id === 'prebid') { return Object.freeze({ accountId: 'test', diff --git a/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs b/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs index 457b22482..aecd94e89 100644 --- a/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs +++ b/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs @@ -16,7 +16,7 @@ const validatorUrl = new URL( const validatorSource = await readFile(validatorUrl, 'utf8'); const bootstrapDocument = await readFile( new URL( - '../../../../trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html', + '../../../../trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html', import.meta.url ), 'utf8' @@ -168,7 +168,10 @@ function bootstrapScript() { return match[1]; } -function executeBootstrap(hash = '#b1_AAECAwQFBgcICQoLDA0ODw') { +function executeBootstrap( + hash = '#b1_AAECAwQFBgcICQoLDA0ODw', + referrer = 'https://publisher.example/article' +) { const parent = { messages: [], postMessage(message, origin) { @@ -180,11 +183,11 @@ function executeBootstrap(hash = '#b1_AAECAwQFBgcICQoLDA0ODw') { const context = vm.createContext({ URL, TextEncoder, - document: { referrer: 'https://publisher.example/article' }, + document: { referrer }, history: { replaceState() {} }, location: { hash, - pathname: '/integrations/aps/renderer/v1', + pathname: '/integrations/aps/renderer/v2', search: '', replace(value) { replacements.push(value); @@ -201,7 +204,7 @@ function executeBootstrap(hash = '#b1_AAECAwQFBgcICQoLDA0ODw') { }, }); vm.runInContext(bootstrapScript(), context, { - filename: 'aps_renderer_bootstrap_v1.html', + filename: 'aps_renderer_bootstrap_v2.html', }); return { parent, @@ -212,96 +215,94 @@ function executeBootstrap(hash = '#b1_AAECAwQFBgcICQoLDA0ODw') { }; } -test('the generated renderer response is an ES5 descriptor-free bootstrap', () => { +test('the generated renderer response is an ES5 descriptor-isolated materializer', () => { const source = bootstrapScript(); assert.doesNotMatch(source, /=>|\b(?:const|let|class)\b|\?\.|\?\?/); - assert.doesNotMatch( - source, - /aaxResponse|accountId|bidId|creativeId|creativeUrl|runner|createElement|iframe|fetch|XMLHttpRequest/i - ); + assert.doesNotMatch(source, /example-account-id|fictional-selected-bid-id|fictional-creative-id/u); + assert.doesNotMatch(source, /fetch|XMLHttpRequest/u); assert.match(source, /TS APS Bootstrap Ready/); - assert.match(source, /TS APS Bootstrap Navigate/); + assert.match(source, /TS APS Bootstrap Configure/); + assert.match(source, /classifyApsRendererV1/u); assert.match(source, /data:text\/html;charset=utf-8,/); }); -test('the generated bootstrap accepts one exact parent navigation instruction', () => { - const nonce = 'b1_AAECAwQFBgcICQoLDA0ODw'; - const harness = executeBootstrap(`#${nonce}`); - assert.deepEqual(harness.parent.messages, [ - { - message: JSON.stringify({ - message: 'TS APS Bootstrap Ready', - version: 1, - bootstrapNonce: nonce, - }), - origin: 'https://publisher.example', - }, - ]); - - const containerUrl = `data:text/html;charset=utf-8,%3C!doctype%20html%3E#${nonce}`; - const navigation = JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, - bootstrapNonce: nonce, - containerUrl, - }); - harness.dispatch({ - source: harness.parent, - origin: 'https://publisher.example', - data: navigation, - ports: [], +test('the generated bootstrap materializes the opaque documents after first-action configuration', () => { + const bootstrapNonce = 'b1_AAECAwQFBgcICQoLDA0ODw'; + const rendererNonce = 'n1_AAECAwQFBgcICQoLDA0ODw'; + const harness = executeBootstrap(`#${bootstrapNonce}`); + const configuration = JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce, + creativeOrigin: 'https://creative.example', + tagType: 'iframe', }); + harness.dispatch({ source: harness.parent, origin: 'https://publisher.example', - data: navigation, + data: configuration, ports: [], }); - assert.deepEqual(harness.replacements, [containerUrl]); -}); -test('the generated bootstrap accepts the exact container URL boundary', () => { - const nonce = `b1_${'a'.repeat(22)}`; - const prefix = 'data:text/html;charset=utf-8,'; - const suffix = `#${nonce}`; - const containerUrl = `${prefix}${'a'.repeat(196_663 - prefix.length - suffix.length)}${suffix}`; - const navigation = JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, - bootstrapNonce: nonce, - containerUrl, - }); - assert.equal(Buffer.byteLength(containerUrl), 196_663); - assert.ok(Buffer.byteLength(navigation) <= 196_800); - const harness = executeBootstrap(`#${nonce}`); + assert.equal(harness.replacements.length, 1); + const containerUrl = harness.replacements[0]; + assert.match(containerUrl, /^data:text\/html;charset=utf-8,/u); + assert.match(containerUrl, new RegExp(`#${bootstrapNonce}$`, 'u')); + const outerDocument = decodeURIComponent( + containerUrl.slice('data:text/html;charset=utf-8,'.length, -(`#${bootstrapNonce}`).length) + ); + assert.match(outerDocument, new RegExp(rendererNonce, 'u')); + assert.match(outerDocument, /TS APS Container Ready/u); + assert.doesNotMatch( + outerDocument, + /example-account-id|fictional-selected-bid-id|fictional-creative-id/u + ); +}); +test('the generated bootstrap authenticates the parent event without referrer dependence', () => { + const bootstrapNonce = 'b1_AAECAwQFBgcICQoLDA0ODw'; + const harness = executeBootstrap(`#${bootstrapNonce}`, ''); + assert.deepEqual(harness.parent.messages, [ + { + message: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: '*', + }, + ]); harness.dispatch({ source: harness.parent, origin: 'https://publisher.example', - data: navigation, + data: JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce: 'n1_AAECAwQFBgcICQoLDA0ODw', + creativeOrigin: 'https://creative.example', + tagType: 'iframe', + }), ports: [], }); - - assert.deepEqual(harness.replacements, [containerUrl]); + assert.equal(harness.replacements.length, 1); }); -test('the generated bootstrap rejects malformed, misbound, and oversized navigation', () => { +test('the generated bootstrap rejects malformed, misbound, and oversized configuration', () => { const nonce = 'b1_AAECAwQFBgcICQoLDA0ODw'; - const containerUrl = `data:text/html;charset=utf-8,%3C!doctype%20html%3E#${nonce}`; - const containerPrefix = 'data:text/html;charset=utf-8,'; - const containerSuffix = `#${nonce}`; - const overlongContainerUrl = `${containerPrefix}${'a'.repeat( - 196_664 - containerPrefix.length - containerSuffix.length - )}${containerSuffix}`; const valid = { - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce: nonce, - containerUrl, + rendererNonce: 'n1_AAECAwQFBgcICQoLDA0ODw', + creativeOrigin: 'https://creative.example', + tagType: 'iframe', }; const cases = [ { source: {}, origin: 'https://publisher.example', data: JSON.stringify(valid), ports: [] }, - { source: null, origin: 'https://attacker.example', data: JSON.stringify(valid), ports: [] }, + { source: null, origin: 'null', data: JSON.stringify(valid), ports: [] }, { source: null, origin: 'https://publisher.example', @@ -317,7 +318,7 @@ test('the generated bootstrap rejects malformed, misbound, and oversized navigat { source: null, origin: 'https://publisher.example', - data: JSON.stringify(valid).replace('"version":1', '"version":1,"version":1'), + data: JSON.stringify(valid).replace('"version":2', '"version":2,"version":2'), ports: [], }, { @@ -331,14 +332,14 @@ test('the generated bootstrap rejects malformed, misbound, and oversized navigat origin: 'https://publisher.example', data: JSON.stringify({ ...valid, - containerUrl: overlongContainerUrl, + creativeOrigin: 'https://publisher.example', }), ports: [], }, { source: null, origin: 'https://publisher.example', - data: 'x'.repeat(196_801), + data: 'x'.repeat(16_385), ports: [], }, ]; diff --git a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts index 307cd4dd8..408ff3298 100644 --- a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts @@ -117,7 +117,7 @@ function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) version: 1 as const, id: 'aps' as const, publisherOrigin: 'https://publisher.example', - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', sandbox: 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation', permanentSandbox: @@ -136,16 +136,10 @@ function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) typeof candidate === 'string' && /^b1_[A-Za-z0-9_-]{22}$/.test(candidate), isRendererNonce: (candidate: unknown): candidate is string => typeof candidate === 'string' && /^n1_[A-Za-z0-9_-]{22}$/.test(candidate), - generateDocuments: ( - _renderer: unknown, - bootstrapNonce: string, - rendererNonce: string - ) => + bootstrapPolicy: () => Object.freeze({ - outerUrl: - 'data:text/html;charset=utf-8,' + - encodeURIComponent(`rendererNonce=${rendererNonce}`) + - `#${bootstrapNonce}`, + creativeOrigin: 'https://creative.example', + tagType: 'iframe' as const, }), createRenderBridge: () => { throw new Error('the full bridge fixture already owns construction'); @@ -317,12 +311,7 @@ function startApsDocument(h: ReturnType) { string, unknown >; - const decodedOuter = decodeURIComponent( - (navigation.containerUrl as string) - .slice('data:text/html;charset=utf-8,'.length) - .split('#')[0] ?? '' - ); - const nonce = /rendererNonce=(n1_[A-Za-z0-9_-]{22})/.exec(decodedOuter)?.[1]; + const nonce = navigation.rendererNonce; if (!nonce) throw new Error('expected the renderer nonce'); const documentPort = new FakePort(); h.dispatch({ @@ -723,21 +712,19 @@ describe('bounded first-display render bridge', () => { unknown >; expect(navigation).toMatchObject({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: expect.stringMatching(/^data:text\/html;charset=utf-8,/), + rendererNonce: expect.stringMatching(/^n1_[A-Za-z0-9_-]{22}$/), + creativeOrigin: 'https://creative.example', + tagType: 'iframe', }); + expect(navigation).not.toHaveProperty('containerUrl'); expect(postMessage.mock.calls[0]?.slice(1)).toEqual(['*', []]); expect(frame?.getAttribute('sandbox')).toBe( 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation' ); - const decodedOuter = decodeURIComponent( - (navigation.containerUrl as string) - .slice('data:text/html;charset=utf-8,'.length) - .split('#')[0] ?? '' - ); - const nonce = /rendererNonce=(n1_[A-Za-z0-9_-]{22})/.exec(decodedOuter)?.[1]; + const nonce = navigation.rendererNonce as string; expect(nonce).toMatch(/^n1_[A-Za-z0-9_-]{22}$/); const documentPort = new FakePort(); h.dispatch({ @@ -804,12 +791,7 @@ describe('bounded first-display render bridge', () => { string, unknown >; - const decodedOuter = decodeURIComponent( - (navigation.containerUrl as string) - .slice('data:text/html;charset=utf-8,'.length) - .split('#')[0] ?? '' - ); - const rendererNonce = /rendererNonce=(n1_[A-Za-z0-9_-]{22})/.exec(decodedOuter)?.[1]; + const rendererNonce = navigation.rendererNonce; const documentPort = new FakePort(); h.dispatch({ data: JSON.stringify({ diff --git a/crates/trusted-server-js/lib/test/first_display/slices.test.ts b/crates/trusted-server-js/lib/test/first_display/slices.test.ts index e17e9434c..f3dd26f9f 100644 --- a/crates/trusted-server-js/lib/test/first_display/slices.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/slices.test.ts @@ -1094,7 +1094,7 @@ describe('first-display initial slice definitions', () => { afterActivate: () => undefined, }) ); - expect(protocol?.rendererUrl).toBe('https://publisher.example/integrations/aps/renderer/v1'); + expect(protocol?.rendererUrl).toBe('https://publisher.example/integrations/aps/renderer/v2'); expect(protocol?.publisherOrigin).toBe('https://publisher.example'); expect(protocol?.sandbox).toBe( 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation' diff --git a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts index a1772cf23..4912702b2 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts @@ -79,7 +79,7 @@ describe('APS provider', () => { expect(registeredRenderer).toBe(aps.render); expect(registeredValidation).toMatchObject({ expectedPublisherOrigin: window.location.origin, - expectedRendererUrl: new URL('/integrations/aps/renderer/v1', window.location.origin).href, + expectedRendererUrl: new URL('/integrations/aps/renderer/v2', window.location.origin).href, }); activationRelease.reverse().forEach((callback) => callback()); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts index c0e6ff063..9f214a6d7 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts @@ -21,7 +21,7 @@ type NavigationResult = current: boolean; }>; -function harness() { +function harness(pageBidsEnabled = true) { const navigationGeneration = Object.freeze({}); const navigate = vi.fn<(_path: string) => Promise>(async (_path: string) => Object.freeze({ status: 'committed', navigationGeneration, current: true }) @@ -40,7 +40,7 @@ function harness() { }); const prepared = createGptLaterIntegrationRegistration(RELEASE_ID).prepare( Object.freeze({ - config: Object.freeze({ gamAttributionEnabled: false }), + config: Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled }), interfaces, onDispose: (callback: () => void) => preparationDisposers.push(callback), signal: new AbortController().signal, @@ -83,6 +83,27 @@ describe('GPT deferred navigation and reconciliation owner', () => { expect(owner.activateLaterLifecycle).not.toHaveBeenCalled(); }); + it('owns reconciliation without installing SPA hooks when page-bids delivery is disabled', async () => { + vi.useFakeTimers(); + const beforePush = Object.getOwnPropertyDescriptor(window.history, 'pushState'); + const beforeReplace = Object.getOwnPropertyDescriptor(window.history, 'replaceState'); + const owner = harness(false); + + owner.prepared.activate(owner.activationContext); + + expect(owner.activateLaterLifecycle).toHaveBeenCalledOnce(); + expect(Object.getOwnPropertyDescriptor(window.history, 'pushState')).toEqual(beforePush); + expect(Object.getOwnPropertyDescriptor(window.history, 'replaceState')).toEqual(beforeReplace); + window.history.pushState({}, '', '/disabled-page-bids'); + window.dispatchEvent(new PopStateEvent('popstate')); + await vi.runAllTimersAsync(); + expect(owner.navigate).not.toHaveBeenCalled(); + + owner.activationDisposers.reverse().forEach((release) => release()); + expect(owner.release).toHaveBeenCalledOnce(); + owner.preparationDisposers.reverse().forEach((release) => release()); + }); + it('owns one deferred history listener and coalesced navigation timer across repeated routes', async () => { vi.useFakeTimers(); const owner = harness(); 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 bb125c70c..d7aa7f72c 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 @@ -39,7 +39,7 @@ import type { SlotRequestOutcome } from '../../../src/services/slots'; const RELEASE_ID = 'a'.repeat(64); const RESERVATION_ID = `r1_${'a'.repeat(22)}`; -const GPT_CONFIG = Object.freeze({ gamAttributionEnabled: false }); +const GPT_CONFIG = Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }); describe('GPT first-display diagnostics adoption', () => { it('hydrates exact physical-slot cycle state before slot adoption', () => { diff --git a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts index 03ce66bfb..36c7404dc 100644 --- a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts @@ -714,7 +714,7 @@ describe('render_runtime provider', () => { }; const renderer = vi.fn(() => true); const origin = window.location.origin; - const rendererUrl = new URL('/integrations/aps/renderer/v1', origin).href; + const rendererUrl = new URL('/integrations/aps/renderer/v2', origin).href; const validation = Object.freeze({ expectedPublisherOrigin: origin, expectedRendererUrl: rendererUrl, 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 882518012..f46881f54 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -125,7 +125,7 @@ function configProduct(id: string): string | undefined { function defaultConfig(id: string): Readonly> { if (id === 'didomi') return { proxyPath: '/integrations/didomi/sdk.js' }; - if (id === 'gpt') return { gamAttributionEnabled: false }; + if (id === 'gpt') return { gamAttributionEnabled: false, pageBidsEnabled: true }; if (id === 'prebid') { return { accountId: 'test', timeout: 1_000, debug: false, bidders: [] }; } @@ -983,7 +983,7 @@ describe('Runtime bootstrap owner', () => { const tracePresentationCapability = Object.freeze({ attachPresentation: traceAttach }); const gptLaterRelease = vi.fn(); const prebidLaterRelease = vi.fn(); - const gptConfig = { gamAttributionEnabled: true }; + const gptConfig = { gamAttributionEnabled: true, pageBidsEnabled: true }; const prebidConfig = { accountId: 'publisher' }; const gptLater = Object.freeze({ activate: vi.fn(() => gptLaterRelease), 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 8bda7e0d3..78fefad64 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 @@ -1152,7 +1152,7 @@ describe('Universal Creative bridge dispatcher', () => { message: 'TS APS Start', version: 1, lifecycleTicket: LIFECYCLE_TICKET, - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', envelope: { version: 1, nonce: 'n1_abcdefghijklmnopqrstuv', 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 89b608fa8..6db23c88b 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -10,10 +10,10 @@ import type { RenderAttemptScope, WinnerContext } from '../../src/kernel/session import { APS_PERMANENT_SANDBOX, APS_RENDERER_SANDBOX, - APS_RENDERER_V1_PATH, + APS_RENDERER_V2_PATH, renderDirectApsAttempt, renderPucApsAttempt, - resolveApsRendererV1Url, + resolveApsRendererV2Url, } from '../../src/integrations/aps/render'; import { bindCommittedArtifactGuard, @@ -1221,7 +1221,7 @@ describe('direct APS attempt rendering', () => { const source = frame.contentWindow!; const postMessage = vi.spyOn(source, 'postMessage'); expect(frame.getAttribute('src')).toBe( - `${window.location.origin}${APS_RENDERER_V1_PATH}#${bootstrapNonce}` + `${window.location.origin}${APS_RENDERER_V2_PATH}#${bootstrapNonce}` ); expect(frame.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); expect(document.querySelectorAll('#fictional-slot iframe')).toHaveLength(1); @@ -1244,12 +1244,12 @@ describe('direct APS attempt rendering', () => { expect(navigation).toBeTypeOf('string'); const parsedNavigation = JSON.parse(navigation as string) as Record; expect(parsedNavigation).toEqual({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: expect.stringMatching( - new RegExp(`^data:text/html;charset=utf-8,.*#${bootstrapNonce}$`) - ), + rendererNonce, + creativeOrigin: 'https://creative.example', + tagType: 'iframe', }); expect(navigation).toBe(JSON.stringify(parsedNavigation)); expect(postMessage.mock.calls[0]?.slice(1)).toEqual(['*', []]); @@ -1564,7 +1564,7 @@ describe('direct APS attempt rendering', () => { try { mutated.frame?.setAttribute( 'src', - window.location.origin + APS_RENDERER_V1_PATH + '#b1_9999999999999999999999' + window.location.origin + APS_RENDERER_V2_PATH + '#b1_9999999999999999999999' ); mutated.bootstrapReady(); expect(mutated.render.snapshot().outcome).toEqual({ @@ -1691,19 +1691,19 @@ describe('direct APS attempt rendering', () => { }); 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(resolveApsRendererV2Url('https://publisher.example')).toBe( + 'https://publisher.example/integrations/aps/renderer/v2' ); - expect(resolveApsRendererV1Url('http://localhost:8080')).toBe( - 'http://localhost:8080/integrations/aps/renderer/v1' + expect(resolveApsRendererV2Url('http://localhost:8080')).toBe( + 'http://localhost:8080/integrations/aps/renderer/v2' ); - expect(resolveApsRendererV1Url('http://127.0.0.1:8080')).toBe( - 'http://127.0.0.1:8080/integrations/aps/renderer/v1' + expect(resolveApsRendererV2Url('http://127.0.0.1:8080')).toBe( + 'http://127.0.0.1:8080/integrations/aps/renderer/v2' ); - expect(resolveApsRendererV1Url('http://[::1]:8080')).toBe( - 'http://[::1]:8080/integrations/aps/renderer/v1' + expect(resolveApsRendererV2Url('http://[::1]:8080')).toBe( + 'http://[::1]:8080/integrations/aps/renderer/v2' ); - expect(resolveApsRendererV1Url('http://publisher.example')).toBeUndefined(); + expect(resolveApsRendererV2Url('http://publisher.example')).toBeUndefined(); }); it('keeps a PUC APS overlay hidden until completion and preserves the slot host', () => { diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 9ab042c41..27d564881 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/v1 + Client->>Client: Validate renderer descriptor
Create opaque sandbox iframe
Load /integrations/aps/renderer/v2 Note right of Client: Fragment-bound nonce and one-time acknowledgement
No allow-same-origin on the outer frame deactivate Client end diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 476d9a501..490504742 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1549,9 +1549,18 @@ publisher-specific. Startup fails if `{section}` is used without a valid `section_root`. Startup rejects a blank `gam_network_id` only when an absent path/default or a `{network_id}` template consumes it; static paths and templates without `{network_id}` do not consume it. A -`enabled = false` explicitly disables publisher and page-bids template delivery. -An enabled block with no slots has no templates to match, so its `gam_network_id` -is not checked. The `enabled` key is required whenever the table is present. +`enabled = false` explicitly disables publisher and page-bids template delivery: +Trusted Server performs no publisher slot matching or automatic auction, injects no +initial slot/auction projection, and does not install SPA page-bids navigation hooks. +`/_ts/page-bids` returns the canonical empty projection. Direct `POST /auction` +remains live under its ordinary auction and consent gates. A successful inactive +publisher HTML `GET` receives `Cache-Control: max-age=60` unless the origin policy +contains `private` or `no-store` (case-insensitive); origin validators and +surrogate/CDN directives remain intact. Non-HTML, failed, and non-`GET` responses +retain the origin cache policy. An enabled block with no slots has no templates to +match, so its `gam_network_id` is not checked. The `enabled` key is required whenever +the table is present, and disabling delivery does not bypass startup validation of +its slots, assembly mode, or template-cache fields. Both knobs are config-driven, so the URL→section convention stays with the publisher: `section_segment` selects which segment names the section, and diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index edd8c4815..f2b53a1ea 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -158,7 +158,7 @@ Trusted Server does not insert APS creative markup into the publisher document. Seats, `impid`, markup, notifications, user-sync data, sibling bids, losing seats, and unknown fields are not exposed. The browser decodes this envelope and cross-checks the ID, dimensions, URL, and tag type before any DOM mutation or message suppression. -Both rendering paths use `GET /integrations/aps/renderer/v1`, a versioned static Trusted Server document with its own restrictive CSP. The document initializes the account-keyed APS queue and then loads the live runner through the fixed first-party proxy at `GET /integrations/aps/runner.js`. Trusted Server neither vendors nor pins the upstream runner bytes. +Both rendering paths use `GET /integrations/aps/renderer/v2`, a versioned static Trusted Server materializer with its own restrictive CSP. After the first APS action, the top page sends only independent nonces, the validated creative origin, and tag type. The materializer creates the opaque outer and inner data documents; only the inner document receives and validates the descriptor, initializes the account-keyed APS queue, and loads the live runner through the fixed first-party proxy at `GET /integrations/aps/runner.js`. Trusted Server neither vendors nor pins the upstream runner bytes. Both paths are reserved before configured `[[handlers]]` are evaluated. They are browser-facing and intentionally anonymous; Basic Auth handler patterns do not @@ -182,7 +182,7 @@ allow-scripts allow-top-navigation-by-user-activation ``` -It deliberately omits `allow-same-origin`, so APS and bidder execution remains below an opaque-origin boundary. The renderer response repeats these restrictions with a CSP `sandbox` directive, preventing another embedding path from restoring publisher-origin execution by omitting the iframe attribute. Trusted Server generates a fresh 128-bit nonce, binds it in the iframe URL fragment before navigation, and requires the same one-time nonce in the parent message and renderer acknowledgement. Existing slot content is retained until the static renderer has accepted the descriptor and loaded the fixed runner. +It deliberately omits `allow-same-origin` during bootstrap, so APS and bidder execution remains below an opaque-origin boundary. The renderer response repeats these restrictions with a CSP `sandbox` directive, preventing another embedding path from restoring publisher-origin execution by omitting the iframe attribute. Trusted Server generates independent 128-bit bootstrap and renderer nonces, binds each to its own phase, and accepts each exactly once. No descriptor or data-document URL crosses the bootstrap configuration channel. Existing slot content is retained until the inner renderer has accepted the descriptor and APS reports render completion. ### Direct `/auction` @@ -259,7 +259,7 @@ Use fictional values in source-controlled configuration and fixtures. Supply con ### Winner targets but does not render -- Confirm `GET /integrations/aps/renderer/v1` returns HTML with its CSP and `Referrer-Policy: no-referrer`. +- Confirm `GET /integrations/aps/renderer/v2` returns HTML with its CSP and `Referrer-Policy: no-referrer`. - Confirm publisher CSP permits `frame-src 'self'`. - Confirm the GAM creative uses the supported Prebid Universal Creative bridge and the winning `hb_adid`. - For client-side `trustedServer` adapter auctions, confirm the bid joins the server-minted `r1_` reservation and that the private PUC bridge accepts exactly one claim for it. 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 6a08967bd..fb655f463 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 @@ -430,7 +430,7 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled ## Phase 2 — Harden APS containment and top-page ownership -### Task 5: Replace renderer v1 with the descriptor-free bootstrap contract +### Task 5: Hard-cut the renderer route to the v2 materialization bootstrap **Files:** @@ -439,6 +439,8 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - Modify: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json` - Modify: `scripts/generate-aps-renderer-contract.mjs` - Modify generated: `crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js` +- Delete generated: `crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html` +- Create generated: `crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html` - Modify: `crates/trusted-server-core/src/integrations/aps.rs` - Modify: `crates/trusted-server-core/src/publisher.rs` - Modify: `scripts/integration-tests-aps-runner-proxy.sh` @@ -447,12 +449,13 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - Test: colocated APS Rust tests - Test: `scripts/integration-tests-aps-runner-proxy.sh` -- [ ] **Step 1: Add failing renderer response tests.** The HTTP response is an - immutable descriptor-free bootstrap with exact CSP, content type, cache, - nosniff, no-referrer, no X-Frame-Options, and exact initial sandbox. It accepts - only its `b1_` fragment, exact checked-parent navigation message, and bounded - `data:text/html;charset=utf-8,` container URL. It contains no runner, - descriptor, price, bid id, creative URL, child frame, or publisher DOM access. +- [ ] **Step 1: Add failing renderer response tests.** The v2 HTTP response is an + immutable descriptor-free materialization bootstrap with exact CSP, content + type, cache, nosniff, no-referrer, no X-Frame-Options, and exact initial + sandbox. It accepts only its `b1_` fragment and one canonical v2 policy + configuration from the exact parent. It embeds TS-authored templates, the ES5 + validator, and the fixed runner-proxy path, but no concrete descriptor value, + vendor bytes, publisher DOM authority, or pre-navigation network operation. - [ ] **Step 2: Add failing route/security tests.** APS-enabled final publisher responses append an independent `Content-Security-Policy: frame-ancestors @@ -470,8 +473,9 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 4: Update the single renderer generator and generated contract.** Keep the schema's `x-*` semantic markers documentary; generator code remains the - enforcement authority. Generate deterministic ES5-compatible bootstrap bytes - and reuse the shared corpus across Rust, TypeScript, and Node tests. + enforcement authority. Generate deterministic ES5-compatible v2 bootstrap + bytes, reuse the shared corpus across Rust, TypeScript, and Node tests, delete + the v1 body, and serve v1 as an unknown local `404` with no compatibility path. - [ ] **Step 5: Preserve the live runner proxy exactly.** Keep its fixed target, credential/referrer stripping, duplicate-header evidence, identity encoding, @@ -481,7 +485,7 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 6: Make the Cloudflare/Wrangler readiness proof exact.** Before the adversarial corpus starts, require two consecutive - `PROPFIND /integrations/aps/renderer/v1` responses with the complete local 405 + `PROPFIND /integrations/aps/renderer/v2` responses with the complete local 405 contract, including `Allow: GET` and `Cache-Control: no-store`. Any other result resets the consecutive count. Implement this in the actual adapter harness `crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs`; @@ -507,15 +511,17 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 8: Commit.** ```bash - git add crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json scripts/generate-aps-renderer-contract.mjs scripts/integration-tests-aps-runner-proxy.sh crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/publisher.rs - git commit -m "Make the APS renderer endpoint a bounded bootstrap" + git add crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json scripts/generate-aps-renderer-contract.mjs scripts/integration-tests-aps-runner-proxy.sh crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/publisher.rs + git commit -m "Hard-cut APS rendering to the v2 bootstrap" ``` -### Task 6: Generate the exact outer and inner APS data documents +### Task 6: Materialize the exact APS data documents after first action **Files:** -- Create: `crates/trusted-server-js/lib/src/integrations/aps/documents.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/aps_documents.ts` +- Modify: `scripts/generate-aps-renderer-contract.mjs` +- Modify generated: `crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html` - Modify: `crates/trusted-server-js/lib/src/core/contracts/aps_renderer.ts` - Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` @@ -525,7 +531,8 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - Test: `crates/trusted-server-js/lib/test/adapters/messaging.test.ts` - Test: `crates/trusted-server-js/lib/test/first_display/slices.test.ts` -- [ ] **Step 1: Add failing template tests.** Generate detached documents using +- [ ] **Step 1: Add failing template tests.** Have the v2 server bootstrap generate + detached documents only after it receives the first-action policy message, using independent `b1_` and `n1_` nonces, exact origin validation, exact permanent sandbox order, sentinel-once substitution, context escaping, and separate 65,536-byte pre-encoding caps. The outer document contains only origins, @@ -551,7 +558,12 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/aps/documents.test.ts test/integrations/aps/render.test.ts test/adapters/messaging.test.ts test/first_display/slices.test.ts ``` -- [ ] **Step 5: Implement pure document generation and exact parsers.** The inner +- [ ] **Step 5: Implement build-time document materialization and exact parsers.** + Keep the templates and ES5 validator as generator/test authorities, emit them + into the server-owned v2 response, and remove them from both first-display and + persistent production TSJS graphs. The top page sends only exact nonces, + creative origin, and tag type; no descriptor or generated URL crosses that + channel. The inner clears its fragment, queues the APS event with one-shot resolve/reject, loads only the same-origin proxy with anonymous CORS/no-referrer, treats script load as progress, and sends completion/failure only on callback settlement. @@ -568,8 +580,8 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 7: Commit.** ```bash - git add crates/trusted-server-js/lib/src/integrations/aps crates/trusted-server-js/lib/src/core/contracts/aps_renderer.ts crates/trusted-server-js/lib/src/adapters/messaging.ts crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts crates/trusted-server-js/lib/test/integrations/aps crates/trusted-server-js/lib/test/adapters/messaging.test.ts crates/trusted-server-js/lib/test/first_display/slices.test.ts - git commit -m "Generate isolated APS data documents" + git add crates/trusted-server-js/lib/src/shared/aps_documents.ts crates/trusted-server-js/lib/src/integrations/aps crates/trusted-server-js/lib/src/core/contracts/aps_renderer.ts crates/trusted-server-js/lib/src/adapters/messaging.ts crates/trusted-server-js/lib/src/first_display crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs scripts/generate-aps-renderer-contract.mjs crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html crates/trusted-server-js/lib/test + git commit -m "Materialize APS documents after first action" ``` ### Task 7: Mount direct APS through the three-phase top-page protocol @@ -595,7 +607,8 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 2: Add failing direct-mount state-machine tests.** Create only the bootstrap iframe initially. Require exact node/parent/src/WindowProxy/ - generation/nonce checks before sandbox mutation/navigation, then exact outer + generation/nonce checks before sandbox mutation and the v2 policy-only + configuration message, then exact materialized outer readiness and one port before sending the envelope. Enforce one-second insertion, one shared three-second document deadline, and the kernel-owned ten-second completion deadline beginning at document acceptance. @@ -613,9 +626,10 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled ``` - [ ] **Step 5: Implement the shared top-page mount service for direct APS.** Keep - descriptor generation before DOM mutation, permanent sandbox after bootstrap - readiness, exact winning size/layout, and one terminal latch. Direct mount is - an ordinary child and does not acquire overlay styling. + descriptor validation before DOM mutation, permanent sandbox after bootstrap + readiness, policy-only server materialization after first action, exact winning + size/layout, and one terminal latch. Direct mount is an ordinary child and does + not acquire overlay styling. - [ ] **Step 6: Run GREEN and adjacent direct-ADM regressions.** @@ -1025,6 +1039,11 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled paired GPT/APS <=1.10 timing/transfer, APS action/completion/paint/heap ceilings, and the common 4 MiB retained-heap ceiling. Require real User Timing marks and no persistent/deferred request, preload, prepare, or execution before paint. + The production source graph must prove that neither first-display nor + persistent artifacts reach `shared/aps_documents.ts` or the document-form ES5 + validator; those authorities exist only in the generator and contract tests. + The mandatory APS + creative + GPT first-display mask must pass the unchanged + 90,000/30,000/26,000 raw/gzip/Brotli ceilings. - [ ] **Step 4: Keep CI logic in scripts.** Workflow YAML may set immutable inputs, install toolchains, and invoke checked-in scripts. Put Git ancestry validation, 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 3c9095784..fd0ec5e08 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 @@ -1556,7 +1556,7 @@ hold capacity until the 15-minute tombstone expiry as a live entry. ### 3.6 Static renderer and APS runner proxy endpoints -`/integrations/aps/renderer/v1` and `/integrations/aps/runner.js` are always-reserved +`/integrations/aps/renderer/v2` and `/integrations/aps/runner.js` are always-reserved Trusted Server routes. When APS is enabled, `GET` returns the local static bootstrap or proxies the APS-hosted creative runner respectively. When APS is disabled, `GET` returns a local `404 no-store`; neither route ever falls through to a publisher @@ -1572,10 +1572,14 @@ anonymous. A configured `[[handlers]]` pattern that matches renderer or runner. Operator-required admission control, rate limiting, and request shielding live at the deployment platform. -The renderer v1 response is an immutable, descriptor-free bootstrap served with a -long-lived immutable cache policy. It is not the document that receives the bid or -loads the runner. Its TS-created iframe initially has exactly this sandbox token set, -serialized in this order: +The renderer v2 response is an immutable, descriptor-free materialization bootstrap +served with a long-lived immutable cache policy. It embeds the TS-authored outer and +inner document templates, the generated ES5 descriptor validator, and the fixed +same-origin runner-proxy path. It contains no attempt descriptor, concrete bid or +creative value, vendor bytes, publisher DOM authority, or pre-navigation network +operation. It is not the document that receives the bid or executes the runner. Its +TS-created iframe initially has exactly this sandbox token set, serialized in this +order: ```text allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation @@ -1589,22 +1593,44 @@ response CSP is: default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none'; ``` -The bootstrap can only validate its exact `b1_` fragment nonce, receive one exact -navigation instruction from its checked parent carrying that same -`bootstrapNonce`, and replace its own location with the supplied -`data:text/html;charset=utf-8,` container URL. It has no descriptor, runner, creative -URL path, price, bid id, network capability, child frame, or publisher DOM access. -The response also has +The bootstrap validates its exact `b1_` fragment nonce, sends readiness only to its +actual `parent`, and accepts one canonical JSON configuration from that exact +`WindowProxy`, with an exact browser-reported HTTP(S) sender origin and no transferred +ports. It has no `document.referrer` dependency, so a publisher's stricter referrer +policy cannot disable the handshake: + +```ts +{ + message: 'TS APS Bootstrap Configure' + version: 2 + bootstrapNonce: string + rendererNonce: string + creativeOrigin: string + tagType: 'iframe' | 'script' +} +``` + +The complete configuration is capped at 16,384 UTF-8 bytes. `creativeOrigin` must +be an exact HTTPS origin distinct from the parent, and the two nonces must have their +exact independent `b1_` and `n1_` forms. The bootstrap then substitutes only those +policy values into the checked templates, enforces the separate 65,536-byte document +caps and 196,663-byte encoded-container cap, and replaces its own location with the +fresh outer `data:text/html;charset=utf-8,` URL. It receives no descriptor and cannot +make a runner or creative request before that navigation. The response also has exactly `Content-Type: text/html; charset=utf-8`, `Cache-Control: public, max-age=31536000, immutable`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. It deliberately omits `X-Frame-Options`; `frame-ancestors 'self'` is mandatory because both direct and PUC paths mount the bootstrap from the publisher top page, never beneath GAM, Universal Creative, or bidder content. The TS-authored container and inner renderer -are checked-in templates encoded into per-attempt `data:` URLs by TSJS; they are not -HTTP artifacts and contain no third-party bytes. Any shipped bootstrap body/header -or data-document protocol change before this release updates the single v1 contract; -after release it requires a new route/protocol version. +remain `data:` documents rather than separately addressable HTTP artifacts and +contain no third-party bytes. The checked-in generator materializes their source into +the server-owned v2 response at build time; neither the first-display nor persistent +TSJS production graph imports the document templates or generated ES5 validator. +The abandoned renderer-v1 route is a local `404`, with no compatibility branch. Any +shipped bootstrap body/header or data-document protocol change before this release +updates the single v2 contract; after release it requires a new route/protocol +version. When APS is enabled, final response privacy appends a separate `Content-Security-Policy: frame-ancestors 'self'` policy after operator headers. CSP @@ -1728,7 +1754,7 @@ new CustomEvent('prebid/creative/render', { ``` `resolve` and `reject` are the Promise's one-shot functions and are the only -non-serializable fields. The top-page owner encodes its exact validated HTTP(S) +non-serializable fields. The server-owned v2 materializer encodes the exact validated HTTP(S) Trusted Server origin and absolute `/integrations/aps/runner.js` URL into the TS-authored inner data document before navigation. The inner CSP permits that exact origin as its only external script source, and the renderer loads only that route. It creates the @@ -1751,7 +1777,7 @@ upstream. Load failure, explicit rejection, and silence fail closed through the existing APS-completion deadline. A changed or compromised APS runner can invoke `resolve` prematurely or incorrectly; this is an accepted external-dependency risk that the outer renderer cannot detect. Real-browser DOM/network conformance reduces -but does not eliminate it. V1 never loads the APS URL directly from the browser, +but does not eliminate it. This design never loads the APS URL directly from the browser, executes a stored fallback, treats script `load` as completion, or uses a reusable global `postMessage` acknowledgement. @@ -2050,7 +2076,7 @@ publisher destruction, DOM-integrity loss, or navigation disposal as applicable. The mount has three document phases: -1. Create one iframe at the immutable renderer-v1 bootstrap URL with the attempt's +1. Create one iframe at the immutable renderer-v2 bootstrap URL with the attempt's `b1_` bootstrap nonce in its fragment and the initial sandbox from §3.6. The bootstrap is publisher-origin by URL but opaque because `allow-same-origin` is absent. It can only send exact @@ -2059,12 +2085,14 @@ The mount has three document phases: 2. After exact element, parent, `src`, `WindowProxy`, generation, and bootstrap-nonce checks, add `allow-same-origin` to the sandbox and post exact - `{message:'TS APS Bootstrap Navigate',version:1,bootstrapNonce,containerUrl}`. - The URL must be the attempt-owned `data:text/html;charset=utf-8,` container with - that same `b1_` fragment. The previously loaded bootstrap remains opaque while it - performs `location.replace`; the destination is naturally opaque because it is + `{message:'TS APS Bootstrap Configure',version:2,bootstrapNonce,rendererNonce,creativeOrigin,tagType}`. + No descriptor or generated document URL crosses this channel. The bootstrap + verifies the exact parent, configuration, nonces, creative origin, and tag type, + materializes the attempt-owned outer and inner data documents, and performs + `location.replace`. The previously loaded bootstrap remains opaque while it + performs that navigation; the destination is naturally opaque because it is `data:`. -3. The outer data container creates one inner data renderer whose `data:` URL +3. The materialized outer data container creates one inner data renderer whose `data:` URL fragment is the independent `n1_` renderer nonce and whose permanent sandbox contains the §3.6 tokens plus `allow-same-origin`. Both final documents remain naturally opaque. After the @@ -2186,7 +2214,7 @@ The inner renderer sends only these exact document-port messages: where `reason` is `descriptor_invalid | runner_no_load | runner_failed`. -Insertion has a one-second deadline. Bootstrap readiness, data navigation, +Insertion has a one-second deadline. Bootstrap readiness, configuration/materialization navigation, container-channel creation, descriptor transfer, and inner document acceptance share one three-second deadline from outer iframe insertion. The kernel is the sole owner of the ten-second APS-completion deadline beginning at inner document acceptance. @@ -2331,12 +2359,13 @@ The shared protocol corpus fixes these bounds and encodings: - before `JSON.parse`, an inbound top-page global-dispatcher string is at most 4,096 UTF-8 bytes; a larger value is unrecognizable and causes no property access or - state lookup. The distinct top-page-to-bootstrap navigation parser accepts only - its exact expected parent and `b1_` nonce and caps the complete JSON instruction at - `MAX_APS_BOOTSTRAP_NAVIGATION_MESSAGE_BYTES = 196_800`; its `containerUrl` is at - most 196,663 UTF-8 bytes (29-byte data prefix + worst-case 3 × 65,536-byte encoded - document + 26-byte `#b1_…` fragment). No other global message receives this larger - allowance; + state lookup. The distinct top-page-to-bootstrap configuration parser accepts only + its exact expected parent, exact `b1_`/`n1_` nonces, exact HTTPS creative origin, + and `iframe | script` tag type, and caps the complete canonical JSON instruction at + 16,384 UTF-8 bytes. The bootstrap-created outer `containerUrl` never crosses that + channel; it is capped internally at 196,663 UTF-8 bytes (29-byte data prefix + + worst-case 3 × 65,536-byte encoded document + 26-byte `#b1_…` fragment). No other + global message receives a larger allowance; - a TS `adId` is exactly the 25-character `r1_` reservation form; a lifecycle ticket, bootstrap nonce, renderer nonce, and attempt id are exactly the respective 25-character `t1_`, `b1_`, `n1_`, and `a1_` forms from §2.2; @@ -2344,7 +2373,7 @@ The shared protocol corpus fixes these bounds and encodings: exact PUC-shape conformance and is never a fetch target or authority; - `publisherOrigin` is at most 2,048 UTF-8 bytes and must serialize an exact HTTP(S) origin with no path/query/fragment. The mount service locally derives the absolute - `/integrations/aps/renderer/v1` URL from that trusted origin, verifies no query or + `/integrations/aps/renderer/v2` URL from that trusted origin, verifies no query or fragment, and appends the exact `b1_` bootstrap-nonce fragment. `rendererUrl` is never serialized in a v4 window or port message. The generated inner `data:` URL independently appends its exact `n1_` renderer-nonce fragment; @@ -4151,7 +4180,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 @@ -4325,7 +4358,10 @@ interface GptDiagnosticsAdManagerIdentity { } type GptDiagnosticsResponseClass = - 'empty' | 'backfill' | 'reservation' | 'unclassified_non_empty' + | 'empty' + | 'backfill' + | 'reservation' + | 'unclassified_non_empty' type GptDiagnosticsRequestPath = | 'trusted_server_direct' @@ -4335,7 +4371,9 @@ type GptDiagnosticsRequestPath = | 'unattributed' type GptDiagnosticsTrustedServerOpportunity = - 'renderable_candidate' | 'unrenderable_candidate' | 'no_candidate' + | 'renderable_candidate' + | 'unrenderable_candidate' + | 'no_candidate' type GptDiagnosticsCreativeFailure = | 'missing_render_source' diff --git a/scripts/generate-aps-renderer-contract.mjs b/scripts/generate-aps-renderer-contract.mjs index ba65830bb..ef68974c9 100644 --- a/scripts/generate-aps-renderer-contract.mjs +++ b/scripts/generate-aps-renderer-contract.mjs @@ -19,7 +19,7 @@ const es5Path = path.join( ); const bootstrapPath = path.join( repositoryRoot, - "crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html", + "crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html", ); const typescriptPath = path.join( repositoryRoot, @@ -29,10 +29,15 @@ const documentValidatorPath = path.join( repositoryRoot, "crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_document_v1.ts", ); +const documentsSourcePath = path.join( + repositoryRoot, + "crates/trusted-server-js/lib/src/shared/aps_documents.ts", +); -const [schemaText, corpusText] = await Promise.all([ +const [schemaText, corpusText, documentsSource] = await Promise.all([ readFile(schemaPath, "utf8"), readFile(corpusPath, "utf8"), + readFile(documentsSourcePath, "utf8"), ]); const schema = JSON.parse(schemaText); const corpus = JSON.parse(corpusText); @@ -312,22 +317,56 @@ const documentValidatorOutput = JSON.stringify(es5Output) + ";\n"; -const bootstrapOutput = String.raw` +function extractDocumentTemplate(name) { + const prefix = "const " + name + " = `"; + const start = documentsSource.indexOf(prefix); + invariant(start >= 0, "missing APS document template " + name); + const contentStart = start + prefix.length; + const end = documentsSource.indexOf("`;\n", contentStart); + invariant(end >= 0, "unterminated APS document template " + name); + const value = documentsSource.slice(contentStart, end); + invariant( + !value.includes("`"), + "APS document template contains a nested template literal", + ); + return value; +} + +function inlineScriptString(value) { + return JSON.stringify(value).replaceAll("", "<\\/script>"); +} + +const innerTemplate = extractDocumentTemplate("INNER_TEMPLATE"); +const outerTemplate = extractDocumentTemplate("OUTER_TEMPLATE"); + +const bootstrapOutput = + String.raw` `; From 0741a7992ae25f6cf8514b07ae3cdfb056899880 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:41:28 -0700 Subject: [PATCH 525/844] Make GPT own parser-time GAM attribution --- .../src/integrations/gpt.rs | 8 - .../src/integrations/registry.rs | 67 --------- crates/trusted-server-core/src/tsjs.rs | 63 +++++++- crates/trusted-server-js/lib/build-all.mjs | 2 +- .../lib/scripts/check-bundle-budgets.mjs | 1 + .../lib/src/adapters/googletag.ts | 54 +++++++ .../lib/src/core/adapters/gam_attribution.ts | 47 ++++++ .../lib/src/core/bootstrap.ts | 9 +- .../src/first_display/adapters/googletag.ts | 30 ++-- .../lib/src/first_display/agent.ts | 3 +- .../src/first_display/leaf/gpt_protocol.ts | 27 +++- .../src/first_display/slices/definition.ts | 3 +- .../lib/src/first_display/slices/gpt.ts | 9 +- .../lib/src/integrations/gpt/module.ts | 12 +- .../lib/src/shared/first_display_contracts.ts | 9 +- .../lib/test/adapters/googletag.test.ts | 41 ++++++ .../lib/test/build/release-v1.test.mjs | 1 + .../lib/test/composition/browser.test.ts | 2 + .../lib/test/first_display/agent.test.ts | 14 +- .../lib/test/first_display/contracts.test.ts | 12 +- .../test/first_display/gpt_adapter.test.ts | 48 ++++++ .../lib/test/first_display/handoff.test.ts | 7 +- .../lib/test/first_display/slices.test.ts | 139 +++++++++++++----- .../lib/test/first_display/takeover.test.ts | 7 +- .../lib/test/integrations/gpt/module.test.ts | 73 +++++++-- .../lib/test/services/slots.test.ts | 2 + 26 files changed, 519 insertions(+), 171 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 21ccea703..0c93c1a3c 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -492,14 +492,6 @@ impl IntegrationHeadInjector for GptIntegration { fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { Vec::new() } - - fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { - if self.config.gam_attribution_enabled { - vec![("data-ts-gam-attribution", "true")] - } else { - Vec::new() - } - } } // Default value functions diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 4ac70fbdd..d0ecea230 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -576,11 +576,6 @@ pub trait IntegrationHeadInjector: Send + Sync { fn integration_id(&self) -> &'static str; /// Return HTML snippets to insert at the start of ``. fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec; - - /// Return attributes to add to the publisher TSJS bundle tag. - fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { - Vec::new() - } } /// Registration payload returned by integration builders. @@ -1423,16 +1418,6 @@ impl IntegrationRegistry { inserts } - /// Collect static attributes for the publisher TSJS bundle tag. - #[must_use] - pub fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { - self.inner - .head_injectors - .iter() - .flat_map(|injector| injector.tsjs_script_tag_attributes()) - .collect() - } - /// Provide a snapshot of registered integrations and their hooks. #[must_use] pub fn registered_integrations(&self) -> Vec { @@ -1870,58 +1855,6 @@ mod tests { use crate::platform::test_support::noop_services; use http::{HeaderValue, StatusCode, header}; - struct DefaultMetadataHeadInjector; - - impl IntegrationHeadInjector for DefaultMetadataHeadInjector { - fn integration_id(&self) -> &'static str { - "default-metadata" - } - - fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - Vec::new() - } - } - - struct StaticMetadataHeadInjector; - - impl IntegrationHeadInjector for StaticMetadataHeadInjector { - fn integration_id(&self) -> &'static str { - "static-metadata" - } - - fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - Vec::new() - } - - fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { - vec![ - ("data-ts-gam-attribution", "true"), - ("data-test-order", "second"), - ] - } - } - - #[test] - fn tsjs_script_tag_attributes_preserve_registration_order_and_default_empty() { - let registry = IntegrationRegistry::from_rewriters_with_head_injectors( - Vec::new(), - Vec::new(), - vec![ - Arc::new(DefaultMetadataHeadInjector), - Arc::new(StaticMetadataHeadInjector), - ], - ); - - assert_eq!( - registry.tsjs_script_tag_attributes(), - vec![ - ("data-ts-gam-attribution", "true"), - ("data-test-order", "second"), - ], - "should omit default-empty metadata and preserve registered attribute order" - ); - } - // Mock integration proxy for testing struct MockProxy; diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 70549d0b5..e5b81d378 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -163,6 +163,15 @@ impl IntegrationConfigsV1 { selected.validate_manifest(module_ids)?; Ok(selected) } + + fn gpt_gam_attribution_enabled(&self) -> bool { + self.entries + .iter() + .find(|entry| entry.id == "gpt") + .and_then(|entry| entry.config.get("gamAttributionEnabled")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + } } impl Default for IntegrationConfigsV1 { @@ -311,6 +320,7 @@ pub fn tsjs_bootstrap_fragment_v1( FirstDisplaySelectionConfigV1 { enabled_integrations: &enabled_integrations, creative: config.creative, + gam_attribution_enabled: config.integration_configs.gpt_gam_attribution_enabled(), }, ); let agent = match first_display.as_ref() { @@ -562,6 +572,8 @@ pub struct FirstDisplaySelectionConfigV1<'a> { pub enabled_integrations: &'a [&'a str], /// Exact server-resolved parser-time creative guard policy. pub creative: CreativeBootConfigV1, + /// Whether GPT owns the document-local parser-time GAM attribution obligation. + pub gam_attribution_enabled: bool, } /// Exact ordered first-display slice mask selected for one immutable projection. @@ -668,7 +680,7 @@ pub fn select_first_display_slices_v1( let creative_guard = enabled.contains("creative") && config.creative.enabled && (config.creative.click_guard || config.creative.render_guard); - let gpt_participates = winner_count > 0; + let gpt_participates = winner_count > 0 || config.gam_attribution_enabled; let prebid_participates = enabled.contains("prebid") && projection.bids.iter().any(|bid| bid.provider == "prebid"); let mut mask = 0_u16; @@ -1286,6 +1298,46 @@ mod tests { ); } + #[test] + fn production_bootstrap_selects_gpt_initial_for_attribution_without_a_winner() { + let configs = IntegrationConfigsV1::new(vec![( + "gpt", + serde_json::json!({ + "gamAttributionEnabled": true, + "pageBidsEnabled": false, + }), + )]) + .expect("GPT browser config should be admitted"); + let projection = serde_json::to_string(&first_display_projection(None)) + .expect("no-bid projection should serialize"); + + let script = tsjs_bootstrap_fragment_v1( + TsjsBootScriptConfigV1 { + module_ids: &["render_runtime", "gpt"], + integration_configs: &configs, + auction_projection_json: &projection, + creative: CreativeBootConfigV1 { + enabled: false, + click_guard: false, + render_guard: false, + }, + render_trace_overlay: false, + gpt_diagnostics_active: false, + }, + "https://publisher.example", + ) + .expect("attribution-only first-display bootstrap should serialize"); + + assert!( + script.contains(r#""slices":["first_display","gpt_initial"]"#), + "typed GAM attribution must select the GPT parser-time owner: {script}" + ); + assert!( + script.contains("m=0041"), + "attribution-only first display must use the admitted GPT mask: {script}" + ); + } + fn hash_query_value(src: &str) -> &str { src.split_once("?v=") .map(|(_, hash)| hash) @@ -1802,6 +1854,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &enabled, creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .expect("closed no-bid batch should select the base agent"); @@ -1813,6 +1866,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &enabled, creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .expect("closed GPT ADM batch should select GPT initial ownership"); @@ -1825,6 +1879,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &enabled, creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .is_none(), @@ -1838,6 +1893,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &enabled, creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .is_none(), @@ -1856,6 +1912,7 @@ mod tests { click_guard: false, render_guard: true, }, + gam_attribution_enabled: false, }, ) .expect("bounded creative/GPT configuration should select the agent"); @@ -1871,6 +1928,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &["aps", "gpt"], creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .expect("bounded APS/GPT configuration should select the agent"); @@ -1887,6 +1945,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &["gpt", "prebid"], creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .expect("bounded Prebid/GPT configuration should select the agent"); @@ -1924,6 +1983,7 @@ mod tests { click_guard: false, render_guard: true, }, + gam_attribution_enabled: false, }, ) .expect("the optimized closed composition should fit the generated budget"); @@ -1938,6 +1998,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &unknown, creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .is_none(), diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index b9d137f74..1e71c3331 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -43,7 +43,7 @@ const firstDisplayApsPrivateProperties = // These names are private to the GPT initial IIFE and never cross its protocol // receipt or handoff boundary. Keep the cross-artifact protocol keys authored. const firstDisplayGptPrivateProperties = - /^(?:options|binding|command|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restoreTargetingObservers|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting|sealedTargetingOwnership|targetingWrites|ingressClosing|diagnosticRecords|nextDiagnosticCycleOrdinal|requestTimer|completionTimer|requestOperation|requestInvoked|bindingState|operations|requested|settled|encodedBytes|readPhysicalElementId|retireCycle|snapshotDestroyedCycles|invalidateCyclesForElement|invalidateCyclesForPublisherCall|consumeTargetingWrite|invalidateStaleTargetingObservers|captureRetainedTargeting)$/; + /^(?:options|binding|command|cycleMap|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restoreTargetingObservers|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting|sealedTargetingOwnership|targetingWrites|ingressClosing|diagnosticRecords|nextDiagnosticCycleOrdinal|requestTimer|completionTimer|requestOperation|requestInvoked|bindingState|operations|requested|settled|encodedBytes|readPhysicalElementId|retireCycle|snapshotDestroyedCycles|invalidateCyclesForElement|invalidateCyclesForPublisherCall|consumeTargetingWrite|invalidateStaleTargetingObservers|captureRetainedTargeting|timer)$/; fs.rmSync(distributionDirectory, { recursive: true, force: true }); fs.mkdirSync(distributionDirectory, { recursive: true }); diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs index 1f9426aa9..b09e95a11 100644 --- a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -105,6 +105,7 @@ const CURRENT_PROVIDER_SOURCE_OWNERS = Object.freeze({ 'src/integrations/sourcepoint/consent.ts': 'sourcepoint_consent', }); const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ + 'src/core/adapters/gam_attribution.ts': 'bootstrap', 'src/core/bootstrap.ts': 'bootstrap', 'src/core/contracts/boot.ts': 'bootstrap', 'src/core/contracts/integration_configs.ts': 'bootstrap', diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index f10ceb1fc..4c763f1a4 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -220,6 +220,7 @@ export interface GoogletagOperation { /** Narrow GPT boundary consumed by kernel sessions and services. */ export interface GoogletagAdapter { bindingStatus(): GoogletagBindingStatus; + enqueueGamAttribution(): boolean; adoptDiagnosticsState?(input: GoogletagDiagnosticsAdoptionV1): boolean; diagnosticsIdentity(slot: object): Readonly | undefined; traceToken(slot: object): GptSlotTokenV1 | undefined; @@ -1131,6 +1132,58 @@ export function createBrowserGoogletagAdapter( let pendingReservations = 0; let disposed = false; let firstDisplayObserved = false; + let gamAttributionEnqueued = false; + + const enqueueGamAttribution = (): boolean => { + if (disposed) return false; + if (gamAttributionEnqueued) return true; + let binding = readTarget(target); + if (binding === undefined || binding === null) { + const created = { cmd: [] }; + try { + if ( + !Reflect.defineProperty(target, 'googletag', { + configurable: true, + enumerable: true, + value: created, + writable: true, + }) || + readTarget(target) !== created + ) { + return false; + } + } catch { + return false; + } + binding = created; + } + if ((typeof binding !== 'object' || binding === null) && typeof binding !== 'function') { + return false; + } + const queue = commandQueue(binding as object); + if (!queue) return false; + gamAttributionEnqueued = true; + try { + queueCommand(queue, () => { + try { + const current = readTarget(target); + const root = + (typeof current === 'object' && current !== null) || typeof current === 'function' + ? (current as object) + : (binding as object); + const setConfig = safeMember(root, 'setConfig'); + if (typeof setConfig === 'function') { + Reflect.apply(setConfig, root, [{ targeting: { ts: 'true' } }]); + } + } catch { + // Missing or hostile GPT targeting cannot block later queue work. + } + }); + return true; + } catch { + return false; + } + }; const reportDiagnosticsFailure = (code: GoogletagDiagnosticsFailureCode): void => { try { @@ -3032,6 +3085,7 @@ export function createBrowserGoogletagAdapter( return Object.freeze({ bindingStatus: (): GoogletagBindingStatus => currentBinding().status, + enqueueGamAttribution, adoptDiagnosticsState, diagnosticsIdentity: (slot: object): Readonly | undefined => { const state = diagnosticsSlotState(slot); diff --git a/crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts b/crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts new file mode 100644 index 000000000..5ee6f99a1 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts @@ -0,0 +1,47 @@ +type GoogletagTarget = Window & { googletag?: unknown }; + +function object(value: unknown): Record | undefined { + return (typeof value === 'object' && value !== null) || typeof value === 'function' + ? (value as Record) + : undefined; +} + +/** Enqueue the document-local GAM attribution command at the parser-time boundary. */ +export function enqueueFirstDisplayGamAttribution(target: GoogletagTarget): boolean { + try { + let binding = object(target.googletag); + if (!binding) { + binding = { cmd: [] }; + if ( + !Reflect.defineProperty(target, 'googletag', { + configurable: true, + enumerable: true, + value: binding, + writable: true, + }) || + target.googletag !== binding + ) { + return false; + } + } + const queue = object(Reflect.get(binding, 'cmd')); + const push = queue && Reflect.get(queue, 'push'); + if (!queue || typeof push !== 'function') return false; + Reflect.apply(push, queue, [ + () => { + try { + const root = object(target.googletag) ?? binding; + const setConfig = Reflect.get(root, 'setConfig'); + if (typeof setConfig === 'function') { + Reflect.apply(setConfig, root, [{ targeting: { ts: 'true' } }]); + } + } catch { + // Missing or hostile GPT targeting cannot block later queue work. + } + }, + ]); + return true; + } catch { + return false; + } +} diff --git a/crates/trusted-server-js/lib/src/core/bootstrap.ts b/crates/trusted-server-js/lib/src/core/bootstrap.ts index 9f6459bb8..c8eef9683 100644 --- a/crates/trusted-server-js/lib/src/core/bootstrap.ts +++ b/crates/trusted-server-js/lib/src/core/bootstrap.ts @@ -9,6 +9,7 @@ import type { PreparedKernelTakeover } from '../kernel/integration_registry'; import type { FirstDisplaySliceId } from '../kernel/release_catalog'; import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; +import { enqueueFirstDisplayGamAttribution } from './adapters/gam_attribution'; import { snapshotBootstrapInputV1, type BootstrapInputSnapshotV1 } from './contracts/boot'; import { integrationConfigValueV1 } from './contracts/integration_configs'; import { EMBEDDED_RELEASE_ID } from './release_id'; @@ -506,7 +507,13 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): if (protocols.get(id) === value) protocols.delete(id); }; }; - if (id === 'gpt_initial') return Object.freeze({ observe, register }); + if (id === 'gpt_initial') { + return Object.freeze({ + gam: () => enqueueFirstDisplayGamAttribution(window), + observe, + register, + }); + } if (id === 'aps_initial') { return Object.freeze({ observe, publisherOrigin: location.origin, register }); } diff --git a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts index 95faf1d82..ce61c3e91 100644 --- a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts @@ -270,7 +270,7 @@ function winnerRows(projection: FirstDisplayProjectionV1): readonly Readonly<{ } class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { - private readonly cycles = new Map(); + private readonly cycleMap = new Map(); private readonly createdSlots = new Set(); private readonly diagnosticFacts: Readonly[] = []; private readonly diagnosticListeners = new Map void>(); @@ -354,7 +354,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { this.ingressClosed || this.ingressClosing || !this.started || - [...this.cycles.values()].some((cycle) => !cycle.settled) || + [...this.cycleMap.values()].some((cycle) => !cycle.settled) || !Array.isArray(committedSlotIds) ) { return false; @@ -363,7 +363,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (committed.size !== committedSlotIds.length) return false; const retainedSlots = new Set(); for (const slotId of committed) { - const matches = [...this.cycles.values()].filter((cycle) => cycle.slotId === slotId); + const matches = [...this.cycleMap.values()].filter((cycle) => cycle.slotId === slotId); if (matches.length !== 1 || !matches[0]?.settled) return false; retainedSlots.add(matches[0].physicalSlot); } @@ -422,7 +422,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { public captureHandoff(): readonly FirstDisplayGptHandoffCycleV1[] | undefined { if (this.disposed || !this.ingressClosed) return undefined; return Object.freeze( - [...this.cycles.values()].map((cycle) => { + [...this.cycleMap.values()].map((cycle) => { const targetingOwnership = this.sealedTargetingOwnership.get(cycle.physicalSlot) ?? Object.freeze([]); return Object.freeze({ @@ -444,7 +444,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (this.disposed || !this.ingressClosed) return undefined; return Object.freeze({ cycles: Object.freeze( - [...this.cycles.values()].map((cycle) => + [...this.cycleMap.values()].map((cycle) => Object.freeze({ nextCycleOrdinal: cycle.nextDiagnosticCycleOrdinal, quarantines: Object.freeze([]), @@ -479,7 +479,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (requested.size !== slotIds.length) return false; const selected: object[] = []; for (const slotId of requested) { - const matches = [...this.cycles.values()].filter((cycle) => cycle.slotId === slotId); + const matches = [...this.cycleMap.values()].filter((cycle) => cycle.slotId === slotId); if (matches.length !== 1 || !matches[0]?.settled) return false; selected.push(matches[0].physicalSlot); } @@ -515,7 +515,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } this.createdSlots.clear(); - this.cycles.clear(); + this.cycleMap.clear(); this.sealedTargetingOwnership.clear(); if (this.detachedSlots.size === 0) this.restoreCreatedBinding(); else this.createdBinding = undefined; @@ -653,7 +653,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { traceToken, unknownPriorCycle: ownership === 'publisher', }; - this.cycles.set(slot, cycle); + this.cycleMap.set(slot, cycle); callbacks.onBound( Object.freeze({ bid: cycle.bid, @@ -672,7 +672,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } - for (const cycle of this.cycles.values()) { + for (const cycle of this.cycleMap.values()) { if (this.disposed || cycle.settled) continue; try { for (let index = 0; index < cycle.operations.length; index += 1) { @@ -710,7 +710,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (this.disposed || this.requestedListener !== requestedListener) return; this.notifyNativeMutation(); const slot = physicalSlot(member(event, 'slot')); - const cycle = slot ? this.cycles.get(slot) : undefined; + const cycle = slot ? this.cycleMap.get(slot) : undefined; if (!cycle) { if (slot) { this.invalidateCyclesForElement(this.readPhysicalElementId(slot), slot, callbacks); @@ -735,7 +735,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (this.disposed || this.renderListener !== renderListener) return; this.notifyNativeMutation(); const slot = physicalSlot(member(event, 'slot')); - const cycle = slot ? this.cycles.get(slot) : undefined; + const cycle = slot ? this.cycleMap.get(slot) : undefined; if (!cycle) return; if (cycle.settled) { this.captureDiagnosticFact(SLOT_RENDER_ENDED, cycle, event); @@ -779,7 +779,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (this.disposed || this.diagnosticListeners.get(eventType) !== listener) return; this.notifyNativeMutation(); const slot = physicalSlot(member(event, 'slot')); - const cycle = slot ? this.cycles.get(slot) : undefined; + const cycle = slot ? this.cycleMap.get(slot) : undefined; if (cycle) this.captureDiagnosticFact(eventType, cycle, event); }; this.diagnosticListeners.set(eventType, listener); @@ -997,12 +997,12 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private snapshotDestroyedCycles(arguments_: readonly unknown[]): readonly ActiveCycle[] { try { const targets = arguments_[0]; - if (targets === undefined) return Object.freeze([...this.cycles.values()]); + if (targets === undefined) return Object.freeze([...this.cycleMap.values()]); if (!Array.isArray(targets)) return Object.freeze([]); const cycles = new Set(); for (let index = 0; index < targets.length; index += 1) { const target = physicalSlot(targets[index]); - const cycle = target ? this.cycles.get(target) : undefined; + const cycle = target ? this.cycleMap.get(target) : undefined; if (cycle) cycles.add(cycle); } return Object.freeze([...cycles]); @@ -1017,7 +1017,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { callbacks: FirstDisplayGoogletagBatchCallbacks ): void { if (!elementId) return; - for (const cycle of this.cycles.values()) { + for (const cycle of this.cycleMap.values()) { if (cycle.physicalSlot !== replacement && cycle.elementId === elementId) { this.retireCycle(cycle, callbacks); } diff --git a/crates/trusted-server-js/lib/src/first_display/agent.ts b/crates/trusted-server-js/lib/src/first_display/agent.ts index 363e68f52..9b4717ef7 100644 --- a/crates/trusted-server-js/lib/src/first_display/agent.ts +++ b/crates/trusted-server-js/lib/src/first_display/agent.ts @@ -1050,7 +1050,8 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { protocolId && AUCTION_PROTOCOLS.includes(protocolId) ? captureProtocolRegistration(candidate, protocolId, fullProtocols) : candidate, - own + own, + config.value ); if (protocolId && AUCTION_PROTOCOLS.includes(protocolId)) { if (auctionProtocols.has(protocolId) || !protocolIdentity(installed, protocolId)) { diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts index 50f19c6b1..c96cdafaa 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts @@ -24,7 +24,8 @@ export interface FirstDisplayGptProtocolV1 { } interface GptInitialBindings { - readonly observe: (name: 'protocol_version', value: number) => void; + readonly gam: () => boolean; + readonly observe: (name: 'gam' | 'v', value: boolean | number) => void; readonly register: (protocol: FirstDisplayGptProtocolV1) => () => void; } @@ -75,8 +76,11 @@ function bindings(candidate: unknown): GptInitialBindings | undefined { ) { return undefined; } - const fields = exactRecord(candidate, ['observe', 'register']); - return fields && typeof fields.observe === 'function' && typeof fields.register === 'function' + const fields = exactRecord(candidate, ['gam', 'observe', 'register']); + return fields && + typeof fields.gam === 'function' && + typeof fields.observe === 'function' && + typeof fields.register === 'function' ? (fields as unknown as GptInitialBindings) : undefined; } catch { @@ -135,10 +139,19 @@ function classifyRenderEnded(candidate: unknown): 'gam_empty' | 'nonempty_gam' | export function installGptInitial( candidate: unknown, own: FirstDisplaySliceActivationContext['own'], - createBatch: FirstDisplayGptBatchFactoryV1 + createBatch: FirstDisplayGptBatchFactoryV1, + configCandidate: unknown ): Readonly<{ version: 1; id: 'gpt' }> { const value = bindings(candidate); - if (!value || typeof own !== 'function' || typeof createBatch !== 'function') { + const gamAttributionEnabled = ( + configCandidate as Readonly<{ gamAttributionEnabled?: unknown }> | undefined + )?.gamAttributionEnabled; + if ( + !value || + typeof own !== 'function' || + typeof createBatch !== 'function' || + typeof gamAttributionEnabled !== 'boolean' + ) { throw new TypeError('tsjs'); } const protocol: FirstDisplayGptProtocolV1 = Object.freeze({ @@ -158,6 +171,8 @@ export function installGptInitial( const release = value.register(protocol); if (typeof release !== 'function') throw new TypeError('tsjs'); own(release); - value.observe('protocol_version', 1); + value.observe('gam', gamAttributionEnabled); + value.observe('v', 1); + if (gamAttributionEnabled && !value.gam()) throw new TypeError('tsjs'); return Object.freeze({ version: 1, id: 'gpt' }); } diff --git a/crates/trusted-server-js/lib/src/first_display/slices/definition.ts b/crates/trusted-server-js/lib/src/first_display/slices/definition.ts index 847f396ed..870db6447 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/definition.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/definition.ts @@ -14,7 +14,8 @@ export interface FirstDisplaySliceHost { export type InitialSliceInstaller = ( bindings: unknown, - own: FirstDisplaySliceActivationContext['own'] + own: FirstDisplaySliceActivationContext['own'], + config: unknown ) => unknown; export interface PreparedInitialSlice { diff --git a/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts index 0eb7e71dd..15c319846 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts @@ -3,9 +3,12 @@ import { installGptInitial } from '../leaf/gpt_protocol'; import { defineInitialSlice, registerInitialSlice } from './definition'; -export const GPT_INITIAL_SLICE = defineInitialSlice('gpt_initial', (candidate, own) => - installGptInitial(candidate, own, (input, protocol) => - createFirstDisplayGoogletagBatch({ ...input, protocol }) +export const GPT_INITIAL_SLICE = defineInitialSlice('gpt_initial', (candidate, own, config) => + installGptInitial( + candidate, + own, + (input, protocol) => createFirstDisplayGoogletagBatch({ ...input, protocol }), + config ) ); 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 215008c6b..98bc53bc4 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -1660,6 +1660,7 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg if (!isGptIntegrationConfigV1(context.config)) { throw new TypeError('GPT integration config is invalid'); } + const config = context.config; const auction = exactCapability(context.interfaces, 'auction.v1'); const slotCapability = exactCapability(context.interfaces, 'slots.v1'); const aps = exactCapability(context.interfaces, 'aps.v1'); @@ -1888,14 +1889,19 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg : undefined; if ( selected === undefined || + (config.gamAttributionEnabled && !selected) || (selected && (!initialState || - initialState.values.length !== 1 || - initialState.values[0]?.[0] !== 'protocol_version' || - initialState.values[0][1] !== 1)) + initialState.values.length !== 2 || + initialState.values[0]?.[0] !== 'gam' || + initialState.values[0][1] !== config.gamAttributionEnabled || + initialState.values[1]?.[0] !== 'v' || + initialState.values[1][1] !== 1)) ) { throw new TypeError('GPT first-display parser state is invalid'); } + } else if (config.gamAttributionEnabled && !googletag.enqueueGamAttribution()) { + throw new TypeError('GPT GAM attribution is unavailable'); } const diagnosticsEventRelease: { current?: () => void } = {}; const diagnosticsRelease: { current?: () => void } = {}; diff --git a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts index 4f091b494..c4c1260f4 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts @@ -468,9 +468,16 @@ function validParserValues( typeof value === 'number' && Number.isInteger(value) && value >= 0; switch (sliceId) { case 'aps_initial': - case 'gpt_initial': case 'prebid_initial': return single('protocol_version', (value) => value === 1); + case 'gpt_initial': + return ( + values.length === 2 && + values[0]?.[0] === 'gam' && + typeof values[0][1] === 'boolean' && + values[1]?.[0] === 'v' && + values[1][1] === 1 + ); case 'creative_initial': return single('guard_count', (value) => integer(value) && value >= 1 && value <= 3); case 'datadome_initial': 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 e71a71b45..44cf0aa76 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -61,6 +61,47 @@ function createReadyGoogletag( describe('browser googletag adapter readiness', () => { afterEach(() => vi.useRealTimers()); + it('enqueues GAM attribution once before later publisher commands', () => { + const ready = createReadyGoogletag({ deferCommands: true }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const order: string[] = []; + ready.googletag.setConfig.mockImplementation((config) => { + order.push('trusted-server'); + return JSON.stringify(config); + }); + + expect(adapter.enqueueGamAttribution()).toBe(true); + expect(adapter.enqueueGamAttribution()).toBe(true); + ready.googletag.cmd.push(() => order.push('publisher')); + + expect(ready.commands).toHaveLength(2); + ready.commands.splice(0).forEach((command) => command()); + expect(order).toEqual(['trusted-server', 'publisher']); + expect(ready.googletag.setConfig).toHaveBeenCalledExactlyOnceWith({ + targeting: { ts: 'true' }, + }); + }); + + it('creates an absent GPT queue and isolates a missing or throwing targeting API', () => { + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + + expect(adapter.enqueueGamAttribution()).toBe(true); + const created = target.googletag as { cmd: Array<() => void> }; + expect(created.cmd).toHaveLength(1); + expect(() => created.cmd[0]?.()).not.toThrow(); + + const publisher = vi.fn(); + const throwing = createReadyGoogletag(); + throwing.googletag.setConfig.mockImplementation(() => { + throw new Error('targeting unavailable'); + }); + const throwingAdapter = createBrowserGoogletagAdapter({ googletag: throwing.googletag }); + expect(throwingAdapter.enqueueGamAttribution()).toBe(true); + expect(() => throwing.googletag.cmd.push(publisher)).not.toThrow(); + expect(publisher).toHaveBeenCalledOnce(); + }); + it('reports present and gives the command only a frozen narrow facade', async () => { const ready = createReadyGoogletag(); const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); 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 8443d401c..9104e6d41 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 @@ -430,6 +430,7 @@ test('generated first-display components self-register through one authenticated if (id === 'gpt_initial') { return Object.freeze({ bindings: Object.freeze({ + gam: function() { return true; }, observe: function() {}, register: function(protocol) { window.__firstDisplayEvents.push('gpt:' + protocol.id); 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 75428af83..5b0dc3a77 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -353,6 +353,7 @@ function synchronousGptAdapter(initialSlots: readonly object[] = []) { }); const adapter: GoogletagAdapter = Object.freeze({ bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), diagnosticsIdentity: () => undefined, dispose: vi.fn(), notifyReady: vi.fn(), @@ -1456,6 +1457,7 @@ describe('browser composition', () => { } as unknown as GoogletagFacade; const googletag: GoogletagAdapter = Object.freeze({ bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), diagnosticsIdentity: () => undefined, dispose: vi.fn(), notifyReady: vi.fn(), diff --git a/crates/trusted-server-js/lib/test/first_display/agent.test.ts b/crates/trusted-server-js/lib/test/first_display/agent.test.ts index 43cf2619b..65f5705b6 100644 --- a/crates/trusted-server-js/lib/test/first_display/agent.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/agent.test.ts @@ -676,8 +676,11 @@ describe('bounded first-display agent', () => { Object.freeze([ Object.freeze({ sliceId: 'gpt_initial', - observations: Object.freeze(['protocol_version']), - values: Object.freeze([Object.freeze(['protocol_version', 1] as const)]), + observations: Object.freeze(['gam', 'v']), + values: Object.freeze([ + Object.freeze(['gam', false] as const), + Object.freeze(['v', 1] as const), + ]), }), ]), handoff: Object.freeze({ @@ -703,8 +706,11 @@ describe('bounded first-display agent', () => { parserState: [ { sliceId: 'gpt_initial', - observations: ['protocol_version'], - values: [['protocol_version', 1]], + observations: ['gam', 'v'], + values: [ + ['gam', false], + ['v', 1], + ], }, ], attempts: [ diff --git a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts index abfe5edf9..818f3d92a 100644 --- a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts @@ -81,8 +81,11 @@ function handoff(overrides: Record = {}): Record { parserState: [ { sliceId: 'gpt_initial', - observations: ['protocol_version', 'extra'], + observations: ['gam', 'v', 'extra'], values: [ - ['protocol_version', 1], + ['gam', false], + ['v', 1], ['extra', true], ], }, diff --git a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts index 928386276..6ecc63caa 100644 --- a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; import { createFirstDisplayGoogletagBatch } from '../../src/first_display/adapters/googletag'; +import { enqueueFirstDisplayGamAttribution } from '../../src/core/adapters/gam_attribution'; import { snapshotFirstDisplayBatchV1 } from '../../src/first_display/leaf/projection'; const RESERVATION_ID = `r1_${'a'.repeat(22)}`; @@ -156,6 +157,53 @@ function protocol() { } describe('first-display GPT adapter', () => { + it('owns enabled GAM attribution before publisher parser work without a winning bid', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const commands: Array<() => void> = []; + const order: string[] = []; + const setConfig = vi.fn(() => order.push('trusted-server')); + Object.defineProperty(dom.window, 'tsjs', { + configurable: true, + value: { adInit: true }, + }); + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { cmd: commands, setConfig }, + writable: true, + }); + expect(enqueueFirstDisplayGamAttribution(dom.window as unknown as Window)).toBe(true); + commands.push(() => order.push('publisher')); + commands.splice(0).forEach((command) => command()); + + expect(order).toEqual(['trusted-server', 'publisher']); + expect(setConfig).toHaveBeenCalledExactlyOnceWith({ targeting: { ts: 'true' } }); + }); + + it('creates an absent GPT queue and isolates a missing or throwing targeting API', () => { + const absent: { googletag?: unknown } = {}; + expect(enqueueFirstDisplayGamAttribution(absent as unknown as Window)).toBe(true); + const created = absent.googletag as { cmd: Array<() => void> }; + expect(created.cmd).toHaveLength(1); + expect(() => created.cmd[0]?.()).not.toThrow(); + + const publisher = vi.fn(); + const commands: Array<() => void> = []; + const throwing = { + googletag: { + cmd: commands, + setConfig: () => { + throw new Error('targeting unavailable'); + }, + }, + }; + expect(enqueueFirstDisplayGamAttribution(throwing as unknown as Window)).toBe(true); + commands.push(publisher); + expect(() => commands.splice(0).forEach((command) => command())).not.toThrow(); + expect(publisher).toHaveBeenCalledOnce(); + }); + it('observes publisher GPT calls, events, and targeting until ingress closes', () => { const dom = new JSDOM('
', { url: 'https://publisher.example/', diff --git a/crates/trusted-server-js/lib/test/first_display/handoff.test.ts b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts index 9c6dc7975..c7bcc9b33 100644 --- a/crates/trusted-server-js/lib/test/first_display/handoff.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts @@ -79,8 +79,11 @@ function acceptedHandoff(revision: number): Record { parserState: [ { sliceId: 'gpt_initial', - observations: ['protocol_version'], - values: [['protocol_version', 1]], + observations: ['gam', 'v'], + values: [ + ['gam', false], + ['v', 1], + ], }, ], timing: { bidsScriptMs: 1, firstDisplayMs: 2, terminalMs: 3, paintMs: 4 }, diff --git a/crates/trusted-server-js/lib/test/first_display/slices.test.ts b/crates/trusted-server-js/lib/test/first_display/slices.test.ts index f3dd26f9f..9ee5571a1 100644 --- a/crates/trusted-server-js/lib/test/first_display/slices.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/slices.test.ts @@ -17,6 +17,7 @@ import { OSANO_INITIAL_SLICE } from '../../src/first_display/slices/osano'; import { PERMUTIVE_INITIAL_SLICE } from '../../src/first_display/slices/permutive'; import { SOURCEPOINT_INITIAL_SLICE } from '../../src/first_display/slices/sourcepoint'; import { TESTLIGHT_INITIAL_SLICE } from '../../src/first_display/slices/testlight'; +import type { InitialSliceInstaller } from '../../src/first_display/slices/definition'; import type { FirstDisplayRouteRuleV1 } from '../../src/first_display/leaf/route_guard'; import { installDidomiInitial } from '../../src/first_display/leaf/config_guard'; import type { FirstDisplayCreativeGuardV1 } from '../../src/first_display/leaf/creative_guard'; @@ -146,22 +147,26 @@ describe('first-display initial slice definitions', () => { }), own ), - installGptInitial(Object.freeze({ observe, register }), own, () => - Object.freeze({ - start: () => true, - closeIngress: () => true, - captureHandoff: () => Object.freeze([]), - captureDiagnosticsHandoff: () => - Object.freeze({ - cycles: Object.freeze([]), - facts: Object.freeze([]), - nextTraceTokenOrdinal: 1, - overflowCount: 0, - dropCount: 0, - }), - detachCommittedSlots: () => true, - dispose: () => undefined, - }) + installGptInitial( + Object.freeze({ gam: () => true, observe, register }), + own, + () => + Object.freeze({ + start: () => true, + closeIngress: () => true, + captureHandoff: () => Object.freeze([]), + captureDiagnosticsHandoff: () => + Object.freeze({ + cycles: Object.freeze([]), + facts: Object.freeze([]), + nextTraceTokenOrdinal: 1, + overflowCount: 0, + dropCount: 0, + }), + detachCommittedSlots: () => true, + dispose: () => undefined, + }), + Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }) ), installPrebidInitial(Object.freeze({ observe, register }), own), ]; @@ -182,6 +187,52 @@ describe('first-display initial slice definitions', () => { } }); + it.each([false, true])( + 'projects the typed GAM attribution flag into the first-display GPT owner (%s)', + (gamAttributionEnabled) => { + const observe = vi.fn(); + const gam = vi.fn(() => true); + let protocol: FirstDisplayGptProtocolV1 | undefined; + const createBatch = vi.fn((_input: unknown) => + Object.freeze({ + start: () => true, + closeIngress: () => true, + captureHandoff: () => Object.freeze([]), + captureDiagnosticsHandoff: () => + Object.freeze({ + cycles: Object.freeze([]), + facts: Object.freeze([]), + nextTraceTokenOrdinal: 1, + overflowCount: 0, + dropCount: 0, + }), + detachCommittedSlots: () => true, + dispose: () => undefined, + }) + ); + installGptInitial( + Object.freeze({ + gam, + observe, + register: (candidate: FirstDisplayGptProtocolV1) => { + protocol = candidate; + return () => undefined; + }, + }), + () => undefined, + createBatch, + Object.freeze({ gamAttributionEnabled, pageBidsEnabled: true }) + ); + + protocol?.createBatch({} as never); + + expect(createBatch).toHaveBeenCalledOnce(); + expect(createBatch.mock.calls[0]?.[0]).toEqual({}); + expect(gam).toHaveBeenCalledTimes(gamAttributionEnabled ? 1 : 0); + expect(observe).toHaveBeenCalledWith('gam', gamAttributionEnabled); + } + ); + it('pins the exact twelve optional slices in build order', () => { expect(INITIAL_SLICE_DEFINITIONS.map(({ id }) => id)).toEqual([ 'aps_initial', @@ -366,10 +417,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('didomi_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); const prepared = DIDOMI_INITIAL_SLICE.prepare(host); @@ -478,7 +529,7 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('testlight_initial'); install?.( @@ -487,7 +538,8 @@ describe('first-display initial slice definitions', () => { observe: (_name: string, count: number) => observations.push(count), target, }), - own + own, + undefined ); }, }); @@ -550,7 +602,7 @@ describe('first-display initial slice definitions', () => { activate: ( _id: string, own: (release: () => void) => void, - install?: (candidate: unknown, ownEffect: (release: () => void) => void) => void + install?: InitialSliceInstaller ) => install?.( Object.freeze({ @@ -561,7 +613,8 @@ describe('first-display initial slice definitions', () => { return dispose; }, }), - own + own, + undefined ), }); fixture.definition.prepare(host).activate( @@ -609,8 +662,8 @@ describe('first-display initial slice definitions', () => { activate: ( _id: string, own: (release: () => void) => void, - install?: (candidate: unknown, ownEffect: (release: () => void) => void) => void - ) => install?.(bindings, own), + install?: InitialSliceInstaller + ) => install?.(bindings, own, undefined), }); LOCKR_INITIAL_SLICE.prepare(host).activate( @@ -677,10 +730,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (release: () => void) => void, - install?: (candidate: unknown, ownEffect: (release: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('permutive_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); @@ -753,10 +806,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (release: () => void) => void, - install?: (candidate: unknown, ownEffect: (release: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('sourcepoint_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); @@ -822,10 +875,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (release: () => void) => void, - install?: (candidate: unknown, ownEffect: (release: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('osano_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); @@ -981,10 +1034,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('creative_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); @@ -1041,7 +1094,7 @@ describe('first-display initial slice definitions', () => { activate: ( _id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => install?.( Object.freeze({ @@ -1053,7 +1106,8 @@ describe('first-display initial slice definitions', () => { observe: () => undefined, register, }), - own + own, + undefined ), }); expect(() => @@ -1081,10 +1135,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('aps_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); @@ -1162,6 +1216,7 @@ describe('first-display initial slice definitions', () => { const disposers: Array<() => void> = []; let protocol: FirstDisplayGptProtocolV1 | undefined; const bindings = Object.freeze({ + gam: vi.fn(() => true), observe: vi.fn(), register: (candidate: FirstDisplayGptProtocolV1) => { protocol = candidate; @@ -1172,10 +1227,14 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('gpt_initial'); - install?.(bindings, own); + install?.( + bindings, + own, + Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }) + ); }, }); @@ -1232,10 +1291,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('prebid_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); diff --git a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts index 41c8d4287..ea4c5cbcb 100644 --- a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts @@ -59,8 +59,11 @@ function handoff(revision = 0): Record { parserState: [ { sliceId: 'gpt_initial', - observations: ['protocol_version'], - values: [['protocol_version', 1]], + observations: ['gam', 'v'], + values: [ + ['gam', false], + ['v', 1], + ], }, ], gptDiagnostics: { facts: [], overflowCount: 0, dropCount: 0 }, 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 d7aa7f72c..28390d0d8 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 @@ -809,14 +809,51 @@ describe('transactional GPT integration module', () => { ); it.each([ - { diagnosticsActive: false, adoptInitialDisplay: false, parserStateValid: true }, - { diagnosticsActive: true, adoptInitialDisplay: false, parserStateValid: true }, - { diagnosticsActive: false, adoptInitialDisplay: true, parserStateValid: true }, - { diagnosticsActive: false, adoptInitialDisplay: true, parserStateValid: false }, - { diagnosticsActive: false, adoptInitialDisplay: true, parserStateValid: 'absent' }, + { + diagnosticsActive: false, + adoptInitialDisplay: false, + parserStateValid: true, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: true, + adoptInitialDisplay: false, + parserStateValid: true, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: false, + parserStateValid: true, + gamAttributionEnabled: true, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: true, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: true, + gamAttributionEnabled: true, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: false, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: 'absent', + gamAttributionEnabled: false, + }, ])( - 'uses only catalog capabilities without replaying adopted display (diagnostics=$diagnosticsActive, adoption=$adoptInitialDisplay, parser=$parserStateValid)', - async ({ diagnosticsActive, adoptInitialDisplay, parserStateValid }) => { + 'uses only catalog capabilities without replaying adopted display (diagnostics=$diagnosticsActive, adoption=$adoptInitialDisplay, parser=$parserStateValid, attribution=$gamAttributionEnabled)', + async ({ diagnosticsActive, adoptInitialDisplay, parserStateValid, gamAttributionEnabled }) => { vi.useFakeTimers(); const NativeMutationObserver = window.MutationObserver; const activeMutationObservers = new Set(); @@ -893,6 +930,7 @@ describe('transactional GPT integration module', () => { removedTypes.push(type); }), }; + const setConfig = vi.fn(); (window as Window & { googletag?: unknown }).googletag = { apiReady: true, pubadsReady: true, @@ -902,7 +940,7 @@ describe('transactional GPT integration module', () => { display, getConfig: vi.fn(() => ({ disableInitialLoad: false })), pubads: () => pubads, - setConfig: vi.fn(), + setConfig, }; const providerFacades = new Map>>(); const protect = vi.fn(() => true); @@ -994,7 +1032,8 @@ describe('transactional GPT integration module', () => { runtimeCapability: runtime, getBindings: (id) => Object.freeze({ - config: id === 'gpt' ? GPT_CONFIG : undefined, + config: + id === 'gpt' ? Object.freeze({ ...GPT_CONFIG, gamAttributionEnabled }) : undefined, interfaces: Object.freeze({}), }), onCapabilityStaged: (key, facade) => { @@ -1078,9 +1117,10 @@ describe('transactional GPT integration module', () => { ? Object.freeze([ Object.freeze({ sliceId: 'gpt_initial', - observations: Object.freeze(['protocol_version']), + observations: Object.freeze(['gam', 'v']), values: Object.freeze([ - Object.freeze(['protocol_version', 1] as const), + Object.freeze(['gam', gamAttributionEnabled] as const), + Object.freeze(['v', 1] as const), ]), }), ]) @@ -1173,6 +1213,7 @@ describe('transactional GPT integration module', () => { const result = await registry.install(installCallbacks); if (adoptInitialDisplay && parserStateValid !== true) { expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(setConfig).not.toHaveBeenCalled(); expect(defineSlot).not.toHaveBeenCalled(); expect(display).not.toHaveBeenCalled(); expect(refresh).not.toHaveBeenCalled(); @@ -1180,6 +1221,12 @@ describe('transactional GPT integration module', () => { return; } expect(result.state).toBe('kernel'); + expect(setConfig).toHaveBeenCalledTimes( + gamAttributionEnabled && !adoptInitialDisplay ? 1 : 0 + ); + if (gamAttributionEnabled && !adoptInitialDisplay) { + expect(setConfig).toHaveBeenCalledExactlyOnceWith({ targeting: { ts: 'true' } }); + } const gpt = providerFacades.get('gpt.v1') as { activateLaterLifecycle: () => Readonly<{ navigate: (path: string) => Promise; @@ -1426,6 +1473,10 @@ describe('transactional GPT integration module', () => { sourceFrame.remove(); slotElement.remove(); + expect(setConfig.mock.calls).toEqual( + gamAttributionEnabled ? [[{ targeting: { ts: 'true' } }]] : [] + ); + if (result.state === 'kernel') result.dispose(); expect(activeMutationObservers.size).toBe(0); expect(removedTypes.sort()).toEqual([...listenerTypes].sort()); 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 b7c3c6628..a4f936660 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -160,6 +160,7 @@ function createGptHarness( }); const adapter: GoogletagAdapter = Object.freeze({ bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), diagnosticsIdentity: () => undefined, dispose: vi.fn(), notifyReady: vi.fn(), @@ -4626,6 +4627,7 @@ describe('Task 11 adversarial ownership review', () => { let rejectNext = true; const adapter: GoogletagAdapter = Object.freeze({ bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), diagnosticsIdentity: () => undefined, dispose: vi.fn(), notifyReady: vi.fn(), From 79a2450240506960293c3fa7bf235a5f3e105705 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:09:18 -0700 Subject: [PATCH 526/844] Prove deferred Prebid refresh ownership --- .../test/integrations/prebid/later.test.ts | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 crates/trusted-server-js/lib/test/integrations/prebid/later.test.ts diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/later.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/later.test.ts new file mode 100644 index 000000000..9f1ef515c --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/later.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createPrebidLaterIntegrationRegistration } from '../../../src/integrations/prebid/later'; +import type { PrebidRefreshPolicy } from '../../../src/integrations/prebid/refresh'; +import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; +import { createRuntimeSession } from '../../../src/kernel/sessions'; + +const RELEASE_ID = 'a'.repeat(64); +const CONFIG = Object.freeze({ + accountId: 'fictional-account', + timeout: 1_000, + debug: false, + bidders: Object.freeze(['server']), + clientSideBidders: Object.freeze(['client']), + excludedGamAdUnitPathSuffixes: Object.freeze(['/excluded']), +}); + +function harness(acceptPolicy = true) { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(4); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected current navigation'); + const navigation = navigationResult.value; + const physicalSlot = Object.freeze({ id: 'physical-slot' }); + const directAuctionUnit = Object.freeze({ + code: 'slot-one', + 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({ + server: Object.freeze({ placement: 'server-owned' }), + }), + }), + }), + Object.freeze({ bidder: 'client', params: Object.freeze({ placement: 'publisher-client' }) }), + ]), + }); + const clearTargeting = vi.fn(); + const gptOperationDispose = vi.fn(); + const googletagRun = vi.fn((command: (facade: object) => unknown) => + Object.freeze({ + result: Promise.resolve( + command( + Object.freeze({ + adUnitPath: () => '/network/eligible', + clearTargeting, + }) + ) + ), + dispose: gptOperationDispose, + }) + ); + const requestBids = vi.fn((request: Readonly>) => { + const callback = request.bidsBackHandler; + if (typeof callback === 'function') callback(); + }); + const setTargetingForGpt = vi.fn(); + const prebidOperationDispose = vi.fn(); + const prebidRun = vi.fn((command: (facade: object) => unknown) => + Object.freeze({ + result: Promise.resolve( + command( + Object.freeze({ + requestBids, + setTargetingForGpt, + }) + ) + ), + dispose: prebidOperationDispose, + }) + ); + let policy: PrebidRefreshPolicy | undefined; + const policyRelease = vi.fn(); + const installRefreshPolicy = vi.fn((candidate: PrebidRefreshPolicy) => { + policy = candidate; + return acceptPolicy ? policyRelease : undefined; + }); + const directAuctionUnitForSlot = vi.fn((slot: object) => + slot === physicalSlot ? directAuctionUnit : undefined + ); + const activationDisposers: Array<() => void> = []; + const registration = createPrebidLaterIntegrationRegistration(RELEASE_ID); + const prepared = registration.prepare( + Object.freeze({ + config: CONFIG, + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({}), + 'slots.v1': Object.freeze({}), + 'gpt.v1': Object.freeze({ + adapter: Object.freeze({ run: googletagRun }), + directAuctionUnitForSlot, + installRefreshPolicy, + navigation: () => navigation, + }), + 'prebid.v1': Object.freeze({ + adapter: Object.freeze({ run: prebidRun }), + clientSideBidders: CONFIG.clientSideBidders, + excludedGamAdUnitPathSuffixes: CONFIG.excludedGamAdUnitPathSuffixes, + }), + }), + onDispose: () => undefined, + signal: new AbortController().signal, + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + const activation = Object.freeze({ + afterCommit: vi.fn(), + onDispose: (callback: () => void) => activationDisposers.push(callback), + signal: new AbortController().signal, + } satisfies IntegrationActivationContext); + + return { + activation, + activationDisposers, + clearTargeting, + directAuctionUnitForSlot, + googletagRun, + installRefreshPolicy, + physicalSlot, + policy: () => policy, + policyRelease, + prebidRun, + registration, + requestBids, + runtime, + setTargetingForGpt, + prepared, + }; +} + +describe('RCJ-PREBID-04 deferred refresh ownership', () => { + it('has no initial-admission effect and runs one detached refresh after activation', async () => { + const owner = harness(); + + expect(owner.registration).toMatchObject({ id: 'prebid_later', phase: 'deferred' }); + expect(owner.installRefreshPolicy).not.toHaveBeenCalled(); + expect(owner.googletagRun).not.toHaveBeenCalled(); + expect(owner.prebidRun).not.toHaveBeenCalled(); + expect(owner.directAuctionUnitForSlot).not.toHaveBeenCalled(); + + owner.prepared.activate(owner.activation); + expect(owner.installRefreshPolicy).toHaveBeenCalledOnce(); + expect(owner.prebidRun).not.toHaveBeenCalled(); + + const completion = owner + .policy() + ?.prepare(Object.freeze({ slots: Object.freeze([owner.physicalSlot]) })); + expect(completion).toBeDefined(); + await Promise.resolve(completion); + + expect(owner.googletagRun).toHaveBeenCalledOnce(); + expect(owner.directAuctionUnitForSlot).toHaveBeenCalledExactlyOnceWith(owner.physicalSlot); + expect(owner.prebidRun).toHaveBeenCalledOnce(); + expect(owner.requestBids).toHaveBeenCalledOnce(); + expect(owner.setTargetingForGpt).toHaveBeenCalledExactlyOnceWith(['slot-one']); + expect(owner.requestBids.mock.calls[0]?.[0]).toMatchObject({ + adUnits: [ + { + code: 'slot-one', + bids: [ + { + bidder: 'trustedServer', + params: { bidderParams: { server: { placement: 'server-owned' } } }, + }, + { bidder: 'client', params: { placement: 'publisher-client' } }, + ], + }, + ], + }); + + owner.activationDisposers.reverse().forEach((dispose) => dispose()); + expect(owner.policyRelease).toHaveBeenCalledOnce(); + owner.runtime.dispose(); + }); + + it('fails closed when GPT already has a refresh owner without starting another auction', () => { + const owner = harness(false); + + expect(() => owner.prepared.activate(owner.activation)).toThrow( + 'Prebid refresh policy is duplicated' + ); + expect(owner.installRefreshPolicy).toHaveBeenCalledOnce(); + expect(owner.googletagRun).not.toHaveBeenCalled(); + expect(owner.prebidRun).not.toHaveBeenCalled(); + + owner.activationDisposers.reverse().forEach((dispose) => dispose()); + expect(owner.policyRelease).not.toHaveBeenCalled(); + owner.runtime.dispose(); + }); +}); From 9c7c631b4242db9d503a40c234e07c98915401d6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:49:17 -0700 Subject: [PATCH 527/844] Prove APS top-mount containment in browsers --- .../browser/fixtures/fictional-aps-runner.js | 50 +- .../browser/helpers/gpt-stub.ts | 15 + .../tests/shared/aps-puc-lifecycle.spec.ts | 140 +++++ .../browser/tests/shared/aps-renderer.spec.ts | 500 ++++++++++++++++-- docs/guide/integrations/aps.md | 6 + 5 files changed, 680 insertions(+), 31 deletions(-) diff --git a/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js b/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js index 900aee7eb..a334e4516 100644 --- a/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js +++ b/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js @@ -36,17 +36,65 @@ detail.reject(new Error("fictional_rejection")); return; } + if (bidId.indexOf("deferred-") === 0) { + window.__fictionalApsResolve = function () { + delete window.__fictionalApsResolve; + detail.resolve(); + }; + return; + } + var decoded; + var bid; + try { + decoded = JSON.parse(atob(detail.aaxResponse)); + bid = decoded.seatbid[0].bid[0]; + } catch (_error) { + detail.reject(new Error("fictional_envelope_invalid")); + return; + } if (bidId.indexOf("nested-") === 0) { var frame = document.createElement("iframe"); + frame.setAttribute("data-fictional-creative", "nested"); frame.setAttribute("sandbox", "allow-scripts"); + frame.setAttribute("width", String(bid.w)); + frame.setAttribute("height", String(bid.h)); + frame.style.border = "0"; + frame.style.display = "block"; + frame.style.width = String(bid.w) + "px"; + frame.style.height = String(bid.h) + "px"; + frame.style.margin = "0"; + frame.style.overflow = "hidden"; frame.srcdoc = - "`, + }); + } + if (url.pathname === "/script-allowed.js") { + return route.fulfill({ + status: 200, + contentType: "application/javascript", + body: `document.documentElement.dataset.scriptCreative='executed';var script=document.createElement('script');script.src='https://other.example/other.js';document.head.appendChild(script);`, + }); + } + if (url.pathname === "/script-redirect.js") { + return route.fulfill({ + status: 302, + headers: { location: "https://redirect.example/final.js" }, + }); + } + return route.abort(); + }); await page.route(`${APS_TEST_ORIGIN}/aps-v2-protocol-test`, (route) => route.fulfill({ status: 200, @@ -131,13 +207,24 @@ window.apsV2Records = Object.create(null); window.startApsV2 = function(options) { var slot = document.createElement('div'); slot.id = options.slotId; + slot.style.height = options.renderer.height + 'px'; + slot.style.overflow = 'hidden'; + slot.style.position = 'relative'; + slot.style.width = options.renderer.width + 'px'; slot.innerHTML = 'existing publisher content'; document.getElementById('slots').appendChild(slot); var frame = document.createElement('iframe'); frame.setAttribute('sandbox', ${JSON.stringify(SANDBOX)}); + frame.setAttribute('width', String(options.renderer.width)); + frame.setAttribute('height', String(options.renderer.height)); frame.src = ${JSON.stringify(APS_TEST_RENDERER_URL)} + '#' + options.bootstrapNonce; + frame.style.border = '0'; frame.style.display = 'none'; - var record = {messages: [], frame: frame, port: null}; + frame.style.height = options.renderer.height + 'px'; + frame.style.margin = '0'; + frame.style.overflow = 'hidden'; + frame.style.width = options.renderer.width + 'px'; + var record = {messages: [], snapshots: [], bootstrapConfiguration: null, frame: frame, port: null}; window.apsV2Records[options.slotId] = record; function receive(event) { if (event.source !== frame.contentWindow || event.origin !== 'null' || @@ -148,14 +235,40 @@ window.startApsV2 = function(options) { value.bootstrapNonce === options.bootstrapNonce && event.ports.length === 0) { frame.setAttribute('sandbox', ${JSON.stringify(PERMANENT_SANDBOX)}); var creativeOrigin = new URL(options.renderer.creativeUrl).origin; - frame.contentWindow.postMessage(JSON.stringify({ + if (options.adversarialBootstrap) { + var invalidChannel = new MessageChannel(); + frame.contentWindow.postMessage('{"message":', '*', []); + frame.contentWindow.postMessage('x'.repeat(16385), '*', []); + frame.contentWindow.postMessage(JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce: 'b1_0000000000000000000000', + rendererNonce: options.rendererNonce, + creativeOrigin: creativeOrigin, + tagType: options.renderer.tagType + }), '*', []); + frame.contentWindow.postMessage(JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce: options.bootstrapNonce, + rendererNonce: options.rendererNonce, + creativeOrigin: creativeOrigin, + tagType: options.renderer.tagType + }), '*', [invalidChannel.port1]); + invalidChannel.port2.close(); + } + record.bootstrapConfiguration = { message: 'TS APS Bootstrap Configure', version: 2, bootstrapNonce: options.bootstrapNonce, rendererNonce: options.rendererNonce, creativeOrigin: creativeOrigin, tagType: options.renderer.tagType - }), '*', []); + }; + frame.contentWindow.postMessage(JSON.stringify(record.bootstrapConfiguration), '*', []); + if (options.adversarialBootstrap) { + frame.contentWindow.postMessage(JSON.stringify(record.bootstrapConfiguration), '*', []); + } return; } if (value.message !== 'TS APS Container Ready' || value.version !== 1 || @@ -165,6 +278,11 @@ window.startApsV2 = function(options) { record.port = event.ports[0]; record.port.onmessage = function(portEvent) { record.messages.push(portEvent.data); + record.snapshots.push({ + message: portEvent.data && portEvent.data.message, + display: frame.style.display, + existing: slot.querySelectorAll('.existing').length + }); if (portEvent.data && portEvent.data.message === 'TS APS Render Completed') { var existing = slot.querySelector('.existing'); if (existing) existing.remove(); @@ -187,23 +305,11 @@ window.startApsV2 = function(options) { ); await page.goto(`${APS_TEST_ORIGIN}/aps-v2-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 = {}, + adversarialBootstrap = false, ) => { const bootstrapNonce = `b1_${slotId.padEnd(22, "b").slice(0, 22)}`; const rendererNonce = `n1_${slotId.padEnd(22, "n").slice(0, 22)}`; @@ -219,10 +325,8 @@ window.startApsV2 = function(options) { slotId, bootstrapNonce, rendererNonce, - renderer: { - ...makeDescriptor(bidId), - ...rendererOverrides, - }, + renderer: descriptor(bidId, rendererOverrides), + adversarialBootstrap, }, ); return rendererNonce; @@ -240,6 +344,38 @@ window.startApsV2 = function(options) { ).apsV2Records[id]?.messages ?? [], slotId, ); + const snapshots = (slotId: string) => + page.evaluate( + (id) => + ( + window as unknown as { + apsV2Records: Record< + string, + { + snapshots: Array<{ + message: string; + display: string; + existing: number; + }>; + } + >; + } + ).apsV2Records[id]?.snapshots ?? [], + slotId, + ); + const bootstrapConfiguration = (slotId: string) => + page.evaluate( + (id) => + ( + window as unknown as { + apsV2Records: Record< + string, + { bootstrapConfiguration: Record | null } + >; + } + ).apsV2Records[id]?.bootstrapConfiguration ?? null, + slotId, + ); await start("duplicate-success", "duplicate-success-bid"); await expect @@ -278,7 +414,61 @@ window.startApsV2 = function(options) { await page.waitForTimeout(150); expect(await messages("silent-case")).toHaveLength(2); - await start("nested-case", "nested-case-bid"); + await start("deferred-visibility", "deferred-visibility-bid"); + await expect + .poll(async () => + (await messages("deferred-visibility")).map( + (message) => message.message, + ), + ) + .toEqual(["TS APS Document Accepted", "TS APS Runner Loaded"]); + expect(await snapshots("deferred-visibility")).toEqual([ + { + message: "TS APS Document Accepted", + display: "none", + existing: 1, + }, + { + message: "TS APS Runner Loaded", + display: "none", + existing: 1, + }, + ]); + await expect(page.locator("#deferred-visibility > iframe")).toHaveCSS( + "display", + "none", + ); + await page + .frameLocator("#deferred-visibility > iframe") + .frameLocator('iframe[title="Ad content"]') + .locator("body") + .evaluate(() => + ( + window as unknown as { + __fictionalApsResolve?: () => void; + } + ).__fictionalApsResolve?.(), + ); + await expect + .poll(async () => + (await messages("deferred-visibility")).map( + (message) => message.message, + ), + ) + .toEqual([ + "TS APS Document Accepted", + "TS APS Runner Loaded", + "TS APS Render Completed", + ]); + await expect(page.locator("#deferred-visibility > iframe")).toBeVisible(); + await expect(page.locator("#deferred-visibility .existing")).toHaveCount(0); + + const nestedRendererNonce = await start( + "nested-case", + "nested-case-bid", + {}, + true, + ); await expect .poll(async () => await messages("nested-case")) .toContainEqual( @@ -286,7 +476,257 @@ window.startApsV2 = function(options) { message: "TS APS Render Completed", }), ); + const nestedOuterFrame = page.locator("#nested-case > iframe"); + const nestedOuter = page.frameLocator("#nested-case > iframe"); + const nestedInnerFrame = nestedOuter.locator( + 'body > iframe[title="Ad content"]', + ); + const nestedInner = nestedOuter.frameLocator( + 'body > iframe[title="Ad content"]', + ); + const nestedCreativeFrame = nestedInner.locator( + 'body > iframe[data-fictional-creative="nested"]', + ); + const nestedCreative = nestedInner.frameLocator( + 'body > iframe[data-fictional-creative="nested"]', + ); + await expect(nestedOuterFrame).toHaveCount(1); + await expect(nestedInnerFrame).toHaveCount(1); + await expect(nestedCreativeFrame).toHaveCount(1); + await expect(nestedOuterFrame).toHaveAttribute( + "sandbox", + PERMANENT_SANDBOX, + ); + await expect(nestedInnerFrame).toHaveAttribute( + "sandbox", + PERMANENT_SANDBOX, + ); + await expect(nestedCreativeFrame).toHaveAttribute( + "sandbox", + "allow-scripts", + ); + expect( + await nestedOuter.locator("html").evaluate(() => location.origin), + ).toBe("null"); + expect( + await nestedInner.locator("html").evaluate(() => location.origin), + ).toBe("null"); + expect( + await nestedCreative.locator("html").evaluate(() => location.origin), + ).toBe("null"); + for (const frame of [nestedOuter, nestedInner, nestedCreative]) { + expect( + await frame.locator("html").evaluate(() => { + try { + return window.top?.document === document; + } catch { + return false; + } + }), + ).toBe(false); + } + await expect( + nestedOuter.locator('meta[http-equiv="Content-Security-Policy"]'), + ).toHaveAttribute( + "content", + containerCsp("https://creative.example", true), + ); + await expect( + nestedInner.locator('meta[http-equiv="Content-Security-Policy"]'), + ).toHaveAttribute( + "content", + containerCsp("https://creative.example", false), + ); + const nestedBootstrapNonce = `b1_${"nested-case".padEnd(22, "b").slice(0, 22)}`; + expect(nestedRendererNonce).not.toBe(nestedBootstrapNonce); + await expect(nestedOuterFrame).toHaveAttribute( + "src", + `${APS_TEST_RENDERER_URL}#${nestedBootstrapNonce}`, + ); + expect(await nestedInnerFrame.getAttribute("src")).toContain( + `#${nestedRendererNonce}`, + ); + for (const html of [ + await nestedOuter + .locator("html") + .evaluate((element) => element.outerHTML), + await nestedInner + .locator("html") + .evaluate((element) => element.outerHTML), + ]) { + expect(html).not.toContain("nested-case-bid"); + expect(html).not.toContain("example-account-id"); + expect(html).not.toContain("fictional-nested-case-creative"); + } + expect( + await page.locator("#nested-case").evaluate((slot) => ({ + iframeAncestor: slot.parentElement?.closest("iframe") !== null, + gamAncestor: + slot.parentElement?.closest( + "[data-google-query-id],[data-google-container-id]", + ) !== null, + safeFrameAncestor: + slot.parentElement?.closest('[id*="safeframe" i]') !== null, + })), + ).toEqual({ + iframeAncestor: false, + gamAncestor: false, + safeFrameAncestor: false, + }); + expect(await bootstrapConfiguration("nested-case")).toEqual({ + message: "TS APS Bootstrap Configure", + version: 2, + bootstrapNonce: nestedBootstrapNonce, + rendererNonce: nestedRendererNonce, + creativeOrigin: "https://creative.example", + tagType: "iframe", + }); + + await start("creative-script", "creative-script-bid", { + creativeUrl: "https://creative.example/script-allowed.js", + tagType: "script", + }); + await expect + .poll(async () => await messages("creative-script")) + .toContainEqual( + expect.objectContaining({ message: "TS APS Render Completed" }), + ); + const scriptInner = page + .frameLocator("#creative-script > iframe") + .frameLocator('iframe[title="Ad content"]'); + await expect( + page + .frameLocator("#creative-script > iframe") + .locator('meta[http-equiv="Content-Security-Policy"]'), + ).toHaveAttribute( + "content", + containerCsp("https://creative.example", true, true), + ); + await expect( + scriptInner.locator('meta[http-equiv="Content-Security-Policy"]'), + ).toHaveAttribute( + "content", + containerCsp("https://creative.example", false, true), + ); + expect( + await scriptInner.locator("html").evaluate(() => ({ + allowed: document.documentElement.dataset.scriptCreative, + unrelated: document.documentElement.dataset.otherScript, + })), + ).toEqual({ allowed: "executed", unrelated: undefined }); + + await start("creative-redirect", "creative-redirect-bid", { + creativeUrl: "https://creative.example/script-redirect.js", + tagType: "script", + }); + await expect + .poll(async () => await messages("creative-redirect")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Failed", + reason: "runner_failed", + }), + ); + const redirectInner = page + .frameLocator("#creative-redirect > iframe") + .frameLocator('iframe[title="Ad content"]'); + expect( + await redirectInner + .locator("html") + .evaluate(() => document.documentElement.dataset.redirectedScript), + ).toBeUndefined(); + for (const { slotId, bidId, width, height } of [ + { + slotId: "creative-boundary-min", + bidId: "creative-min", + width: 1, + height: 1, + }, + { + slotId: "creative-boundary-max", + bidId: "creative-max", + width: 4096, + height: 4096, + }, + ]) { + await start(slotId, bidId, { + creativeUrl: "https://creative.example/iframe-policy", + tagType: "iframe", + width, + height, + }); + await expect + .poll(async () => await messages(slotId)) + .toContainEqual( + expect.objectContaining({ message: "TS APS Render Completed" }), + ); + const outerFrame = page.locator(`#${slotId} > iframe`); + const outer = page.frameLocator(`#${slotId} > iframe`); + const innerFrame = outer.locator('iframe[title="Ad content"]'); + const inner = outer.frameLocator('iframe[title="Ad content"]'); + const creativeFrame = inner.locator( + 'iframe[data-fictional-creative="iframe"]', + ); + const creative = inner.frameLocator( + 'iframe[data-fictional-creative="iframe"]', + ); + for (const frame of [outerFrame, creativeFrame]) { + await expect(frame).toHaveAttribute("width", String(width)); + await expect(frame).toHaveAttribute("height", String(height)); + } + for (const frame of [outerFrame, innerFrame, creativeFrame]) { + expect( + await frame.evaluate((element) => { + const value = element as HTMLElement; + const box = value.getBoundingClientRect(); + const computed = getComputedStyle(value); + return { + width: box.width, + height: box.height, + clientWidth: value.clientWidth, + clientHeight: value.clientHeight, + margin: computed.margin, + overflow: computed.overflow, + }; + }), + ).toEqual({ + width, + height, + clientWidth: width, + clientHeight: height, + margin: "0px", + overflow: "hidden", + }); + } + for (const frame of [outer, inner, creative]) { + expect( + await frame.locator("body").evaluate((body) => { + const computed = getComputedStyle(body); + return { + clientWidth: body.clientWidth, + clientHeight: body.clientHeight, + scrollWidth: body.scrollWidth, + scrollHeight: body.scrollHeight, + margin: computed.margin, + overflow: computed.overflow, + }; + }), + ).toEqual({ + clientWidth: width, + clientHeight: height, + scrollWidth: width, + scrollHeight: height, + margin: "0px", + overflow: "hidden", + }); + } + expect( + await creative + .locator("html") + .evaluate(() => document.documentElement.dataset.externalScript), + ).toBeUndefined(); + } const requestsBeforeInvalid = runnerRequests; await start("invalid-case", "invalid-case-bid", { unexpected: true, diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index f2b53a1ea..6b513c199 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -194,6 +194,11 @@ For initial navigation and page-bids, Trusted Server publishes the same descript For client-side `trustedServer` adapter auctions, Prebid generates its own `hb_adid`. Trusted Server binds that generated ID to the validated APS descriptor in a bounded, expiring browser registry before GAM refresh. The bridge verifies that the requesting Universal Creative iframe belongs to the same ad unit, consumes the capability once, and passes the APS bid ID separately to the Amazon runner. +The protected browser conformance environment uses Prebid Universal Creative +`1.17.2`. Configure that release in GAM outside this repository. Trusted Server +does not vendor, proxy, pin, archive, or serve PUC bytes; the version is test and +operator metadata for the externally hosted creative only. + These paths do not fetch PBS Cache, fire generic APS win/billing beacons, or call `apstag.setDisplayBids()` for the Trusted Server winner. Publisher-owned native APS objects are otherwise left untouched. ## Publisher CSP @@ -232,6 +237,7 @@ This release is a direct protocol cutover: 7. Disable publisher-native APS demand for the Trusted Server test cohort. There is no legacy runtime switch. Roll back by disabling `[integrations.aps]`, restoring native APS for the cohort, or deploying the prior binary. +Disabling `[integrations.aps]` is also the containment stop for renderer or live-runner incidents: it removes APS auction and browser materialization ownership without adding a cached or vendored fallback. ## Rollout From f277eebeac5fc7d3318a53f48748a27b208de575 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:13:20 -0700 Subject: [PATCH 528/844] Fix APS renderer materialization boundaries --- .../src/integrations/aps.rs | 4 +- .../generated/aps_renderer_bootstrap_v2.html | 2 +- .../browser/tests/shared/aps-renderer.spec.ts | 2 +- .../tests/aps_runner_proxy.rs | 2 +- crates/trusted-server-js/lib/build-all.mjs | 4 +- .../lib/src/first_display/render_bridge.ts | 25 ++-- .../lib/src/shared/aps_documents.ts | 9 +- .../lib/test/build/release-v1.test.mjs | 22 ++++ .../test/contract/aps-renderer-es5.test.mjs | 110 +++++++++++++++++- ...s-render-fix-and-tsjs-resilience-design.md | 11 +- 10 files changed, 173 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 9dd1c772f..fea312760 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -68,7 +68,7 @@ pub const APS_RUNNER_BLOCKING_READ_TIMEOUT: Duration = Duration::from_millis(250 pub fn is_aps_family_path(path: &str) -> bool { path == "/integrations/aps" || path.starts_with("/integrations/aps/") } -const APS_RENDERER_V2_CSP: &str = "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';"; +const APS_RENDERER_V2_CSP: &str = "default-src 'none'; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' http: https:; connect-src http: https:; frame-src data: https:; img-src data: blob: http: https:; media-src blob: http: https:; style-src 'unsafe-inline' http: https:; font-src data: http: https:; worker-src blob: http: https:; frame-ancestors 'self'; form-action https:;"; // This document is served with an immutable v2 URL. Any semantic change must // ship at a new versioned route so cached v2 bytes retain their contract. @@ -3393,7 +3393,7 @@ mod tests { assert!(!response.headers().contains_key("x-frame-options")); assert_eq!( APS_RENDERER_V2_CSP, - "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';" + "default-src 'none'; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' http: https:; connect-src http: https:; frame-src data: https:; img-src data: blob: http: https:; media-src blob: http: https:; style-src 'unsafe-inline' http: https:; font-src data: http: https:; worker-src blob: http: https:; frame-ancestors 'self'; form-action https:;" ); assert_eq!( APS_RENDERER_SANDBOX, diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html index 6cf1e995a..3b9e3fc9a 100644 --- a/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html +++ b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html @@ -6,7 +6,7 @@ var MAX_CONFIGURATION_BYTES=16384; var MAX_CONTAINER_URL_BYTES=196663; var DATA_PREFIX='data:text/html;charset=utf-8,'; -var INNER_TEMPLATE="\n\n\n\n\n`; const comparisonSetup = ``; - if (resources.artifactModel === "legacy-main-v1") { + if (resources.artifactModel === "legacy-rc-v1") { const release = manualDisplay ? "" : "window.__fixtureGpt.release();"; - return `${comparisonSetup}${selectedTag}
`; + return `${comparisonSetup}${selectedTag}
`; } if (!resources.controllerDocument) throw new Error("generated controller document is unavailable"); @@ -508,8 +627,11 @@ function fixtureDocument( return released; } -async function installFixtureGpt(context: BrowserContext): Promise { - await context.addInitScript(() => { +async function installFixtureGpt( + context: BrowserContext, + performanceCase: PerformanceCase, +): Promise { + await context.addInitScript((selectedCase) => { type Listener = (event: Record) => void; type Slot = { addService(service: object): Slot; @@ -567,6 +689,86 @@ async function installFixtureGpt(context: BrowserContext): Promise { for (const listener of listeners.get(name) ?? []) listener({ slot, ...facts }); }; + const startFictionalPuc = (slot: Slot): void => { + const adId = slot.getTargeting("hb_adid")[0]; + const root = document.getElementById(slot.getSlotElementId()); + if (!adId || !root) + throw new Error("fictional APS PUC input is unavailable"); + const frame = document.createElement("iframe"); + frame.dataset.fictionalPuc = ""; + frame.srcdoc = `\n", + ); + validateRealGamArtifact(roots.realGam, expectedRealGam); + const manifestPath = path.join(traceRoot, "evidence-manifest.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + fs.writeFileSync( + manifestPath, + `${JSON.stringify({ ...manifest, conclusion: "failure" })}\n`, + ); + assert.throws( + () => validateRealGamArtifact(roots.realGam, expectedRealGam), + /successful conclusion/u, + ); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); fs.writeFileSync( path.join(traceRoot, "secret.txt"), environment.TS_REAL_GAM_AUTH_HEADER, @@ -274,8 +382,38 @@ function selfTest() { const command = process.argv[2]; if (command === "self-test") selfTest(); -else if (!execute(command)) { +else if (command === "validate-real-gam") { + const [ + downloadRoot, + evidenceId, + releaseId, + commitSha, + runId, + previousArtifactId, + ] = process.argv.slice(3); + if ( + !downloadRoot || + !evidenceId || + !releaseId || + !/^[0-9a-f]{40}$/u.test(commitSha ?? "") || + !/^[1-9][0-9]*$/u.test(runId ?? "") || + !previousArtifactId || + process.argv.length !== 9 + ) { + throw new Error( + "usage: aps-tsjs-evidence.mjs validate-real-gam ", + ); + } + validateRealGamArtifact(downloadRoot, { + evidenceId, + releaseId, + commitSha, + runId, + previousArtifactId, + }); + process.stdout.write("APS real-GAM evidence is valid\n"); +} else if (!execute(command)) { throw new Error( - "usage: aps-tsjs-evidence.mjs {write-quality|write-integration|write-real-gam|scrub-integration|scrub-real-gam|self-test}", + "usage: aps-tsjs-evidence.mjs {write-quality|write-integration|write-real-gam|scrub-integration|scrub-real-gam|validate-real-gam|self-test}", ); } diff --git a/scripts/ci/aps-tsjs-quality.sh b/scripts/ci/aps-tsjs-quality.sh index e64168259..813a4c6a9 100755 --- a/scripts/ci/aps-tsjs-quality.sh +++ b/scripts/ci/aps-tsjs-quality.sh @@ -42,6 +42,13 @@ run_quality_gates() { cp "$repository_root/crates/trusted-server-js/dist/tsjs-build-metrics-v1.json" "$evidence_root/" } +run_contract_tests() { + cd "$repository_root" + node --test scripts/ci/dispatch-aps-tsjs-gate.test.mjs + node scripts/ci/aps-tsjs-evidence.mjs self-test + node scripts/validate-tsjs-performance-evidence.mjs --self-test +} + case "${1:-}" in install) install_dependencies @@ -49,8 +56,11 @@ case "${1:-}" in run) run_quality_gates ;; + contracts) + run_contract_tests + ;; *) - printf 'usage: %s {install|run}\n' "$0" >&2 + printf 'usage: %s {install|run|contracts}\n' "$0" >&2 exit 64 ;; esac diff --git a/scripts/ci/dispatch-aps-tsjs-gate.mjs b/scripts/ci/dispatch-aps-tsjs-gate.mjs new file mode 100644 index 000000000..b29e2303e --- /dev/null +++ b/scripts/ci/dispatch-aps-tsjs-gate.mjs @@ -0,0 +1,402 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync } from "node:fs"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const SHA_PATTERN = /^[0-9a-f]{40}$/u; +const RELEASE_PATTERN = /^[0-9a-f]{64}$/u; +const TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u; +const RUN_LIST_FIELDS = "databaseId,displayTitle,headSha,createdAt"; +const WORKFLOWS = Object.freeze({ + performance: ".github/workflows/tsjs-performance-gate.yml", + "real-gam": ".github/workflows/aps-real-gam.yml", +}); + +function fail(message) { + throw new Error(`APS/TSJS dispatch refused: ${message}`); +} + +function exactSha(value, label) { + if (!SHA_PATTERN.test(value ?? "")) + fail(`${label} must be 40 lowercase hex characters`); + return value; +} + +function exactToken(value, label) { + if (!TOKEN_PATTERN.test(value ?? "")) fail(`${label} is invalid`); + return value; +} + +function exactRef(value) { + if ( + !REF_PATTERN.test(value ?? "") || + value.includes("..") || + value.includes("//") || + value.endsWith("/") || + value.endsWith(".lock") + ) { + fail("ref is invalid"); + } + return value; +} + +function defaultRunner(command, args, options = {}) { + return execFileSync(command, args, { + cwd: process.cwd(), + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + ...options, + }); +} + +function defaultSleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +export function createEvidenceId({ kind, mode, headSha, now, randomSuffix }) { + if (kind !== "performance" && kind !== "real-gam") + fail("gate kind is invalid"); + exactSha(headSha, "head SHA"); + if (!(now instanceof Date) || !Number.isFinite(now.valueOf())) + fail("dispatch time is invalid"); + if (!/^[0-9a-f]{8}$/u.test(randomSuffix ?? "")) + fail("random suffix is invalid"); + const timestamp = now + .toISOString() + .replace(/[-:]/gu, "") + .replace(/\.\d{3}Z$/u, "Z"); + const phase = kind === "performance" ? exactToken(mode, "mode") : "cutover"; + const evidenceId = `aps-tsjs-${kind}-${phase}-${headSha.slice(0, 12)}-${timestamp}-${randomSuffix}`; + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/u.test(evidenceId)) { + fail("generated evidence id is invalid"); + } + return evidenceId; +} + +function workflowRun(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return undefined; + if ( + !Number.isSafeInteger(value.databaseId) || + value.databaseId < 1 || + typeof value.displayTitle !== "string" || + typeof value.headSha !== "string" || + typeof value.createdAt !== "string" + ) { + return undefined; + } + return value; +} + +export function selectUniqueWorkflowRun(runs, expected) { + if (!Array.isArray(runs)) fail("GitHub run list must be an array"); + const matches = runs + .map(workflowRun) + .filter( + (run) => + run !== undefined && + run.headSha === expected.headSha && + run.displayTitle === expected.displayTitle, + ); + if (matches.length > 1) + fail("multiple workflow runs match the exact dispatch"); + const match = matches[0]; + if (!match) return undefined; + const createdAt = new Date(match.createdAt); + if ( + !Number.isFinite(createdAt.valueOf()) || + createdAt.valueOf() < expected.dispatchStartedAt.valueOf() + ) { + fail("stale workflow run predates the dispatch"); + } + return match; +} + +async function runCommand(runner, command, args, options) { + const output = await runner(command, args, options); + if (typeof output !== "string") fail(`${command} returned non-text output`); + return output; +} + +async function resolveLocalAndRemoteHead(ref, runner) { + const status = await runCommand(runner, "git", [ + "status", + "--porcelain=v1", + "--untracked-files=all", + ]); + if (status !== "") fail("worktree must be clean before dispatch"); + const headSha = exactSha( + (await runCommand(runner, "git", ["rev-parse", "HEAD"])).trim(), + "local HEAD", + ); + const remote = ( + await runCommand(runner, "git", [ + "ls-remote", + "--heads", + "origin", + `refs/heads/${ref}`, + ]) + ).trim(); + const rows = remote === "" ? [] : remote.split("\n"); + if (rows.length !== 1) fail("remote branch must resolve exactly once"); + const [remoteSha, remoteRef, ...extra] = rows[0].split(/\s+/u); + if ( + extra.length !== 0 || + remoteRef !== `refs/heads/${ref}` || + remoteSha !== headSha + ) { + fail("remote branch must resolve to local HEAD"); + } + return headSha; +} + +async function waitForWorkflowRun({ + workflow, + ref, + evidenceId, + headSha, + displayTitle, + dispatchStartedAt, + runner, + sleep, +}) { + for (let attempt = 0; attempt < 30; attempt += 1) { + const raw = await runCommand(runner, "gh", [ + "run", + "list", + "--workflow", + workflow, + "--branch", + ref, + "--event", + "workflow_dispatch", + "--limit", + "100", + "--json", + RUN_LIST_FIELDS, + ]); + let runs; + try { + runs = JSON.parse(raw); + } catch { + fail("GitHub run list returned invalid JSON"); + } + const match = selectUniqueWorkflowRun(runs, { + evidenceId, + headSha, + displayTitle, + dispatchStartedAt, + }); + if (match) return match; + if (attempt < 29) await sleep(2_000); + } + fail("zero workflow runs matched the exact dispatch"); +} + +export async function dispatchGate(input, dependencies = {}) { + const runner = dependencies.runner ?? defaultRunner; + const now = dependencies.now ?? (() => new Date()); + const randomSuffix = + dependencies.randomSuffix ?? (() => randomBytes(4).toString("hex")); + const sleep = dependencies.sleep ?? defaultSleep; + const pathExists = dependencies.pathExists ?? existsSync; + const kind = input.kind; + const workflow = WORKFLOWS[kind]; + if (!workflow) fail("gate kind is invalid"); + const ref = exactRef(input.ref); + const outputDir = input.outputDir; + if (typeof outputDir !== "string" || outputDir.length === 0) + fail("output directory is required"); + if (pathExists(outputDir)) fail("output directory must not already exist"); + + let mode; + let baseSha; + let previousArtifactId; + if (kind === "performance") { + mode = input.mode; + if (mode !== "preswitch" && mode !== "postswitch") + fail("performance mode is invalid"); + baseSha = exactSha(input.baseSha, "base SHA"); + } else { + previousArtifactId = exactToken( + input.previousArtifactId, + "previous artifact id", + ); + } + + const headSha = await resolveLocalAndRemoteHead(ref, runner); + let releaseId; + if (kind === "real-gam") { + releaseId = ( + await runCommand(runner, "npm", [ + "--prefix", + "crates/trusted-server-js/lib", + "run", + "--silent", + "print:release-id", + ]) + ).trim(); + if (!RELEASE_PATTERN.test(releaseId)) + fail("generated release id is invalid"); + } + + const observedDispatchTime = now(); + if ( + !(observedDispatchTime instanceof Date) || + !Number.isFinite(observedDispatchTime.valueOf()) + ) { + fail("dispatch time is invalid"); + } + const dispatchStartedAt = new Date( + Math.floor(observedDispatchTime.valueOf() / 1_000) * 1_000, + ); + const evidenceId = createEvidenceId({ + kind, + mode, + headSha, + now: dispatchStartedAt, + randomSuffix: randomSuffix(), + }); + const displayTitle = + kind === "performance" + ? `TSJS Performance Gate / ${evidenceId} / ${mode}` + : `APS real-GAM / ${evidenceId} / ${releaseId}`; + const fields = + kind === "performance" + ? [ + "-f", + `evidence_id=${evidenceId}`, + "-f", + `mode=${mode}`, + "-f", + `base_sha=${baseSha}`, + ] + : [ + "-f", + `evidence_id=${evidenceId}`, + "-f", + `release_id=${releaseId}`, + "-f", + `previous_artifact_id=${previousArtifactId}`, + ]; + await runCommand(runner, "gh", [ + "workflow", + "run", + workflow, + "--ref", + ref, + ...fields, + ]); + const run = await waitForWorkflowRun({ + workflow, + ref, + evidenceId, + headSha, + displayTitle, + dispatchStartedAt, + runner, + sleep, + }); + const runId = run.databaseId; + await runCommand(runner, "gh", [ + "run", + "watch", + String(runId), + "--exit-status", + ]); + const artifactName = + kind === "performance" + ? `tsjs-performance-${evidenceId}` + : `aps-real-gam-${runId}`; + await runCommand(runner, "gh", [ + "run", + "download", + String(runId), + "--name", + artifactName, + "--dir", + outputDir, + ]); + + if (kind === "performance") { + await runCommand(runner, "node", [ + "scripts/validate-tsjs-performance-evidence.mjs", + "--file", + `${outputDir}/tsjs-performance-${mode}.json`, + "--evidence-id", + evidenceId, + "--head-sha", + headSha, + "--base-sha", + baseSha, + "--mode", + mode, + ]); + } else { + await runCommand(runner, "node", [ + "scripts/ci/aps-tsjs-evidence.mjs", + "validate-real-gam", + outputDir, + evidenceId, + releaseId, + headSha, + String(runId), + previousArtifactId, + ]); + } + return { evidenceId, headSha, runId }; +} + +function parseCli(argv) { + const kind = argv[0]; + if (kind !== "performance" && kind !== "real-gam") + fail("first argument must be performance or real-gam"); + const values = new Map(); + for (let index = 1; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || value === undefined || values.has(name)) + fail("invalid CLI arguments"); + values.set(name, value); + } + const allowed = + kind === "performance" + ? new Set(["--ref", "--base-sha", "--mode", "--output-dir"]) + : new Set(["--ref", "--previous-artifact-id", "--output-dir"]); + if ( + values.size !== allowed.size || + [...values.keys()].some((name) => !allowed.has(name)) + ) { + fail("missing or unknown CLI arguments"); + } + return kind === "performance" + ? { + kind, + ref: values.get("--ref"), + baseSha: values.get("--base-sha"), + mode: values.get("--mode"), + outputDir: values.get("--output-dir"), + } + : { + kind, + ref: values.get("--ref"), + previousArtifactId: values.get("--previous-artifact-id"), + outputDir: values.get("--output-dir"), + }; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + const result = await dispatchGate(parseCli(process.argv.slice(2))); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + } +} diff --git a/scripts/ci/dispatch-aps-tsjs-gate.test.mjs b/scripts/ci/dispatch-aps-tsjs-gate.test.mjs new file mode 100644 index 000000000..1e1d57da0 --- /dev/null +++ b/scripts/ci/dispatch-aps-tsjs-gate.test.mjs @@ -0,0 +1,340 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createEvidenceId, + dispatchGate, + selectUniqueWorkflowRun, +} from "./dispatch-aps-tsjs-gate.mjs"; + +const HEAD = "a".repeat(40); +const BASE = "b".repeat(40); +const START = new Date("2026-08-20T12:00:00.000Z"); + +function fakeRunner(responses) { + const calls = []; + return { + calls, + async run(command, args, options = {}) { + calls.push({ command, args, options }); + const key = [command, ...args].join(" "); + const response = responses.get(key); + if (response instanceof Error) throw response; + if (response === undefined) return ""; + return typeof response === "function" ? response(calls) : response; + }, + }; +} + +test("evidence ids bind gate, mode, head, UTC time, and random suffix", () => { + const first = createEvidenceId({ + kind: "performance", + mode: "postswitch", + headSha: HEAD, + now: START, + randomSuffix: "0123abcd", + }); + const second = createEvidenceId({ + kind: "performance", + mode: "postswitch", + headSha: HEAD, + now: START, + randomSuffix: "fedcba98", + }); + assert.equal( + first, + "aps-tsjs-performance-postswitch-aaaaaaaaaaaa-20260820T120000Z-0123abcd", + ); + assert.notEqual(first, second); + assert.match(first, /^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/u); +}); + +test("run selection rejects stale, ambiguous, and wrong-head evidence", () => { + const expected = { + evidenceId: + "aps-tsjs-performance-postswitch-aaaaaaaaaaaa-20260820T120000Z-0123abcd", + headSha: HEAD, + displayTitle: + "TSJS Performance Gate / aps-tsjs-performance-postswitch-aaaaaaaaaaaa-20260820T120000Z-0123abcd / postswitch", + dispatchStartedAt: START, + }; + assert.equal( + selectUniqueWorkflowRun( + [ + { + databaseId: 1, + displayTitle: "unrelated", + headSha: HEAD, + createdAt: START.toISOString(), + }, + ], + expected, + ), + undefined, + ); + assert.equal( + selectUniqueWorkflowRun( + [ + { + databaseId: 2, + displayTitle: expected.displayTitle, + headSha: BASE, + createdAt: START.toISOString(), + }, + ], + expected, + ), + undefined, + ); + assert.equal( + selectUniqueWorkflowRun( + [ + { + databaseId: 22, + displayTitle: `${expected.displayTitle}-not-exact`, + headSha: HEAD, + createdAt: START.toISOString(), + }, + ], + expected, + ), + undefined, + ); + assert.throws( + () => + selectUniqueWorkflowRun( + [ + { + databaseId: 3, + displayTitle: expected.displayTitle, + headSha: HEAD, + createdAt: "2026-08-20T11:59:59.999Z", + }, + ], + expected, + ), + /stale/u, + ); + assert.throws( + () => + selectUniqueWorkflowRun( + [4, 5].map((databaseId) => ({ + databaseId, + displayTitle: expected.displayTitle, + headSha: HEAD, + createdAt: START.toISOString(), + })), + expected, + ), + /multiple/u, + ); +}); + +test("performance dispatch binds the remote head, exact base, artifact, and validator", async () => { + const ref = "feature/aps-tsjs-resilience-rc202608"; + const evidenceId = + "aps-tsjs-performance-postswitch-aaaaaaaaaaaa-20260820T120000Z-0123abcd"; + const run = { + databaseId: 42, + displayTitle: `TSJS Performance Gate / ${evidenceId} / postswitch`, + headSha: HEAD, + createdAt: START.toISOString(), + }; + const runner = fakeRunner( + new Map([ + ["git status --porcelain=v1 --untracked-files=all", ""], + ["git rev-parse HEAD", `${HEAD}\n`], + [ + `git ls-remote --heads origin refs/heads/${ref}`, + `${HEAD}\trefs/heads/${ref}\n`, + ], + [ + "gh run list --workflow .github/workflows/tsjs-performance-gate.yml --branch feature/aps-tsjs-resilience-rc202608 --event workflow_dispatch --limit 100 --json databaseId,displayTitle,headSha,createdAt", + JSON.stringify([run]), + ], + ]), + ); + + const result = await dispatchGate( + { + kind: "performance", + ref, + baseSha: BASE, + mode: "postswitch", + outputDir: "target/performance-evidence", + }, + { + runner: runner.run, + now: () => new Date("2026-08-20T12:00:00.999Z"), + randomSuffix: () => "0123abcd", + sleep: async () => {}, + pathExists: () => false, + }, + ); + + assert.deepEqual(result, { evidenceId, headSha: HEAD, runId: 42 }); + const commands = runner.calls.map(({ command, args }) => [command, ...args]); + assert.ok( + commands.some( + (command) => + command.join(" ") === + `gh workflow run .github/workflows/tsjs-performance-gate.yml --ref ${ref} -f evidence_id=${evidenceId} -f mode=postswitch -f base_sha=${BASE}`, + ), + ); + assert.ok( + commands.some( + (command) => + command.join(" ") === + `gh run download 42 --name tsjs-performance-${evidenceId} --dir target/performance-evidence`, + ), + ); + assert.ok( + commands.some( + (command) => + command.join(" ") === + `node scripts/validate-tsjs-performance-evidence.mjs --file target/performance-evidence/tsjs-performance-postswitch.json --evidence-id ${evidenceId} --head-sha ${HEAD} --base-sha ${BASE} --mode postswitch`, + ), + ); +}); + +test("remote-head mismatch fails before any workflow dispatch", async () => { + const ref = "feature/aps-tsjs-resilience-rc202608"; + const runner = fakeRunner( + new Map([ + ["git status --porcelain=v1 --untracked-files=all", ""], + ["git rev-parse HEAD", `${HEAD}\n`], + [ + `git ls-remote --heads origin refs/heads/${ref}`, + `${BASE}\trefs/heads/${ref}\n`, + ], + ]), + ); + await assert.rejects( + dispatchGate( + { + kind: "performance", + ref, + baseSha: BASE, + mode: "postswitch", + outputDir: "target/performance-evidence", + }, + { runner: runner.run, pathExists: () => false }, + ), + /remote branch must resolve to local HEAD/u, + ); + assert.equal( + runner.calls.some(({ command }) => command === "gh"), + false, + ); +}); + +test("zero matching workflow runs are rejected after bounded polling", async () => { + const ref = "feature/aps-tsjs-resilience-rc202608"; + const runner = fakeRunner( + new Map([ + ["git status --porcelain=v1 --untracked-files=all", ""], + ["git rev-parse HEAD", `${HEAD}\n`], + [ + `git ls-remote --heads origin refs/heads/${ref}`, + `${HEAD}\trefs/heads/${ref}\n`, + ], + [ + "gh run list --workflow .github/workflows/tsjs-performance-gate.yml --branch feature/aps-tsjs-resilience-rc202608 --event workflow_dispatch --limit 100 --json databaseId,displayTitle,headSha,createdAt", + "[]", + ], + ]), + ); + await assert.rejects( + dispatchGate( + { + kind: "performance", + ref, + baseSha: BASE, + mode: "postswitch", + outputDir: "target/performance-evidence", + }, + { + runner: runner.run, + now: () => START, + randomSuffix: () => "0123abcd", + sleep: async () => {}, + pathExists: () => false, + }, + ), + /zero workflow runs/u, + ); + assert.equal( + runner.calls.filter( + ({ command, args }) => + command === "gh" && args[0] === "run" && args[1] === "list", + ).length, + 30, + ); +}); + +test("real-GAM dispatch derives release id and validates the exact run artifact", async () => { + const ref = "feature/aps-tsjs-resilience-rc202608"; + const releaseId = "c".repeat(64); + const previousArtifactId = "rollback-artifact-123"; + const evidenceId = + "aps-tsjs-real-gam-cutover-aaaaaaaaaaaa-20260820T120000Z-0123abcd"; + const runner = fakeRunner( + new Map([ + ["git status --porcelain=v1 --untracked-files=all", ""], + ["git rev-parse HEAD", `${HEAD}\n`], + [ + `git ls-remote --heads origin refs/heads/${ref}`, + `${HEAD}\trefs/heads/${ref}\n`, + ], + [ + "npm --prefix crates/trusted-server-js/lib run --silent print:release-id", + `${releaseId}\n`, + ], + [ + "gh run list --workflow .github/workflows/aps-real-gam.yml --branch feature/aps-tsjs-resilience-rc202608 --event workflow_dispatch --limit 100 --json databaseId,displayTitle,headSha,createdAt", + JSON.stringify([ + { + databaseId: 77, + displayTitle: `APS real-GAM / ${evidenceId} / ${releaseId}`, + headSha: HEAD, + createdAt: START.toISOString(), + }, + ]), + ], + ]), + ); + + await dispatchGate( + { + kind: "real-gam", + ref, + previousArtifactId, + outputDir: "target/real-gam-evidence", + }, + { + runner: runner.run, + now: () => START, + randomSuffix: () => "0123abcd", + sleep: async () => {}, + pathExists: () => false, + }, + ); + const commands = runner.calls.map(({ command, args }) => + [command, ...args].join(" "), + ); + assert.ok( + commands.includes( + `gh workflow run .github/workflows/aps-real-gam.yml --ref ${ref} -f evidence_id=${evidenceId} -f release_id=${releaseId} -f previous_artifact_id=${previousArtifactId}`, + ), + ); + assert.ok( + commands.includes( + "gh run download 77 --name aps-real-gam-77 --dir target/real-gam-evidence", + ), + ); + assert.ok( + commands.includes( + `node scripts/ci/aps-tsjs-evidence.mjs validate-real-gam target/real-gam-evidence ${evidenceId} ${releaseId} ${HEAD} 77 ${previousArtifactId}`, + ), + ); +}); diff --git a/scripts/ci/tsjs-performance.sh b/scripts/ci/tsjs-performance.sh index 1fd7f9915..4475c4cbb 100755 --- a/scripts/ci/tsjs-performance.sh +++ b/scripts/ci/tsjs-performance.sh @@ -14,6 +14,9 @@ validate_inputs() { *) return 1 ;; esac [[ "${TSJS_EVIDENCE_ID:-}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$ ]] + [[ "${TSJS_PERF_HEAD_SHA:-}" =~ ^[0-9a-f]{40}$ ]] + [[ "${TSJS_PERF_BASE_SHA:-}" =~ ^[0-9a-f]{40}$ ]] + test "$(git -C "$repository_root" rev-parse HEAD)" = "$TSJS_PERF_HEAD_SHA" } verify_toolchains() { @@ -34,20 +37,22 @@ build_candidate() { node "$repository_root/scripts/validate-tsjs-performance-evidence.mjs" --self-test } -build_main() { +build_baseline() { test -n "${RUNNER_TEMP:-}" test -n "${GITHUB_ENV:-}" + base_sha="${TSJS_PERF_BASE_SHA:-}" + [[ "$base_sha" =~ ^[0-9a-f]{40}$ ]] cd "$repository_root" - git fetch origin main - main_sha="$(git rev-parse origin/main)" - main_root="$RUNNER_TEMP/tsjs-performance-main" - [[ "$main_sha" =~ ^[0-9a-f]{40}$ ]] - test "$(git cat-file -t "$main_sha")" = commit - git worktree add --detach "$main_root" "$main_sha" - npm --prefix "$main_root/crates/trusted-server-js/lib" ci - npm --prefix "$main_root/crates/trusted-server-js/lib" run build - printf 'TSJS_PERF_MAIN_SHA=%s\n' "$main_sha" >> "$GITHUB_ENV" - printf 'TSJS_PERF_MAIN_ROOT=%s\n' "$main_root" >> "$GITHUB_ENV" + git fetch origin refs/heads/rc/202608:refs/remotes/origin/rc/202608 + test "$(git cat-file -t "$base_sha")" = commit + git merge-base --is-ancestor "$base_sha" origin/rc/202608 + baseline_root="$RUNNER_TEMP/tsjs-performance-baseline" + test ! -e "$baseline_root" + git worktree add --detach "$baseline_root" "$base_sha" + npm --prefix "$baseline_root/crates/trusted-server-js/lib" ci + npm --prefix "$baseline_root/crates/trusted-server-js/lib" run build + printf 'TSJS_PERF_BASE_SHA=%s\n' "$base_sha" >> "$GITHUB_ENV" + printf 'TSJS_PERF_BASE_ROOT=%s\n' "$baseline_root" >> "$GITHUB_ENV" } install_browser() { @@ -71,13 +76,13 @@ validate_evidence() { test -n "${TSJS_PERF_OUTPUT:-}" test -n "${TSJS_EVIDENCE_ID:-}" test -n "${TSJS_PERF_HEAD_SHA:-}" - test -n "${TSJS_PERF_MAIN_SHA:-}" + test -n "${TSJS_PERF_BASE_SHA:-}" test -n "${TSJS_PERF_MODE:-}" node "$repository_root/scripts/validate-tsjs-performance-evidence.mjs" \ --file "$TSJS_PERF_OUTPUT" \ --evidence-id "$TSJS_EVIDENCE_ID" \ --head-sha "$TSJS_PERF_HEAD_SHA" \ - --main-sha "$TSJS_PERF_MAIN_SHA" \ + --base-sha "$TSJS_PERF_BASE_SHA" \ --mode "$TSJS_PERF_MODE" } @@ -91,8 +96,8 @@ case "${1:-}" in build-candidate) build_candidate ;; - build-main) - build_main + build-baseline) + build_baseline ;; install-browser) install_browser @@ -104,7 +109,7 @@ case "${1:-}" in validate_evidence ;; *) - printf 'usage: %s {validate-inputs|verify-toolchains|build-candidate|build-main|install-browser|run-sample|validate-evidence}\n' "$0" >&2 + printf 'usage: %s {validate-inputs|verify-toolchains|build-candidate|build-baseline|install-browser|run-sample|validate-evidence}\n' "$0" >&2 exit 64 ;; esac diff --git a/scripts/validate-tsjs-performance-evidence.mjs b/scripts/validate-tsjs-performance-evidence.mjs index b97bc6950..12e8df4f8 100644 --- a/scripts/validate-tsjs-performance-evidence.mjs +++ b/scripts/validate-tsjs-performance-evidence.mjs @@ -5,19 +5,19 @@ import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const EXPECTED = Object.freeze({ - schemaVersion: 5, + schemaVersion: 6, chromium: "145.0.7632.6", machineClass: "github-hosted:ubuntu-24.04", runnerImage: "ubuntu-24.04", - fixture: "tsjs-main-paired-network-v2", - controller: "generated-server-v1+production-main-v1", + fixture: "tsjs-baseline-paired-network-v3", + controller: "generated-server-v1+production-rc-v1", node: "v24.12.0", npm: "11.6.2", typescript: "6.0.3", warmupsPerVariant: 5, samplesPerVariant: 50, percentile: 90, - interleaving: "alternating-main-candidate", + interleaving: "alternating-baseline-candidate", networkProfile: Object.freeze({ mechanism: "cdp-Network.emulateNetworkConditions", appliedBeforeNavigation: true, @@ -34,6 +34,14 @@ const EXPECTED = Object.freeze({ "afterSpaNavigation", ]), heapHardCeilingBytes: 4 * 1024 * 1024, + aps: Object.freeze({ + actionCeilingMs: 900, + actionToCompletionCeilingMs: 1_500, + completionToPaintCeilingMs: 250, + totalToPaintCeilingMs: 2_500, + afterProtectedPaintHeapCeilingBytes: 3 * 1024 * 1024, + afterTakeoverQueueDrainHeapCeilingBytes: 3_932_160, + }), workflowName: "TSJS Performance Gate", workflowFile: ".github/workflows/tsjs-performance-gate.yml", }); @@ -87,7 +95,13 @@ function nearestRank(values, percentile) { return ordered[Math.ceil((percentile / 100) * ordered.length) - 1]; } -function validateReferenceTransfer(value, expectedSha, expectedModel, path) { +function validateReferenceTransfer( + value, + expectedSha, + expectedModel, + path, + firstDisplayMask = "0045", +) { exactKeys( value, ["sha", "artifactModel", "sources", "rawBytes", "gzipBytes", "brotliBytes"], @@ -97,7 +111,7 @@ function validateReferenceTransfer(value, expectedSha, expectedModel, path) { exactString(value.artifactModel, expectedModel, `${path}.artifactModel`); if (!Array.isArray(value.sources)) fail(`${path}.sources must be an array`); const expectedInlineEndpoints = - expectedModel === "legacy-main-v1" + expectedModel === "legacy-rc-v1" ? ["inline:legacy-boot", "inline:legacy-ad-init"] : ["inline:boot-controller"]; if (value.sources.length !== expectedInlineEndpoints.length + 1) { @@ -138,7 +152,7 @@ function validateReferenceTransfer(value, expectedSha, expectedModel, path) { } else if (source.delivery === "external") { const expectedExternalEndpoint = expectedModel === "release-v1" - ? `external:/static/tsjs=tsjs-first-display.min.js?m=0045&v=${source.sha256}` + ? `external:/static/tsjs=tsjs-first-display.min.js?m=${firstDisplayMask}&v=${source.sha256}` : `external:/static/tsjs=tsjs-unified.min.js?v=${source.sha256}`; if (source.semanticEndpoint !== expectedExternalEndpoint) { fail(`${sourcePath} has a mismatched external endpoint`); @@ -195,8 +209,8 @@ export function validateEvidence(evidence, expected) { } if (!/^[0-9a-f]{40}$/.test(expected.headSha ?? "")) fail("expected head SHA is invalid"); - if (!/^[0-9a-f]{40}$/.test(expected.mainSha ?? "")) - fail("expected main SHA is invalid"); + if (!/^[0-9a-f]{40}$/.test(expected.baseSha ?? "")) + fail("expected base SHA is invalid"); exactKeys( evidence, @@ -213,6 +227,7 @@ export function validateEvidence(evidence, expected) { "transfer", "heap", "requests", + "aps", "assertions", "provenance", "result", @@ -334,7 +349,7 @@ export function validateEvidence(evidence, expected) { const timing = evidence.performance.requestToFirstActionMs; exactKeys( timing, - ["main", "candidate", "percentile", "maximumRatio", "observedRatio"], + ["baseline", "candidate", "percentile", "maximumRatio", "observedRatio"], "performance timing", ); if (timing.percentile !== EXPECTED.percentile) @@ -342,20 +357,20 @@ export function validateEvidence(evidence, expected) { if (timing.maximumRatio !== EXPECTED.maximumRatio) fail("performance ratio limit drifted"); exactKeys( - timing.main, + timing.baseline, ["sha", "artifactModel", "selectedTransferBytes", "samples", "p90"], - "main performance timing", + "baseline performance timing", ); - exactString(timing.main.sha, expected.mainSha, "performance main SHA"); + exactString(timing.baseline.sha, expected.baseSha, "performance base SHA"); if ( - timing.main.artifactModel !== "legacy-main-v1" && - timing.main.artifactModel !== "release-v1" + timing.baseline.artifactModel !== "legacy-rc-v1" && + timing.baseline.artifactModel !== "release-v1" ) { - fail("performance main artifact model is invalid"); + fail("performance baseline artifact model is invalid"); } finiteNumber( - timing.main.selectedTransferBytes, - "main selected transfer bytes", + timing.baseline.selectedTransferBytes, + "baseline selected transfer bytes", { integer: true, minimum: 1 }, ); exactKeys( @@ -389,22 +404,22 @@ export function validateEvidence(evidence, expected) { } return variantP90; }; - const mainP90 = validateVariant(timing.main, "main"); + const baselineP90 = validateVariant(timing.baseline, "baseline"); const candidateP90 = validateVariant(timing.candidate, "candidate"); - if (mainP90 <= 0) fail("main performance p90 must be positive"); + if (baselineP90 <= 0) fail("baseline performance p90 must be positive"); const observedRatio = finiteNumber( timing.observedRatio, "performance observed ratio", ); - if (Math.abs(observedRatio - candidateP90 / mainP90) > Number.EPSILON) { + if (Math.abs(observedRatio - candidateP90 / baselineP90) > Number.EPSILON) { fail("performance observed ratio is inconsistent with the p90 values"); } - if (candidateP90 > mainP90 * EXPECTED.maximumRatio) + if (candidateP90 > baselineP90 * EXPECTED.maximumRatio) fail("candidate performance p90 exceeds the paired 10% limit"); exactKeys( evidence.transfer, - ["algorithm", "mainReferenceTransfer", "candidateReferenceTransfer"], + ["algorithm", "baselineReferenceTransfer", "candidateReferenceTransfer"], "transfer", ); exactString( @@ -412,11 +427,11 @@ export function validateEvidence(evidence, expected) { "semantic-tsjs-transfer-v1", "transfer.algorithm", ); - const mainTransfer = validateReferenceTransfer( - evidence.transfer.mainReferenceTransfer, - expected.mainSha, - timing.main.artifactModel, - "transfer.mainReferenceTransfer", + const baselineTransfer = validateReferenceTransfer( + evidence.transfer.baselineReferenceTransfer, + expected.baseSha, + timing.baseline.artifactModel, + "transfer.baselineReferenceTransfer", ); const candidateTransfer = validateReferenceTransfer( evidence.transfer.candidateReferenceTransfer, @@ -424,8 +439,10 @@ export function validateEvidence(evidence, expected) { timing.candidate.artifactModel, "transfer.candidateReferenceTransfer", ); - if (timing.main.selectedTransferBytes !== mainTransfer.externalRawBytes) { - fail("main selected transfer bytes disagree with semantic transfer"); + if ( + timing.baseline.selectedTransferBytes !== baselineTransfer.externalRawBytes + ) { + fail("baseline selected transfer bytes disagree with semantic transfer"); } if ( timing.candidate.selectedTransferBytes !== @@ -436,15 +453,15 @@ export function validateEvidence(evidence, expected) { for (const metric of ["rawBytes", "gzipBytes", "brotliBytes"]) { if ( evidence.transfer.candidateReferenceTransfer[metric] > - evidence.transfer.mainReferenceTransfer[metric] + evidence.transfer.baselineReferenceTransfer[metric] ) { - fail(`candidate reference ${metric} exceeds current main`); + fail(`candidate reference ${metric} exceeds the rc baseline`); } } exactKeys( evidence.heap, - ["collection", "hardCeilingBytes", "main", "candidate"], + ["collection", "hardCeilingBytes", "baseline", "candidate"], "heap", ); exactString( @@ -454,13 +471,13 @@ export function validateEvidence(evidence, expected) { ); if (evidence.heap.hardCeilingBytes !== EXPECTED.heapHardCeilingBytes) fail("heap hard ceiling drifted"); - exactKeys(evidence.heap.main, ["sha", "checkpoints"], "heap.main"); - exactString(evidence.heap.main.sha, expected.mainSha, "heap main SHA"); + exactKeys(evidence.heap.baseline, ["sha", "checkpoints"], "heap.baseline"); + exactString(evidence.heap.baseline.sha, expected.baseSha, "heap base SHA"); exactKeys(evidence.heap.candidate, ["checkpoints"], "heap.candidate"); exactKeys( - evidence.heap.main.checkpoints, + evidence.heap.baseline.checkpoints, EXPECTED.heapCheckpoints, - "heap.main.checkpoints", + "heap.baseline.checkpoints", ); exactKeys( evidence.heap.candidate.checkpoints, @@ -468,9 +485,9 @@ export function validateEvidence(evidence, expected) { "heap.candidate.checkpoints", ); for (const name of EXPECTED.heapCheckpoints) { - const mainUsedSize = finiteNumber( - evidence.heap.main.checkpoints[name], - `heap.main.checkpoints.${name}`, + const baselineUsedSize = finiteNumber( + evidence.heap.baseline.checkpoints[name], + `heap.baseline.checkpoints.${name}`, { integer: true, minimum: 1 }, ); const candidateUsedSize = finiteNumber( @@ -479,7 +496,7 @@ export function validateEvidence(evidence, expected) { { integer: true, minimum: 1 }, ); if ( - mainUsedSize > EXPECTED.heapHardCeilingBytes || + baselineUsedSize > EXPECTED.heapHardCeilingBytes || candidateUsedSize > EXPECTED.heapHardCeilingBytes ) { fail(`${name} retained heap exceeds the hard ceiling`); @@ -528,6 +545,265 @@ export function validateEvidence(evidence, expected) { "deferred headOfLineBlocking", ); + exactKeys( + evidence.aps, + ["marks", "performance", "transfer", "heap", "requests", "assertions"], + "aps", + ); + exactKeys( + evidence.aps.marks, + [ + "source", + "candidateBidsScript", + "candidateFirstDisplay", + "candidateFirstDisplayTerminal", + "candidateFirstDisplayPaint", + ], + "aps.marks", + ); + exactString( + evidence.aps.marks.source, + "performance-entry", + "aps.marks.source", + ); + for (const name of [ + "candidateBidsScript", + "candidateFirstDisplay", + "candidateFirstDisplayTerminal", + "candidateFirstDisplayPaint", + ]) { + boolean(evidence.aps.marks[name], true, `aps.marks.${name}`); + } + + exactKeys( + evidence.aps.performance, + ["firstAction", "actionToCompletion", "completionToPaint", "totalToPaint"], + "aps.performance", + ); + const apsFirstAction = evidence.aps.performance.firstAction; + exactKeys( + apsFirstAction, + [ + "baseline", + "candidate", + "percentile", + "maximumRatio", + "absoluteCeilingMs", + "observedRatio", + ], + "aps.performance.firstAction", + ); + if ( + apsFirstAction.percentile !== EXPECTED.percentile || + apsFirstAction.maximumRatio !== EXPECTED.maximumRatio || + apsFirstAction.absoluteCeilingMs !== EXPECTED.aps.actionCeilingMs + ) { + fail("APS first-action policy drifted"); + } + exactKeys( + apsFirstAction.baseline, + ["sha", "artifactModel", "selectedTransferBytes", "samples", "p90"], + "aps.performance.firstAction.baseline", + ); + exactString( + apsFirstAction.baseline.sha, + expected.baseSha, + "APS first-action base SHA", + ); + if ( + apsFirstAction.baseline.artifactModel !== "legacy-rc-v1" && + apsFirstAction.baseline.artifactModel !== "release-v1" + ) { + fail("APS baseline artifact model is invalid"); + } + finiteNumber( + apsFirstAction.baseline.selectedTransferBytes, + "APS baseline selected transfer bytes", + { integer: true, minimum: 1 }, + ); + exactKeys( + apsFirstAction.candidate, + ["artifactModel", "selectedTransferBytes", "samples", "p90"], + "aps.performance.firstAction.candidate", + ); + exactString( + apsFirstAction.candidate.artifactModel, + "release-v1", + "APS candidate artifact model", + ); + finiteNumber( + apsFirstAction.candidate.selectedTransferBytes, + "APS candidate selected transfer bytes", + { integer: true, minimum: 1 }, + ); + const apsBaselineActionP90 = validateVariant( + apsFirstAction.baseline, + "APS baseline first action", + ); + const apsCandidateActionP90 = validateVariant( + apsFirstAction.candidate, + "APS candidate first action", + ); + if (apsBaselineActionP90 <= 0) + fail("APS baseline first-action p90 must be positive"); + const apsObservedRatio = finiteNumber( + apsFirstAction.observedRatio, + "APS first-action observed ratio", + ); + if ( + Math.abs(apsObservedRatio - apsCandidateActionP90 / apsBaselineActionP90) > + Number.EPSILON + ) { + fail("APS first-action ratio is inconsistent"); + } + if (apsCandidateActionP90 > apsBaselineActionP90 * EXPECTED.maximumRatio) { + fail("APS candidate first-action p90 exceeds the paired 10% limit"); + } + if (apsCandidateActionP90 > EXPECTED.aps.actionCeilingMs) { + fail("APS candidate first-action p90 exceeds its absolute ceiling"); + } + + const validateApsInterval = (name, ceiling) => { + const interval = evidence.aps.performance[name]; + exactKeys( + interval, + ["samples", "p90", "ceilingMs"], + `aps.performance.${name}`, + ); + if (interval.ceilingMs !== ceiling) fail(`APS ${name} ceiling drifted`); + const intervalP90 = validateVariant(interval, `APS ${name}`); + if (intervalP90 > ceiling) fail(`APS ${name} p90 exceeds its ceiling`); + }; + validateApsInterval( + "actionToCompletion", + EXPECTED.aps.actionToCompletionCeilingMs, + ); + validateApsInterval( + "completionToPaint", + EXPECTED.aps.completionToPaintCeilingMs, + ); + validateApsInterval("totalToPaint", EXPECTED.aps.totalToPaintCeilingMs); + + exactKeys( + evidence.aps.transfer, + [ + "algorithm", + "maximumRatio", + "baselineReferenceTransfer", + "candidateReferenceTransfer", + ], + "aps.transfer", + ); + exactString( + evidence.aps.transfer.algorithm, + "semantic-tsjs-transfer-v1", + "aps.transfer.algorithm", + ); + if (evidence.aps.transfer.maximumRatio !== EXPECTED.maximumRatio) { + fail("APS transfer ratio limit drifted"); + } + const apsBaselineTransfer = validateReferenceTransfer( + evidence.aps.transfer.baselineReferenceTransfer, + expected.baseSha, + apsFirstAction.baseline.artifactModel, + "aps.transfer.baselineReferenceTransfer", + "0047", + ); + const apsCandidateTransfer = validateReferenceTransfer( + evidence.aps.transfer.candidateReferenceTransfer, + expected.headSha, + apsFirstAction.candidate.artifactModel, + "aps.transfer.candidateReferenceTransfer", + "0047", + ); + if ( + apsFirstAction.baseline.selectedTransferBytes !== + apsBaselineTransfer.externalRawBytes || + apsFirstAction.candidate.selectedTransferBytes !== + apsCandidateTransfer.externalRawBytes + ) { + fail("APS selected transfer bytes disagree with semantic transfer"); + } + for (const metric of ["rawBytes", "gzipBytes", "brotliBytes"]) { + if ( + evidence.aps.transfer.candidateReferenceTransfer[metric] > + evidence.aps.transfer.baselineReferenceTransfer[metric] * + EXPECTED.maximumRatio + ) { + fail(`APS candidate ${metric} transfer exceeds the paired 10% limit`); + } + } + + exactKeys( + evidence.aps.heap, + ["collection", "afterProtectedPaint", "afterTakeoverQueueDrain"], + "aps.heap", + ); + exactString( + evidence.aps.heap.collection, + "playwright-requestGC-then-immediate-cdp-getHeapUsage", + "aps.heap.collection", + ); + for (const [name, ceiling] of [ + ["afterProtectedPaint", EXPECTED.aps.afterProtectedPaintHeapCeilingBytes], + [ + "afterTakeoverQueueDrain", + EXPECTED.aps.afterTakeoverQueueDrainHeapCeilingBytes, + ], + ]) { + const checkpoint = evidence.aps.heap[name]; + exactKeys(checkpoint, ["usedSize", "ceilingBytes"], `aps.heap.${name}`); + if (checkpoint.ceilingBytes !== ceiling) + fail(`APS ${name} heap ceiling drifted`); + if ( + finiteNumber(checkpoint.usedSize, `aps.heap.${name}.usedSize`, { + integer: true, + minimum: 1, + }) > ceiling + ) { + fail(`APS ${name} retained heap exceeds its ceiling`); + } + } + + exactKeys(evidence.aps.requests, ["selected", "deferred"], "aps.requests"); + exactKeys(evidence.aps.requests.selected, ["count"], "aps.requests.selected"); + if (evidence.aps.requests.selected.count !== 1) + fail("APS selected request count must be one"); + exactKeys( + evidence.aps.requests.deferred, + [ + "count", + "requestBeforePaintCount", + "preloadBeforePaintCount", + "preparationBeforePaintCount", + "executionBeforePaintCount", + ], + "aps.requests.deferred", + ); + if (evidence.aps.requests.deferred.count !== 2) + fail("APS deferred module count must be two"); + for (const name of [ + "requestBeforePaintCount", + "preloadBeforePaintCount", + "preparationBeforePaintCount", + "executionBeforePaintCount", + ]) { + if (evidence.aps.requests.deferred[name] !== 0) { + fail(`APS deferred ${name} must be zero`); + } + } + exactKeys( + evidence.aps.assertions, + ["correctness", "loadOrder"], + "aps.assertions", + ); + boolean( + evidence.aps.assertions.correctness, + true, + "aps.assertions.correctness", + ); + boolean(evidence.aps.assertions.loadOrder, true, "aps.assertions.loadOrder"); + exactKeys(evidence.assertions, ["correctness", "loadOrder"], "assertions"); boolean(evidence.assertions.correctness, true, "assertions.correctness"); boolean(evidence.assertions.loadOrder, true, "assertions.loadOrder"); @@ -578,22 +854,22 @@ export function validateEvidence(evidence, expected) { function validFixture() { const evidenceId = "aps-tsjs-preswitch-12345678"; const headSha = "a".repeat(40); - const mainSha = "c".repeat(40); - const mainSamples = Array.from({ length: 50 }, () => 200); + const baseSha = "c".repeat(40); + const baselineSamples = Array.from({ length: 50 }, () => 200); const candidateSamples = Array.from({ length: 50 }, () => 210); return { - expected: { evidenceId, headSha, mainSha, mode: "preswitch" }, + expected: { evidenceId, headSha, baseSha, mode: "preswitch" }, evidence: { - schemaVersion: 5, + schemaVersion: 6, evidenceId, mode: "preswitch", headSha, environment: { chromium: "145.0.7632.6", - controller: "generated-server-v1+production-main-v1", + controller: "generated-server-v1+production-rc-v1", machineClass: "github-hosted:ubuntu-24.04", runnerImage: "ubuntu-24.04", - fixture: "tsjs-main-paired-network-v2", + fixture: "tsjs-baseline-paired-network-v3", node: "v24.12.0", npm: "11.6.2", typescript: "6.0.3", @@ -602,7 +878,7 @@ function validFixture() { warmupsPerVariant: 5, samplesPerVariant: 50, percentile: 90, - interleaving: "alternating-main-candidate", + interleaving: "alternating-baseline-candidate", }, networkProfile: { mechanism: "cdp-Network.emulateNetworkConditions", @@ -622,11 +898,11 @@ function validFixture() { }, performance: { requestToFirstActionMs: { - main: { - sha: mainSha, - artifactModel: "legacy-main-v1", + baseline: { + sha: baseSha, + artifactModel: "legacy-rc-v1", selectedTransferBytes: 80_000, - samples: mainSamples, + samples: baselineSamples, p90: 200, }, candidate: { @@ -642,9 +918,9 @@ function validFixture() { }, transfer: { algorithm: "semantic-tsjs-transfer-v1", - mainReferenceTransfer: { - sha: mainSha, - artifactModel: "legacy-main-v1", + baselineReferenceTransfer: { + sha: baseSha, + artifactModel: "legacy-rc-v1", sources: [ { semanticEndpoint: "inline:legacy-boot", @@ -704,8 +980,8 @@ function validFixture() { heap: { collection: "playwright-requestGC-then-immediate-cdp-getHeapUsage", hardCeilingBytes: 4 * 1024 * 1024, - main: { - sha: mainSha, + baseline: { + sha: baseSha, checkpoints: Object.fromEntries( EXPECTED.heapCheckpoints.map((name) => [name, 1_600_000]), ), @@ -728,6 +1004,135 @@ function validFixture() { headOfLineBlocking: false, }, }, + aps: { + marks: { + source: "performance-entry", + candidateBidsScript: true, + candidateFirstDisplay: true, + candidateFirstDisplayTerminal: true, + candidateFirstDisplayPaint: true, + }, + performance: { + firstAction: { + baseline: { + sha: baseSha, + artifactModel: "legacy-rc-v1", + selectedTransferBytes: 80_000, + samples: Array.from({ length: 50 }, () => 300), + p90: 300, + }, + candidate: { + artifactModel: "release-v1", + selectedTransferBytes: 79_500, + samples: Array.from({ length: 50 }, () => 310), + p90: 310, + }, + percentile: 90, + maximumRatio: 1.1, + absoluteCeilingMs: 900, + observedRatio: 310 / 300, + }, + actionToCompletion: { + samples: Array.from({ length: 50 }, () => 60), + p90: 60, + ceilingMs: 1_500, + }, + completionToPaint: { + samples: Array.from({ length: 50 }, () => 20), + p90: 20, + ceilingMs: 250, + }, + totalToPaint: { + samples: Array.from({ length: 50 }, () => 400), + p90: 400, + ceilingMs: 2_500, + }, + }, + transfer: { + algorithm: "semantic-tsjs-transfer-v1", + maximumRatio: 1.1, + baselineReferenceTransfer: { + sha: baseSha, + artifactModel: "legacy-rc-v1", + sources: [ + { + semanticEndpoint: "inline:legacy-boot", + delivery: "inline", + rawBytes: 1_000, + gzipBytes: 400, + brotliBytes: 300, + sha256: "6".repeat(64), + }, + { + semanticEndpoint: "inline:legacy-ad-init", + delivery: "inline", + rawBytes: 200, + gzipBytes: 100, + brotliBytes: 80, + sha256: "7".repeat(64), + }, + { + semanticEndpoint: `external:/static/tsjs=tsjs-unified.min.js?v=${"8".repeat(64)}`, + delivery: "external", + rawBytes: 80_000, + gzipBytes: 20_000, + brotliBytes: 15_000, + sha256: "8".repeat(64), + }, + ], + rawBytes: 81_200, + gzipBytes: 20_500, + brotliBytes: 15_380, + }, + candidateReferenceTransfer: { + sha: headSha, + artifactModel: "release-v1", + sources: [ + { + semanticEndpoint: "inline:boot-controller", + delivery: "inline", + rawBytes: 1_000, + gzipBytes: 400, + brotliBytes: 300, + sha256: "9".repeat(64), + }, + { + semanticEndpoint: `external:/static/tsjs=tsjs-first-display.min.js?m=0047&v=${"a".repeat(64)}`, + delivery: "external", + rawBytes: 79_500, + gzipBytes: 19_500, + brotliBytes: 14_500, + sha256: "a".repeat(64), + }, + ], + rawBytes: 80_500, + gzipBytes: 19_900, + brotliBytes: 14_800, + }, + }, + heap: { + collection: "playwright-requestGC-then-immediate-cdp-getHeapUsage", + afterProtectedPaint: { + usedSize: 2_000_000, + ceilingBytes: 3 * 1024 * 1024, + }, + afterTakeoverQueueDrain: { + usedSize: 2_500_000, + ceilingBytes: 3_932_160, + }, + }, + requests: { + selected: { count: 1 }, + deferred: { + count: 2, + requestBeforePaintCount: 0, + preloadBeforePaintCount: 0, + preparationBeforePaintCount: 0, + executionBeforePaintCount: 0, + }, + }, + assertions: { correctness: true, loadOrder: true }, + }, assertions: { correctness: true, loadOrder: true }, provenance: { workflowName: "TSJS Performance Gate", @@ -745,21 +1150,24 @@ function validFixture() { function runSelfTest() { const fixture = validFixture(); validateEvidence(fixture.evidence, fixture.expected); - const releaseMainFixture = structuredClone(fixture.evidence); - releaseMainFixture.performance.requestToFirstActionMs.main.artifactModel = + const releaseBaselineFixture = structuredClone(fixture.evidence); + releaseBaselineFixture.performance.requestToFirstActionMs.baseline.artifactModel = "release-v1"; - releaseMainFixture.transfer.mainReferenceTransfer.artifactModel = + releaseBaselineFixture.transfer.baselineReferenceTransfer.artifactModel = "release-v1"; const removedLegacyInit = - releaseMainFixture.transfer.mainReferenceTransfer.sources.splice(1, 1)[0]; - releaseMainFixture.transfer.mainReferenceTransfer.sources[0].semanticEndpoint = + releaseBaselineFixture.transfer.baselineReferenceTransfer.sources.splice( + 1, + 1, + )[0]; + releaseBaselineFixture.transfer.baselineReferenceTransfer.sources[0].semanticEndpoint = "inline:boot-controller"; - releaseMainFixture.transfer.mainReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=0045&v=${"3".repeat(64)}`; + releaseBaselineFixture.transfer.baselineReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=0045&v=${"3".repeat(64)}`; for (const metric of ["rawBytes", "gzipBytes", "brotliBytes"]) { - releaseMainFixture.transfer.mainReferenceTransfer[metric] -= + releaseBaselineFixture.transfer.baselineReferenceTransfer[metric] -= removedLegacyInit[metric]; } - validateEvidence(releaseMainFixture, fixture.expected); + validateEvidence(releaseBaselineFixture, fixture.expected); const mutations = [ ["evidence id", (value) => (value.evidenceId = "wrong-evidence")], ["head SHA", (value) => (value.headSha = "b".repeat(40))], @@ -788,17 +1196,19 @@ function runSelfTest() { (value.performance.requestToFirstActionMs.candidate.selectedTransferBytes = 0), ], [ - "main sample count", - (value) => value.performance.requestToFirstActionMs.main.samples.pop(), + "baseline sample count", + (value) => + value.performance.requestToFirstActionMs.baseline.samples.pop(), ], [ - "main transfer bytes", + "baseline transfer bytes", (value) => - (value.performance.requestToFirstActionMs.main.selectedTransferBytes = 0), + (value.performance.requestToFirstActionMs.baseline.selectedTransferBytes = 0), ], [ - "stale transfer main SHA", - (value) => (value.transfer.mainReferenceTransfer.sha = "b".repeat(40)), + "stale transfer base SHA", + (value) => + (value.transfer.baselineReferenceTransfer.sha = "b".repeat(40)), ], [ "stale transfer candidate SHA", @@ -849,7 +1259,9 @@ function runSelfTest() { (value) => { const transfer = value.transfer.candidateReferenceTransfer; const increase = - value.transfer.mainReferenceTransfer.rawBytes - transfer.rawBytes + 1; + value.transfer.baselineReferenceTransfer.rawBytes - + transfer.rawBytes + + 1; transfer.sources[1].rawBytes += increase; transfer.rawBytes += increase; value.performance.requestToFirstActionMs.candidate.selectedTransferBytes += @@ -861,7 +1273,7 @@ function runSelfTest() { (value) => { const transfer = value.transfer.candidateReferenceTransfer; const increase = - value.transfer.mainReferenceTransfer.gzipBytes - + value.transfer.baselineReferenceTransfer.gzipBytes - transfer.gzipBytes + 1; transfer.sources[1].gzipBytes += increase; @@ -873,7 +1285,7 @@ function runSelfTest() { (value) => { const transfer = value.transfer.candidateReferenceTransfer; const increase = - value.transfer.mainReferenceTransfer.brotliBytes - + value.transfer.baselineReferenceTransfer.brotliBytes - transfer.brotliBytes + 1; transfer.sources[1].brotliBytes += increase; @@ -901,21 +1313,23 @@ function runSelfTest() { (value.performance.requestToFirstActionMs.candidate.samples[0] = null), ], [ - "main SHA", + "base SHA", (value) => - (value.performance.requestToFirstActionMs.main.sha = "b".repeat(40)), + (value.performance.requestToFirstActionMs.baseline.sha = "b".repeat( + 40, + )), ], [ - "main artifact model", + "baseline artifact model", (value) => - (value.performance.requestToFirstActionMs.main.artifactModel = + (value.performance.requestToFirstActionMs.baseline.artifactModel = "unknown-v1"), ], [ "candidate artifact model", (value) => (value.performance.requestToFirstActionMs.candidate.artifactModel = - "legacy-main-v1"), + "legacy-rc-v1"), ], [ "observed ratio", @@ -924,7 +1338,7 @@ function runSelfTest() { [ "heap hard ceiling", (value) => { - value.heap.main.checkpoints.afterBoot = 4 * 1024 * 1024 + 1; + value.heap.baseline.checkpoints.afterBoot = 4 * 1024 * 1024 + 1; value.heap.candidate.checkpoints.afterBoot = 4 * 1024 * 1024 + 1; }, ], @@ -933,7 +1347,7 @@ function runSelfTest() { (value) => (value.heap.collection = "direct-cdp-collection"), ], ["retired heap ratio policy", (value) => (value.heap.maximumRatio = 1.1)], - ["heap main SHA", (value) => (value.heap.main.sha = "b".repeat(40))], + ["heap base SHA", (value) => (value.heap.baseline.sha = "b".repeat(40))], ["selected count", (value) => (value.requests.selected.count = 2)], ["deferred count", (value) => (value.requests.deferred.count = 1)], ["excess deferred count", (value) => (value.requests.deferred.count = 3)], @@ -959,6 +1373,74 @@ function runSelfTest() { (value) => (value.requests.deferred.independentlyTriggered = false), ], ["correctness", (value) => (value.assertions.correctness = false)], + [ + "APS real terminal mark", + (value) => (value.aps.marks.candidateFirstDisplayTerminal = false), + ], + [ + "APS paired action ratio", + (value) => { + value.aps.performance.firstAction.candidate.samples.fill(331); + value.aps.performance.firstAction.candidate.p90 = 331; + value.aps.performance.firstAction.observedRatio = 331 / 300; + }, + ], + [ + "APS action absolute ceiling", + (value) => { + value.aps.performance.firstAction.candidate.samples.fill(901); + value.aps.performance.firstAction.candidate.p90 = 901; + value.aps.performance.firstAction.observedRatio = 901 / 300; + }, + ], + [ + "APS completion ceiling", + (value) => { + value.aps.performance.actionToCompletion.samples.fill(1_501); + value.aps.performance.actionToCompletion.p90 = 1_501; + }, + ], + [ + "APS paint ceiling", + (value) => { + value.aps.performance.completionToPaint.samples.fill(251); + value.aps.performance.completionToPaint.p90 = 251; + }, + ], + [ + "APS total ceiling", + (value) => { + value.aps.performance.totalToPaint.samples.fill(2_501); + value.aps.performance.totalToPaint.p90 = 2_501; + }, + ], + [ + "APS transfer ratio", + (value) => { + const transfer = value.aps.transfer.candidateReferenceTransfer; + const limit = Math.floor( + value.aps.transfer.baselineReferenceTransfer.rawBytes * 1.1, + ); + const increase = limit - transfer.rawBytes + 1; + transfer.sources[1].rawBytes += increase; + transfer.rawBytes += increase; + value.aps.performance.firstAction.candidate.selectedTransferBytes += + increase; + }, + ], + [ + "APS protected-paint heap", + (value) => + (value.aps.heap.afterProtectedPaint.usedSize = 3 * 1024 * 1024 + 1), + ], + [ + "APS takeover heap", + (value) => (value.aps.heap.afterTakeoverQueueDrain.usedSize = 3_932_161), + ], + [ + "APS deferred execution before paint", + (value) => (value.aps.requests.deferred.executionBeforePaintCount = 1), + ], ["load order", (value) => (value.assertions.loadOrder = false)], ["incomplete", (value) => (value.result = "failed")], [ @@ -998,10 +1480,10 @@ function runSelfTest() { () => validateEvidence(fixture.evidence, { ...fixture.expected, - mainSha: undefined, + baseSha: undefined, }), /invalid TSJS performance evidence/u, - "missing current-main identity must make the relative comparison unavailable", + "missing exact-rc identity must make the relative comparison unavailable", ); const repositoryRoot = new URL("../", import.meta.url); const performanceTest = readFileSync( @@ -1053,12 +1535,12 @@ function runSelfTest() { ); assert.match( performanceTest, - /tsjs-first-display\.min\.js\?m=0045&v=/u, - "the release-v1 semantic transfer must measure the exact admitted reference agent", + /performanceCase === "aps" \? "0047" : "0045"/u, + "the release-v1 semantic transfer must measure the exact admitted GPT and APS agents", ); assert.match( generatorSource, - /tsjs_bootstrap_fragment_v1/u, + /tsjs_bootstrap_fixture_fragment_v1/u, "the production server fixture must emit the admitted first-display transport", ); assert.match( @@ -1083,7 +1565,7 @@ function runSelfTest() { ); assert.match( performanceTest, - /FIXTURE_ID = "tsjs-main-paired-network-v2"/u, + /FIXTURE_ID = "tsjs-baseline-paired-network-v3"/u, "the browser gate must identify the paired network-shaped fixture", ); assert.doesNotMatch( @@ -1104,8 +1586,8 @@ function runSelfTest() { ); assert.match( performanceTest, - /loadLegacyMainFixtureResources[\s\S]*tsjs-core\.js[\s\S]*tsjs-creative\.js[\s\S]*tsjs-gpt\.js/u, - "the browser gate must consume main's actual legacy core, creative, and GPT artifact shape", + /loadLegacyBaselineFixtureResources[\s\S]*tsjs-core\.js[\s\S]*tsjs-creative\.js[\s\S]*tsjs-gpt\.js/u, + "the browser gate must consume the rc base's actual legacy core, creative, and GPT artifact shape", ); assert.match( performanceTest, @@ -1114,13 +1596,13 @@ function runSelfTest() { ); assert.match( generatorSource, - /creative:[\s\S]*enabled: true/u, - "the generated candidate controller must enable main's default creative policy", + /CreativeBootConfigV1 \{[\s\S]*enabled: true/u, + "the generated candidate controller must enable the rc baseline's default creative policy", ); assert.match( performanceTest, - /loadMainFixtureResources[\s\S]*tsjs-release-v1\.json[\s\S]*loadReleaseFixtureResources[\s\S]*loadLegacyMainFixtureResources/u, - "the browser gate must detect main's actual legacy or release-v1 artifact shape", + /loadBaselineFixtureResources[\s\S]*tsjs-release-v1\.json[\s\S]*loadReleaseFixtureResources[\s\S]*loadLegacyBaselineFixtureResources/u, + "the browser gate must detect the rc base's actual legacy or release-v1 artifact shape", ); assert.match( performanceTest, @@ -1129,7 +1611,7 @@ function runSelfTest() { ); assert.match( performanceTest, - /mainReferenceTransfer:[\s\S]*mainResources\.referenceTransfer[\s\S]*candidateReferenceTransfer:[\s\S]*candidateResources\.referenceTransfer/u, + /baselineReferenceTransfer:[\s\S]*baselineResources\.referenceTransfer[\s\S]*candidateReferenceTransfer:[\s\S]*candidateResources\.referenceTransfer/u, "the evidence must record both exact semantic reference transfers", ); assert.match( @@ -1150,13 +1632,28 @@ function runSelfTest() { } assert.match( performanceTest, - /candidateResources\.referenceTransfer\[metric\][\s\S]*mainResources\.referenceTransfer\[metric\]/u, + /candidateResources\.referenceTransfer\[metric\][\s\S]*baselineResources\.referenceTransfer\[metric\]/u, "the browser gate must softly collect all evidence while enforcing every transfer encoding", ); assert.match( performanceWorkflowScript, - /git fetch origin main[\s\S]*main_sha="\$\(git rev-parse origin\/main\)"[\s\S]*TSJS_PERF_MAIN_SHA/u, - "the performance workflow script must resolve and export the exact current main SHA", + /git fetch origin refs\/heads\/rc\/202608:refs\/remotes\/origin\/rc\/202608[\s\S]*merge-base --is-ancestor "\$base_sha" origin\/rc\/202608[\s\S]*TSJS_PERF_BASE_SHA/u, + "the performance workflow script must validate and export the exact rc base SHA", + ); + assert.match( + performanceWorkflow, + /base_sha:[\s\S]*required: true/u, + "manual and called performance runs must require an exact base SHA", + ); + assert.match( + performanceWorkflow, + /TSJS_PERF_BASE_SHA: \$\{\{ github\.event_name == 'pull_request' && github\.event\.pull_request\.base\.sha \|\| inputs\.base_sha \}\}/u, + "PR performance runs must bind the exact pull-request base SHA", + ); + assert.doesNotMatch( + performanceWorkflowScript, + /origin\/main|TSJS_PERF_MAIN/u, + "the performance scripts must not use a moving main baseline", ); assert.match( performanceWorkflow, @@ -1208,7 +1705,7 @@ function runSelfTest() { assert.doesNotMatch( `${performanceWorkflow}\n${performanceWorkflowScript}`, /62421ee44c62f24534ea8782a46dfa5bfbcea950/u, - "the performance workflow must never build a frozen reference instead of current main", + "the performance workflow must never build a frozen reference instead of the exact rc base", ); assert.doesNotMatch( performanceTest, @@ -1315,7 +1812,7 @@ function runSelfTest() { } assert.match( performanceTest, - /const heapBrowser = await chromium\.launch\([\s\S]*collectHeapCheckpoints\([\s\S]*heapBrowser,[\s\S]*mainServer[\s\S]*collectHeapCheckpoints\([\s\S]*heapBrowser,[\s\S]*candidateServer/u, + /const heapBrowser = await chromium\.launch\([\s\S]*collectHeapCheckpoints\([\s\S]*heapBrowser,[\s\S]*baselineServer[\s\S]*collectHeapCheckpoints\([\s\S]*heapBrowser,[\s\S]*candidateServer/u, "paired retained-heap checkpoints must run in one fresh, explicitly closed Chromium process", ); assert.match( @@ -1331,12 +1828,12 @@ function runSelfTest() { assert.match( performanceTest, /history\.pushState\(\{\}, "", "\/fixture\/navigation"\)/u, - "the paired SPA heap checkpoint must change pathname so current main and candidate both observe the same navigation", + "the paired SPA heap checkpoint must change pathname so the rc base and candidate both observe the same navigation", ); assert.doesNotMatch( performanceTest, /history\.pushState\(\{\}, "", "\/fixture\?navigation=/u, - "the paired SPA heap checkpoint must not use a query-only transition ignored by current main", + "the paired SPA heap checkpoint must not use a query-only transition ignored by the rc base", ); assert.match( performanceTest, @@ -1433,7 +1930,7 @@ function runSelfTest() { assert.match( generalTestWorkflow, /test-typescript:[\s\S]*?uses: actions\/checkout@v4\n with:\n fetch-depth: 0/u, - "the current-main concept audit must receive the pinned baseline commit", + "the exact-rc concept audit must receive the pinned baseline commit", ); console.log( `TSJS performance evidence self-test passed (${mutations.length} mutations)`, @@ -1454,7 +1951,7 @@ function parseArguments(arguments_) { "--file", "--evidence-id", "--head-sha", - "--main-sha", + "--base-sha", "--mode", ]) { if (!values.has(name)) fail(`missing ${name}`); @@ -1480,7 +1977,7 @@ function main() { validateEvidence(evidence, { evidenceId: arguments_.get("--evidence-id"), headSha: arguments_.get("--head-sha"), - mainSha: arguments_.get("--main-sha"), + baseSha: arguments_.get("--base-sha"), mode: arguments_.get("--mode"), }); console.log("TSJS performance evidence is valid"); From 5ae3083cfcb258044360ed12ea45343be57d8ba0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:31:36 -0700 Subject: [PATCH 530/844] Enforce final TSJS cutover boundaries --- .../trusted-server-core/src/auction/types.rs | 2 +- .../trusted-server-core/src/html_processor.rs | 6 +- crates/trusted-server-core/src/publisher.rs | 4 +- .../lib/build-prebid-external.mjs | 21 ++- .../lib/eslint-rules/no-adtech-globals.js | 5 +- .../scripts/check-hard-cutover-absence.mjs | 139 +++++++++++++++++- .../lib/test/build-prebid-external.test.mjs | 22 +++ .../lib/test/build/release-v1.test.mjs | 67 +++++++++ .../test/eslint/no-adtech-globals.test.mjs | 20 ++- docs/guide/integrations/aps.md | 4 +- docs/guide/integrations/gpt-diagnostics.md | 6 +- 11 files changed, 280 insertions(+), 16 deletions(-) diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 271cac8ca..0c3335af3 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -955,7 +955,7 @@ pub struct Bid { /// Prebid Cache UUID for this bid. /// /// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response. - /// Used as `hb_adid` targeting value in `window.tsjs.bids`. `None` for + /// Used as the `hb_adid` value in the initial browser auction projection. `None` for /// non-PBS providers (e.g., APS) and PBS bids without Prebid Cache enabled. pub cache_id: Option, /// Prebid Cache host (e.g., `"openads.adsrvr.org"`). diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 3c3063369..4a3a13816 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -182,12 +182,12 @@ pub struct HtmlProcessorConfig { pub request_host: String, pub request_scheme: String, pub integrations: IntegrationRegistry, - /// Pre-computed ``. - /// Injected at `` open. `None` when no slots matched. + /// Pre-computed canonical browser-slot JSON used to build the immutable boot + /// auction projection. `None` when no slots matched. pub ad_slots_script: Option, /// Shared auction result — written by auction task before HTML processing begins. /// Handler reads this in `el.on_end_tag()` on the body element. - /// `None` means no auction ran; inject empty `tsjs.bids = {}` as fallback. + /// `None` means no auction ran; inject an empty boot auction projection. pub ad_bids_state: std::sync::Arc>>, /// Maximum bytes the post-processing accumulator may buffer before the /// processor aborts. Mirrors `publisher.max_buffered_body_bytes` so the diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 0f963503c..da67e0797 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1326,7 +1326,7 @@ pub struct OwnedProcessResponseParams { /// The streaming phase collects these and writes bids to `ad_bids_state` /// before processing the last body chunk, so `` injection sees live bids. pub(crate) dispatched_auction: Option, - /// Price granularity used to bucket bids when building `tsjs.bids`. + /// Price granularity used to bucket bids in the browser auction projection. pub(crate) price_granularity: PriceGranularity, /// Whether to omit Trusted Server's automatic `DataDome` client-side tag. pub(crate) suppress_datadome_client_side_tag: bool, @@ -3932,7 +3932,7 @@ pub async fn handle_publisher_request( // stamp. // 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, - // no per-user `tsjs.adSlots`/`tsjs.bids` are injected, so forcing private + // no per-navigation browser auction projection is injected, so forcing private // here would needlessly strip shared cacheability from ordinary publisher // HTML. Applies regardless of the auction *outcome* (empty bids still inject // per-user slot state). The separate EC-cookie cache net in the adapter's diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index f8a370d18..1a77b9b70 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -42,7 +42,25 @@ const LEGACY_RUNTIME_FLAG_PREFIX = ['__', 'tsjs', '_'].join(''); /** Refuse to publish an external Prebid artifact carrying a retired TSJS runtime flag. */ export function assertNoLegacyRuntimeFlags(bundleCode) { if (bundleCode.includes(LEGACY_RUNTIME_FLAG_PREFIX)) { - throw new Error('[build-prebid-external] Generated artifact contains a legacy TSJS runtime flag'); + throw new Error( + '[build-prebid-external] Generated artifact contains a legacy TSJS runtime flag' + ); + } +} + +const TS_OWNED_PREBID_ARTIFACT_MARKERS = [ + /\bTS (?:APS|ADM|Owner|Render Owner)\b/u, + /\/_ts\/(?:auction|page-bids)\b/u, + /\b(?:rendererReservationId|lifecycleTicket)\b/u, + /\btsjs\.(?:addAdUnits|diagnostics|requestAds)\b/u, +]; + +/** Keep the independently useful external bundle free of TS-owned behavior. */ +export function assertPurePrebidArtifact(bundleCode) { + if (TS_OWNED_PREBID_ARTIFACT_MARKERS.some((expression) => expression.test(bundleCode))) { + throw new Error( + '[build-prebid-external] Generated artifact contains TS-owned auction or render behavior' + ); } } @@ -404,6 +422,7 @@ async function buildExternalBundle(outDir, generatedModules, stamp) { throw new Error('[build-prebid-external] Artifact release sentinel remained after stamping'); } assertNoLegacyRuntimeFlags(finalBundle); + assertPurePrebidArtifact(finalBundle); const bundleBytes = Buffer.from(finalBundle, 'utf8'); const metadata = deriveBundleMetadata(bundleBytes); const finalPath = path.join(outDir, metadata.filename); 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 00e253512..07de1d026 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 @@ -2,8 +2,9 @@ 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. +// and function-returned roots (`getWin().googletag`). Exact adapter boundaries, +// restricted imports, bundle/source scans, and browser ownership tests remain the +// defense-in-depth layers for those exotic forms. function normalizeFilename(filename, rootDirectory) { const normalized = filename.replaceAll('\\', '/'); diff --git a/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs index 9629e4691..d1f490894 100644 --- a/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs +++ b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs @@ -6,15 +6,21 @@ const repositoryRoot = path.resolve(packageRoot, '../../..'); const extensions = new Set([ '.css', '.html', + '.integrity', '.js', '.json', + '.log', + '.map', '.md', '.mjs', '.rs', '.sh', + '.sha256', + '.sri', '.toml', '.ts', '.tsx', + '.txt', '.yaml', '.yml', ]); @@ -45,6 +51,9 @@ function lineNumber(source, offset) { const jsPackageFiles = collect(packageRoot); const shippedTsjsFiles = collect(path.join(packageRoot, 'src')); +const productionTsjsFiles = shippedTsjsFiles.filter( + (file) => !/(?:_test|\.test)\.[cm]?[jt]sx?$/u.test(file) +); const generatedTsjsFiles = collect(path.join(packageRoot, 'dist')); const currentGuideFiles = collect(path.join(repositoryRoot, 'docs/guide')); const browserTestFiles = collect( @@ -87,6 +96,115 @@ function token(...parts) { return parts.join(''); } +const retiredRuntimeFiles = new Set([ + 'src/composition/browser.ts', + 'src/composition/critical_transport.ts', + 'src/composition/index.ts', + 'src/core/bootstrap_controller.ts', +]); +const retiredCutoverExpressions = [ + [ + 'non-canonical APS renderer route', + /\/integrations\/aps\/renderer(?!\/v2(?![A-Za-z0-9_./-]))/gu, + ], + [ + 'rendererUrl in a v4 Prebid response', + /(?:message\s*:\s*['"]Prebid Response['"]|\[['"]message['"]\]\s*=\s*[^;\n]*prebidResponse)[\s\S]{0,512}(?:rendererVersion\s*:\s*['"]4['"]|\[['"]rendererVersion['"]\]\s*=)[\s\S]{0,512}\brendererUrl\b/gu, + ], + ['retired APS owner start payload', /['"]TS APS Start['"]/gu], + ['raw TSJS integration global', /__tsjs_[A-Za-z0-9_]+/gu], + ['retired bundle activation attribute', /data-ts-gam-attribution/gu], + [ + 'retired TSJS public surface', + /\b(?:LegacyTsjsApi|TsjsApiV1|apsPrebidRenderers|renderAllAdUnits|renderAdUnit|renderLog|renderSeq|registerContextProvider|collectContext|installGuards)\b|tsjs:adRendered/gu, + ], + [ + 'retired TSJS namespace member', + /\btsjs(?:\.|\?\.)(?:adSlots|bids|getConfig|gptDiagnostics|renders|setConfig)\b/gu, + ], + ['retired TSJS public version', /\btsjs(?:\.|\?\.)version\s*=\s*['"]0\.1\.0['"]/gu], + ['retired creative global', /\b(?:tscreative|tsCreativeConfig)\b/gu], +]; + +export function findCutoverTextViolations(file, source) { + const normalized = file + .replaceAll('\\', '/') + .replace(/^.*\/crates\/trusted-server-js\/lib\//u, ''); + const found = []; + if (retiredRuntimeFiles.has(normalized)) found.push('retired second runtime file'); + for (const [label, expression] of retiredCutoverExpressions) { + expression.lastIndex = 0; + if (expression.test(source)) found.push(label); + } + return found; +} + +const forbiddenVendorBasename = + /^(?:gpt|pubads_impl|prebid-creative|prebid-universal-creative(?:[._-].*)?)\.(?:[cm]?js|html)(?:\.(?:integrity|map|sha256|sri))?$/iu; +const vendorBodyExpressions = [ + /\/[*!]\s*(?:@license\s+)?[^\n]{0,160}\bPrebid Universal Creative\b/giu, + /\/[*!][^\n]{0,160}@license[^\n]{0,160}\bGoogle Publisher Tag\b/giu, + /\/[*!][^\n]{0,160}(?:Copyright|@license)[^\n]{0,160}\bAmazon Publisher Services\b/giu, + /\b(?:apsRunner|gpt|puc)(?:Digest|Integrity|Sha(?:256|384|512)|Sri|Checksum|Version)\b/giu, + /\b(?:digest|integrity|sha(?:256|384|512)|sri|checksum)\b[^\n]{0,160}\b(?:prebid-creative\.js|Google Publisher Tag|Prebid Universal Creative|Amazon Publisher Services)\b/giu, + /\b(?:prebid-creative\.js|Google Publisher Tag|Prebid Universal Creative|Amazon Publisher Services)\b[^\n]{0,160}\b(?:digest|integrity|sha(?:256|384|512)|sri|checksum)\b/giu, +]; + +export function findVendorBoundaryViolations(file, source) { + const normalized = file.replaceAll('\\', '/'); + const basename = path.posix.basename(normalized); + const found = []; + if (forbiddenVendorBasename.test(basename)) found.push('vendor distributable filename'); + for (const expression of vendorBodyExpressions) { + expression.lastIndex = 0; + if (expression.test(source)) found.push('stored vendor body, checksum, or version metadata'); + } + return found; +} + +for (const file of uniqueFiles([ + ...productionTsjsFiles, + ...generatedTsjsFiles, + ...currentGuideFiles, + ...productionRustFiles, +])) { + const source = fs.readFileSync(file, 'utf8'); + const productionSource = file.endsWith('.rs') + ? (source.split('\n#[cfg(test)]')[0] ?? source) + : source; + for (const label of findCutoverTextViolations(relative(file), productionSource)) { + violations.push(`${relative(file)}:1: ${label}`); + } +} + +const vendorBoundaryFiles = [ + ...productionTsjsFiles, + ...generatedTsjsFiles, + ...productionRustFiles, + ...collect(path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/fixtures')), + ...collect(path.join(repositoryRoot, 'crates/trusted-server-integration-tests/fixtures')), + ...collect(path.join(repositoryRoot, 'target/aps-tsjs-quality-evidence')), + ...collect(path.join(repositoryRoot, 'target/aps-tsjs-cutover-evidence')), + ...collect( + path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/real-gam-evidence') + ), + ...collect( + path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/playwright-report') + ), + ...collect( + path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/test-results') + ), +]; +for (const file of uniqueFiles(vendorBoundaryFiles)) { + const source = fs.readFileSync(file, 'utf8'); + const productionSource = file.endsWith('.rs') + ? (source.split('\n#[cfg(test)]')[0] ?? source) + : source; + for (const label of findVendorBoundaryViolations(relative(file), productionSource)) { + violations.push(`${relative(file)}:1: ${label}`); + } +} + const oldRuntimePrefix = token('__', 'tsjs', '_'); const oldCreativeGlobal = token('ts', 'creative'); const oldIntegrationConfigTransport = token('_', 'integration', 'Config'); @@ -230,6 +348,22 @@ for (const manifest of browserPackageManifests) { violations.push(`${manifest}:1: PUC package is vendored into the local harness`); } } +const realGamNetworkFile = path.join( + repositoryRoot, + 'crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts' +); +const realGamNetworkSource = fs.readFileSync(realGamNetworkFile, 'utf8'); +if (!/export const REAL_GAM_PUC_RELEASE = ['"]1\.17\.2['"];/u.test(realGamNetworkSource)) { + violations.push( + `${relative(realGamNetworkFile)}:1: protected conformance metadata must pin PUC 1.17.2` + ); +} +forbidSource( + realGamNetworkFile, + realGamNetworkSource, + 'PUC conformance metadata must not carry vendor bytes, URLs, or checksums', + /\bPUC_(?:ASSET|BODY|DIGEST|INTEGRITY|SCRIPT|SRI|URL|VERSION)\b/gu +); for (const manifest of [ 'crates/trusted-server-adapter-fastly/Cargo.toml', @@ -285,10 +419,13 @@ for (const [file, required] of requiredReplacements) { } } +const executedAsMain = + process.argv[1] !== undefined && + path.resolve(process.argv[1]) === path.resolve(import.meta.filename); if (violations.length > 0) { console.error(`Hard-cutover absence check failed (${violations.length} violations):`); for (const violation of violations.sort()) console.error(`- ${violation}`); process.exitCode = 1; -} else { +} else if (executedAsMain) { console.log('Hard-cutover absence check passed.'); } 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 0e8a7e89a..8176cf91c 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 @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest'; import { ARTIFACT_RELEASE_SENTINEL, assertNoLegacyRuntimeFlags, + assertPurePrebidArtifact, deriveBundleMetadata, main, parseArgs, @@ -26,6 +27,27 @@ describe('build-prebid-external metadata', () => { expect(() => assertNoLegacyRuntimeFlags('window.pbjs = { que: [] };')).not.toThrow(); }); + it('rejects Trusted Server auction, render, and PUC behavior in the external Prebid artifact', () => { + for (const marker of [ + 'TS APS Top Mount Started', + 'TS ADM Start', + 'TS Render Owner Register', + '/_ts/auction', + 'rendererReservationId', + 'lifecycleTicket', + 'tsjs.requestAds', + ]) { + expect(() => assertPurePrebidArtifact(`/* prebid */ ${marker}`)).toThrow( + /TS-owned auction or render behavior/ + ); + } + expect(() => + assertPurePrebidArtifact( + 'window.pbjs={que:[],cmd:[]};window.__prebidProtocol="Prebid Request";Object.defineProperty(window.pbjs,"__trustedServerArtifactV1",{});' + ) + ).not.toThrow(); + }); + it('derives filename, sha256, and SRI from exact bundle bytes', () => { const bundleBytes = Buffer.from('console.log("trusted prebid");\n', 'utf8'); const sha256 = crypto.createHash('sha256').update(bundleBytes).digest('hex'); 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 1350cc077..4737842eb 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 @@ -20,6 +20,10 @@ import { } from '../../scripts/check-bundle-budgets.mjs'; import * as bundleBudgets from '../../scripts/check-bundle-budgets.mjs'; import * as bundleMetrics from '../../scripts/bundle-metrics.mjs'; +import { + findCutoverTextViolations, + findVendorBoundaryViolations, +} from '../../scripts/check-hard-cutover-absence.mjs'; const testDirectory = path.dirname(fileURLToPath(import.meta.url)); const libDirectory = path.resolve(testDirectory, '../..'); @@ -1978,6 +1982,69 @@ test('hard-cutover absence is exposed once and enforced after both production bu assert.ok(absenceStep > externalPrebidStep, 'absence must run after both production builds'); }); +test('hard-cutover policy rejects every retired wire, runtime, and public surface', () => { + const retired = [ + ['src/legacy.ts', '"/integrations/aps/renderer"'], + [ + 'src/legacy.ts', + "JSON.stringify({message:'Prebid Response',rendererVersion:'4',rendererUrl})", + ], + ['src/legacy.ts', "port.postMessage({message:'TS APS Start'})"], + ['src/legacy.ts', 'window.__tsjs_gpt_enabled = true'], + ['src/legacy.ts', 'script.setAttribute("data-ts-gam-attribution", "1")'], + ['src/legacy.ts', 'interface TsjsApiV1 {}'], + ['src/legacy.ts', 'tsjs.renderAdUnit("slot")'], + ['src/legacy.ts', 'tsjs.setConfig({debug: true})'], + ['src/legacy.ts', 'const value = tsjs.getConfig()'], + ['src/legacy.ts', 'const slots = tsjs.adSlots'], + ['src/legacy.ts', 'const trace = tsjs.renders'], + ['src/legacy.ts', 'const diagnostics = tsjs.gptDiagnostics'], + ['src/legacy.ts', 'tsjs.version = "0.1.0"'], + ['src/legacy.ts', 'window.dispatchEvent(new Event("tsjs:adRendered"))'], + ['src/composition/browser.ts', 'export function createBrowserRuntime() {}'], + ]; + for (const [file, source] of retired) { + assert.ok( + findCutoverTextViolations(file, source).length > 0, + `retired cutover surface should be rejected: ${source}` + ); + } +}); + +test('vendor boundary rejects APS, GPT, and PUC artifacts but permits conformance metadata', () => { + const vendored = [ + ['fixtures/prebid-creative.js', 'window._aps = new Map();'], + ['fixtures/gpt.js', 'window.googletag = window.googletag || {};'], + ['fixtures/gpt.js.map', '{"sources":["gpt.js"]}'], + ['fixtures/gpt.js.sha256', 'deadbeef'], + ['fixtures/prebid-universal-creative-1.17.2.js', 'window.renderAd = function() {};'], + ['fixtures/renamed.js', '/*! Prebid Universal Creative v1.17.2 */'], + ['fixtures/renamed.js', '/*! @license Google Publisher Tag */'], + ['fixtures/renamed.js', '/*! Copyright Amazon Publisher Services */'], + ['fixtures/vendor.json', '{"pucIntegrity":"sha384-deadbeef"}'], + ]; + for (const [file, source] of vendored) { + assert.ok( + findVendorBoundaryViolations(file, source).length > 0, + `vendored upstream artifact should be rejected: ${file}` + ); + } + assert.deepEqual( + findVendorBoundaryViolations( + 'browser/helpers/gam-test-network.ts', + 'export const REAL_GAM_PUC_RELEASE = "1.17.2"; Object.freeze({pucRelease: REAL_GAM_PUC_RELEASE});' + ), + [] + ); + assert.deepEqual( + findVendorBoundaryViolations( + 'browser/fixtures/fictional-aps-runner.js', + '// Fictional hermetic APS runner fixture; not copied from APS.\nwindow._aps = new Map();' + ), + [] + ); +}); + test('registered integration dispatch selects post-switch evidence without changing the instrument', () => { const workflow = fs.readFileSync( path.join(repositoryRoot, '.github/workflows/integration-tests.yml'), 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 40b788091..396781a4d 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 @@ -278,8 +278,12 @@ test('restricted paths enforce dependency direction and exact target-file exempt (await restrictedMessages("import '../core/types';", 'src/services/new-service.ts')).length > 0 ); assert.ok( - (await restrictedMessages("import '../composition/runtime_transport';", 'src/kernel/new-runtime.ts')) - .length > 0 + ( + await restrictedMessages( + "import '../composition/runtime_transport';", + 'src/kernel/new-runtime.ts' + ) + ).length > 0 ); assert.ok( ( @@ -333,3 +337,15 @@ test('every current integration directory participates in cross-integration isol assert.deepEqual(actual, [...ARCHITECTURE_INTEGRATION_DIRECTORIES].sort()); }); + +test('documents exotic global-analysis blind spots and the defense-in-depth layers', async () => { + const source = await readFile( + path.join(packageRoot, 'eslint-rules/no-adtech-globals.js'), + 'utf8' + ); + + assert.match(source, /computed composition/u); + assert.match(source, /function-returned roots/u); + assert.match(source, /bundle\/source scans/u); + assert.match(source, /browser ownership tests/u); +}); diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index 6b513c199..8dcf4096a 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -182,7 +182,7 @@ allow-scripts allow-top-navigation-by-user-activation ``` -It deliberately omits `allow-same-origin` during bootstrap, so APS and bidder execution remains below an opaque-origin boundary. The renderer response repeats these restrictions with a CSP `sandbox` directive, preventing another embedding path from restoring publisher-origin execution by omitting the iframe attribute. Trusted Server generates independent 128-bit bootstrap and renderer nonces, binds each to its own phase, and accepts each exactly once. No descriptor or data-document URL crosses the bootstrap configuration channel. Existing slot content is retained until the inner renderer has accepted the descriptor and APS reports render completion. +It deliberately omits `allow-same-origin` during bootstrap, so APS and bidder execution remains below an opaque-origin boundary. The renderer response is an intentional transport-CSP superset with no CSP `sandbox` directive. The generated outer and inner meta CSPs narrow it to the exact Trusted Server and creative origins, while the iframe `sandbox` attributes enforce the exact phase-specific restrictions. Trusted Server generates independent 128-bit bootstrap and renderer nonces, binds each to its own phase, and accepts each exactly once. No descriptor or data-document URL crosses the bootstrap configuration channel. Existing slot content is retained until the inner renderer has accepted the descriptor and APS reports render completion. ### Direct `/auction` @@ -190,7 +190,7 @@ The TSJS auction client validates the typed renderer descriptor, creates the opa ### GAM and Universal Creative -For initial navigation and page-bids, Trusted Server publishes the same descriptor in `window.tsjs.bids`. The source-checked Prebid Universal Creative bridge accepts requests only from the iframe that owns the matching `hb_adid`, validates the complete envelope, and returns a static dynamic-renderer program that creates the same opaque renderer iframe. +For initial navigation, Trusted Server publishes the descriptor in the immutable `tsjs.boot.auctionProjection`. A later SPA page-bids response replaces only that navigation session's internal projection and never mutates `tsjs.boot`. The source-checked Prebid Universal Creative bridge accepts requests only from the iframe that owns the matching `hb_adid`, validates the complete envelope, and returns a static dynamic-renderer program that creates the same opaque renderer iframe. For client-side `trustedServer` adapter auctions, Prebid generates its own `hb_adid`. Trusted Server binds that generated ID to the validated APS descriptor in a bounded, expiring browser registry before GAM refresh. The bridge verifies that the requesting Universal Creative iframe belongs to the same ad unit, consumes the capability once, and passes the APS bid ID separately to the Amazon runner. diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index 12f5c727b..cfea6025a 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -47,8 +47,10 @@ and non-storeable. Enabling the integration has one server-side effect that does not depend on browser activation. For each server-side auction that produced winning bids, Trusted Server -mints a fresh correlation token and publishes it as `hb_auction_id` on each winning bid -in `window.tsjs.bids`: +mints a fresh correlation token and publishes it as `hb_auction_id` on each winning +bid targeting record in the initial immutable `tsjs.boot.auctionProjection`. A later +SPA page-bids response keeps its replacement projection internal to that navigation +session: ```text ts-auc-2f8c1d5a4b7e4c0f9a3d6b1e8c5f2a7d From f1a2e19497a9672a0369232cdbc337545ad10f6e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:34:38 -0700 Subject: [PATCH 531/844] Fix APS cutover browser gate --- .../browser/fixtures/fictional-aps-runner.js | 5 +- .../browser/helpers/gpt-stub.ts | 9 +-- .../tests/shared/aps-puc-lifecycle.spec.ts | 16 +++++- .../browser/tests/shared/aps-renderer.spec.ts | 55 ++++++++++++------- .../tests/shared/creative-sandbox.spec.ts | 9 ++- .../browser/tests/shared/tsjs-runtime.spec.ts | 2 - .../lib/src/kernel/release_catalog.ts | 10 +++- .../lib/test/build/release-v1.test.mjs | 16 ++++++ .../test/integrations/phase-slices.test.ts | 2 +- .../lib/test/kernel/release_catalog.test.ts | 10 +++- ...s-render-fix-and-tsjs-resilience-design.md | 44 +++++++-------- scripts/ci/aps-tsjs-cutover.sh | 3 +- 12 files changed, 126 insertions(+), 55 deletions(-) diff --git a/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js b/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js index 2aa37be01..750d51a01 100644 --- a/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js +++ b/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js @@ -92,7 +92,10 @@ creative.style.margin = "0"; creative.style.overflow = "hidden"; } - creative.src = bid.ext.creativeurl; + creative.src = + bidId.indexOf("creative-cross-origin-") === 0 + ? "https://redirect.example/final.js" + : bid.ext.creativeurl; document.body.appendChild(creative); return; } 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 a7351ab71..0e9285df8 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts @@ -165,6 +165,8 @@ export async function installGptStub(page: Page): Promise { refresh: pubadsService.refresh, fetch: window.fetch, xhrOpen: window.XMLHttpRequest.prototype.open, + }; + const publisherHistoryReferences = { pushState: window.history.pushState, replaceState: window.history.replaceState, }; @@ -433,14 +435,13 @@ export async function installGptStub(page: Page): Promise { references.refresh = pubadsService.refresh; references.fetch = window.fetch; references.xhrOpen = window.XMLHttpRequest.prototype.open; - references.pushState = window.history.pushState; - references.replaceState = window.history.replaceState; }, referenceOwnership() { const pushStateChanged = - window.history.pushState !== references.pushState; + window.history.pushState !== publisherHistoryReferences.pushState; const replaceStateChanged = - window.history.replaceState !== references.replaceState; + window.history.replaceState !== + publisherHistoryReferences.replaceState; return { diagnosticsSafe: commandQueue.push === references.commandPush && diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts index d9d990557..9d8d543b3 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts @@ -84,6 +84,19 @@ function boot() { }, ], }, + integrations: { + version: 1, + entries: [ + { id: "aps", config: {} }, + { + id: "gpt", + config: { + gamAttributionEnabled: false, + pageBidsEnabled: true, + }, + }, + ], + }, creative: { version: 1, enabled: false, @@ -152,7 +165,6 @@ async function openLifecyclePage( browserWindow.tsjs = { boot: initialBoot, que: [], - _integrationConfig: { aps: {}, gpt: {} }, }; }, { @@ -355,7 +367,7 @@ test.describe("APS/PUC lifecycle over the hard-cutover runtime", () => { ) .toMatchObject({ lifecycle: ["accepted", "failed:fictional PUC response refused"], - ownerFrames: [1, 0], + ownerFrames: [0, 0], pucFrames: 2, }); 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 bb3196848..62680bafa 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 @@ -9,7 +9,8 @@ const APS_TEST_RENDERER_URL = `${APS_TEST_ORIGIN}/integrations/aps/renderer/v2`; const APS_TEST_RUNNER_URL = `${APS_TEST_ORIGIN}/integrations/aps/runner.js`; const SANDBOX = "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation"; -const PERMANENT_SANDBOX = `${SANDBOX} allow-same-origin`; +const PERMANENT_SANDBOX = + "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation"; const FICTIONAL_APS_RUNNER = readFileSync( resolve(__dirname, "../../fixtures/fictional-aps-runner.js"), "utf8", @@ -91,6 +92,7 @@ test.describe("APS renderer v2 protocol", () => { test("uses one port, reports ordered progress, and fails closed", async ({ page, + browserName, }) => { test.setTimeout(60_000); const rendererResponse = await page.request.get( @@ -615,7 +617,15 @@ window.startApsV2 = function(options) { })), ).toEqual({ allowed: "executed", unrelated: undefined }); - await start("creative-redirect", "creative-redirect-bid", { + // WebKit's Playwright interception backend cannot synthesize a redirect + // response. Exercise the same exact-origin CSP boundary there by asking the + // fictional runner to attempt the redirected final URL directly; Chromium + // and Firefox retain the end-to-end redirect case. + const redirectBidId = + browserName === "webkit" + ? "creative-cross-origin-bid" + : "creative-redirect-bid"; + await start("creative-redirect", redirectBidId, { creativeUrl: "https://creative.example/script-redirect.js", tagType: "script", }); @@ -675,29 +685,36 @@ window.startApsV2 = function(options) { await expect(frame).toHaveAttribute("width", String(width)); await expect(frame).toHaveAttribute("height", String(height)); } - for (const frame of [outerFrame, innerFrame, creativeFrame]) { - expect( - await frame.evaluate((element) => { - const value = element as HTMLElement; - const box = value.getBoundingClientRect(); - const computed = getComputedStyle(value); - return { - width: box.width, - height: box.height, - clientWidth: value.clientWidth, - clientHeight: value.clientHeight, - margin: computed.margin, - overflow: computed.overflow, - }; - }), - ).toEqual({ + for (const [frame, declaredOverflow] of [ + [outerFrame, "hidden"], + [innerFrame, ""], + [creativeFrame, "hidden"], + ] as const) { + const frameBox = await frame.evaluate((element) => { + const value = element as HTMLElement; + const box = value.getBoundingClientRect(); + const computed = getComputedStyle(value); + return { + width: box.width, + height: box.height, + clientWidth: value.clientWidth, + clientHeight: value.clientHeight, + margin: computed.margin, + declaredOverflow: value.style.overflow, + computedOverflow: computed.overflow, + }; + }); + expect(frameBox).toMatchObject({ width, height, clientWidth: width, clientHeight: height, margin: "0px", - overflow: "hidden", + declaredOverflow, }); + // Chromium normalizes overflow on replaced iframe elements to `clip`; + // Firefox/WebKit may preserve the declared `hidden` computed value. + expect(["clip", "hidden"]).toContain(frameBox.computedOverflow); } for (const frame of [outer, inner, creative]) { expect( 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 1efd0f304..c877b9121 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 @@ -42,6 +42,7 @@ function creativeDocument( slots: [], bids: [], }, + integrations: { version: 1, entries: [] }, creative: { version: 1, enabled: true, @@ -64,7 +65,13 @@ function creativeDocument( configurable: false, enumerable: false, }); - window.tsjs = { boot: ${boot}, que: [], _integrationConfig: {} }; + window.tsjs = { que: [] }; + const __TSJS_SERVER_BOOT_INPUT_V1__ = { + target: window.tsjs, + boot: ${boot}, + outline: null, + }; + ${fixture.bootstrapBody} ", + let transport = format!( + r#"{{"version":1,"boot":{{"abi":1,"releaseId":"{}","manifest":{},"auctionProjection":{},"integrations":{},"creative":{{"version":1,"enabled":{},"clickGuard":{},"renderGuard":{}}},"diagnostics":{{"version":1,"renderTraceOverlay":{},"gpt":{{"active":{}}}}}}},"integrity":{},"outline":{}}}"#, release_id(), manifest, projection, - integration_configs, + integration_configs_json, config.creative.enabled, config.creative.click_guard, config.creative.render_guard, config.render_trace_overlay, config.gpt_diagnostics_active, + integrity, outline, - bootstrap, + ); + if transport.len() > SERVER_BOOT_TRANSPORT_MAX_BYTES_V1 { + return Err(boot_manifest_error( + "server boot transport exceeds 10 MiB UTF-8", + )); + } + let transport_literal = serde_json::to_string(&transport) + .map_err(|_| boot_manifest_error("server boot transport serialization failed"))?; + let transport_literal = escape_json_for_inline_script(&transport_literal); + let bootstrap = trusted_server_js::bootstrap_bundle(); + if bootstrap.to_ascii_lowercase().contains("const __TSJS_SERVER_BOOT_TRANSPORT_V1__={transport_literal};{bootstrap}" ); let selected_src = agent .as_ref() @@ -989,10 +1249,34 @@ pub fn tsjs_deferred_script_tags(module_ids: &[&str]) -> String { #[cfg(test)] mod tests { use super::*; + use crate::test_support::tests::bootstrap_transport; const VALID_BROWSER_AUCTION_PROJECTION_JSON: &str = r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"slots":[],"bids":[]}"#; const PERFORMANCE_ORIGIN: &str = "https://performance.example"; + #[test] + fn ecmascript_json_corpus_matches_the_browser_stringify_contract() { + let corpus: serde_json::Value = serde_json::from_str(include_str!( + "../../trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json" + )) + .expect("shared ECMAScript JSON corpus should parse"); + for case in corpus + .as_array() + .expect("shared ECMAScript JSON corpus should be an array") + { + let name = case["name"].as_str().expect("case should have a name"); + let input = case["input"].as_str().expect("case should have input"); + let expected = case["expected"] + .as_str() + .expect("case should have expected bytes"); + assert_eq!( + ecmascript_json_stringify_v1(input).as_deref(), + Some(expected), + "shared case should match: {name}" + ); + } + } + #[test] fn integration_configs_serialize_once_in_canonical_product_order() { let configs = IntegrationConfigsV1::new(vec![ @@ -1161,8 +1445,27 @@ mod tests { PERFORMANCE_ORIGIN, ) .expect("matching carrier and manifest should serialize"); + let transport = bootstrap_transport(&script); + assert_eq!( + transport + .as_object() + .map(|record| record.keys().cloned().collect::>()), + Some(vec![ + "boot".to_owned(), + "integrity".to_owned(), + "outline".to_owned(), + "version".to_owned(), + ]), + "production transport must have one exact four-key root" + ); + assert!(transport["integrity"]["projectionDigest"].is_string()); + assert!(transport["integrity"]["integrationConfigDigest"].is_string()); + assert!(transport["outline"].is_null()); + assert!(!script.contains("__TSJS_SERVER_BOOT_INPUT_V1__")); + assert!(!script.contains(r#""target""#)); assert!( - script.contains(r#""integrations":{"version":1,"entries":[{"id":"aps","config":{}}]}"#), + transport["boot"]["integrations"] + == serde_json::json!({"version":1,"entries":[{"id":"aps","config":{}}]}), "boot must contain the sole typed integration carrier: {script}" ); @@ -1293,9 +1596,15 @@ mod tests { .expect("first-display bootstrap should serialize"); assert!( - script.contains(&format!(r#""integrationConfigDigest":"{expected_digest}""#)), + bootstrap_transport(&script)["integrity"]["integrationConfigDigest"].as_str() + == Some(expected_digest.as_str()), "outline must bind the exact canonical carrier: {script}" ); + assert_eq!( + bootstrap_transport(&script)["outline"]["integrationConfigDigest"], + expected_digest, + "outline must copy the always-present integrity value" + ); } #[test] @@ -1329,7 +1638,8 @@ mod tests { .expect("attribution-only first-display bootstrap should serialize"); assert!( - script.contains(r#""slices":["first_display","gpt_initial"]"#), + bootstrap_transport(&script)["boot"]["manifest"]["firstDisplay"]["slices"] + == serde_json::json!(["first_display", "gpt_initial"]), "typed GAM attribution must select the GPT parser-time owner: {script}" ); assert!( @@ -1404,11 +1714,12 @@ mod tests { ) .expect("should serialize the production bootstrap"); - assert!( - controller.contains(&format!( - r#""runtimeSrc":"/static/tsjs=tsjs-unified.min.js?v={}""#, + assert_eq!( + bootstrap_transport(&controller)["boot"]["manifest"]["runtimeSrc"], + format!( + "/static/tsjs=tsjs-unified.min.js?v={}", concatenated_hash(&["render_runtime", "gpt"]) - )), + ), "should carry the exact runtime artifact source in BootManifestV1" ); assert!( @@ -1592,25 +1903,32 @@ mod tests { .expect("the default creative integration should be enabled"); let expected_src = tsjs_script_src(&["render_runtime", "creative"]); - assert!( - bootstrap.contains("__TSJS_SERVER_BOOT_INPUT_V1__"), + assert!(bootstrap.contains("__TSJS_SERVER_BOOT_TRANSPORT_V1__")); + assert!(!bootstrap.contains("__TSJS_SERVER_BOOT_INPUT_V1__")); + let transport = bootstrap_transport(&bootstrap); + assert_eq!( + transport["boot"]["manifest"]["integrations"], + serde_json::json!([ + {"id":"render_runtime","phase":"takeover"}, + {"id":"creative","phase":"takeover"} + ]), "{bootstrap}" ); assert!( - bootstrap.contains(r#"{"id":"render_runtime","phase":"takeover"}"#), + bootstrap.contains(&format!(r#"src="{expected_src}""#)), "{bootstrap}" ); + assert_eq!(bootstrap.matches("id=\"trustedserver-js\"").count(), 1); assert!( - bootstrap.contains(r#"{"id":"creative","phase":"takeover"}"#), - "{bootstrap}" + transport["boot"]["manifest"]["integrations"] + .as_array() + .is_some_and(|entries| entries.iter().all(|entry| entry["id"] != "gpt")) ); assert!( - bootstrap.contains(&format!(r#"src="{expected_src}""#)), - "{bootstrap}" + transport["boot"]["manifest"]["integrations"] + .as_array() + .is_some_and(|entries| entries.iter().all(|entry| entry["phase"] != "deferred")) ); - assert_eq!(bootstrap.matches("id=\"trustedserver-js\"").count(), 1); - assert!(!bootstrap.contains(r#""id":"gpt""#), "{bootstrap}"); - assert!(!bootstrap.contains(r#""phase":"deferred""#), "{bootstrap}"); } #[test] diff --git a/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts b/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts index 81b4c2dd0..b7ebc8ecc 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts @@ -33,6 +33,39 @@ export interface RuntimeTsjsFixture { }; } +interface ServerBootDigestInputV1 { + readonly auctionProjection: unknown; + readonly integrations: unknown; + readonly [key: string]: unknown; +} + +/** Serialize the exact string-only server boot carrier consumed by bootstrap. */ +export function serverBootTransportLiteralV1( + boot: ServerBootDigestInputV1, + outline: unknown = null, +): string { + const projection = JSON.stringify(boot.auctionProjection); + const integrationConfigs = JSON.stringify(boot.integrations); + if (projection === undefined || integrationConfigs === undefined) { + throw new Error("TSJS server boot digest input is not JSON"); + } + const transport = JSON.stringify({ + version: 1, + boot, + integrity: { + version: 1, + projectionDigest: createHash("sha256") + .update(projection, "utf8") + .digest("hex"), + integrationConfigDigest: createHash("sha256") + .update(integrationConfigs, "utf8") + .digest("hex"), + }, + outline, + }); + return JSON.stringify(transport); +} + function exactArtifact(release: Release, id: string): ReleaseArtifact { const matches = release.artifacts.filter((artifact) => artifact.id === id); if (matches.length !== 1) @@ -102,37 +135,18 @@ export async function loadRuntimeTsjsFixture( fixture: RuntimeTsjsFixture, ): Promise { await routeRuntimeTsjsFixture(page, fixture); + const boot = await page.evaluate( + () => (window as unknown as { tsjs: Record }).tsjs.boot, + ); + if (typeof boot !== "object" || boot === null || Array.isArray(boot)) { + throw new Error("TSJS runtime fixture boot is unavailable"); + } + await page.addScriptTag({ + content: `const __TSJS_SERVER_BOOT_TRANSPORT_V1__=${serverBootTransportLiteralV1(boot as ServerBootDigestInputV1)};${fixture.bootstrapBody}`, + }); await page.evaluate( (src) => new Promise((resolveLoad, rejectLoad) => { - const target = (window as unknown as { tsjs: Record }) - .tsjs; - const freeze = (value: unknown): void => { - if ( - typeof value !== "object" || - value === null || - Object.isFrozen(value) - ) - return; - for (const key of Reflect.ownKeys(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor && "value" in descriptor) freeze(descriptor.value); - } - Object.freeze(value); - }; - const retainedBoot = target.boot; - freeze(retainedBoot); - const claim = (source: unknown): unknown => { - if (source !== document.currentScript) return undefined; - Reflect.deleteProperty(target, "_claimBootSnapshot"); - return retainedBoot; - }; - Object.defineProperty(target, "_claimBootSnapshot", { - configurable: true, - enumerable: false, - value: claim, - writable: false, - }); const script = document.createElement("script"); script.id = "trustedserver-js"; script.src = src; 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 c877b9121..2e2e57054 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 @@ -3,6 +3,7 @@ import { runtimeUrl } from "../../helpers/state.js"; import { routeRuntimeTsjsFixture, runtimeTsjsFixture, + serverBootTransportLiteralV1, type RuntimeTsjsFixture, } from "../../helpers/tsjs-fixture.js"; @@ -32,7 +33,7 @@ function creativeDocument( fixture: RuntimeTsjsFixture, signedClick: string, ): string { - const boot = JSON.stringify({ + const transport = serverBootTransportLiteralV1({ abi: 1, releaseId: fixture.releaseId, manifest: fixture.manifest, @@ -66,11 +67,7 @@ function creativeDocument( enumerable: false, }); window.tsjs = { que: [] }; - const __TSJS_SERVER_BOOT_INPUT_V1__ = { - target: window.tsjs, - boot: ${boot}, - outline: null, - }; + const __TSJS_SERVER_BOOT_TRANSPORT_V1__ = ${transport}; ${fixture.bootstrapBody} `, + body: ``, }), ); @@ -205,37 +206,31 @@ test.describe("TSJS hard-cutover runtime", () => { page, }) => { await openRuntimePage(page); - await page.evaluate( - ({ initialBoot, releaseId }) => { - 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, manifest: { version: 2, releaseId } }, - que: [], - }; - }, - { - initialBoot: boot(FALLBACK_FIXTURE), - releaseId: FALLBACK_FIXTURE.releaseId, - }, - ); + 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: [], + }; + }, boot(FALLBACK_FIXTURE)); await loadRuntimeTsjsFixture(page, FALLBACK_FIXTURE); await waitForRuntime(page, "fallback"); @@ -302,7 +297,7 @@ test.describe("TSJS hard-cutover runtime", () => { ); expect(after.effects.timeouts).toBe(before.effects.timeouts); expect(after.frames).toBe(0); - expect(after.scripts).toBe(2); + expect(after.scripts).toBe(3); expect(after.hasGoogletag).toBe(false); }); }); diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs index 188939fba..758542484 100644 --- a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -107,9 +107,11 @@ const CURRENT_PROVIDER_SOURCE_OWNERS = Object.freeze({ const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ 'src/core/adapters/gam_attribution.ts': 'bootstrap', 'src/core/bootstrap.ts': 'bootstrap', - 'src/core/contracts/boot.ts': 'bootstrap', - 'src/core/contracts/integration_configs.ts': 'bootstrap', - 'src/core/types.ts': 'bootstrap', + 'src/core/contracts/server_boot_transport.ts': 'bootstrap', + 'src/core/contracts/boot.ts': 'core', + 'src/core/contracts/integration_configs.ts': 'core', + 'src/core/contracts/sha256.ts': 'core', + 'src/core/types.ts': 'core', 'src/adapters/googletag.ts': 'gpt', 'src/adapters/messaging.ts': 'core', 'src/composition/runtime_transport.ts': 'core', @@ -180,12 +182,13 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/auction.ts': Object.freeze(['core']), 'src/core/config.ts': Object.freeze(['bootstrap', 'core']), 'src/core/contracts/aps_renderer.ts': Object.freeze(['bootstrap', 'core', 'aps']), - 'src/core/contracts/auction_projection.ts': Object.freeze([ + 'src/core/contracts/bounded_string.ts': Object.freeze([ 'bootstrap', 'core', 'prebid', 'prebid_later', ]), + 'src/core/contracts/auction_projection.ts': Object.freeze(['core', 'prebid', 'prebid_later']), 'src/core/contracts/generated/renderer_validator_v1.ts': Object.freeze([ 'bootstrap', 'core', @@ -263,6 +266,7 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/templates/iframe.html?raw': Object.freeze(['core']), 'src/core/trace.ts': Object.freeze(['core', 'gpt_diagnostics']), 'src/kernel/contracts/message_protocol.ts': Object.freeze(['core', 'gpt']), + 'src/kernel/contracts/release_capacity.ts': Object.freeze(['core']), 'src/kernel/contracts/puc_dynamic_owner.ts': Object.freeze(['aps_initial', 'gpt']), 'src/kernel/diagnostics.ts': Object.freeze(['core']), 'src/kernel/disposable.ts': Object.freeze(['core', 'gpt']), diff --git a/crates/trusted-server-js/lib/src/core/bootstrap.ts b/crates/trusted-server-js/lib/src/core/bootstrap.ts index c8eef9683..acf383b25 100644 --- a/crates/trusted-server-js/lib/src/core/bootstrap.ts +++ b/crates/trusted-server-js/lib/src/core/bootstrap.ts @@ -1,4 +1,4 @@ -declare const __TSJS_SERVER_BOOT_INPUT_V1__: unknown; +declare const __TSJS_SERVER_BOOT_TRANSPORT_V1__: unknown; import type { FirstDisplayAgent, @@ -10,8 +10,12 @@ import type { FirstDisplaySliceId } from '../kernel/release_catalog'; import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; import { enqueueFirstDisplayGamAttribution } from './adapters/gam_attribution'; -import { snapshotBootstrapInputV1, type BootstrapInputSnapshotV1 } from './contracts/boot'; -import { integrationConfigValueV1 } from './contracts/integration_configs'; +import { validateRequestAdsOptions } from './contracts/request_ads'; +import { + snapshotServerBootTransportV1, + type ServerBootTransportSnapshotV1, +} from './contracts/server_boot_transport'; +import { validateProgrammaticAdUnits } from './registry'; import { EMBEDDED_RELEASE_ID } from './release_id'; const RELEASE = EMBEDDED_RELEASE_ID; @@ -21,7 +25,14 @@ interface ComponentRegistration { readonly prepare: (host: unknown) => unknown; } +type BootstrapTarget = object & { boot?: unknown; que?: unknown }; + +interface BootstrapInputSnapshotV1 extends ServerBootTransportSnapshotV1 { + readonly target: BootstrapTarget; +} + type BootFailureReason = 'abi_mismatch' | 'bundle_partial'; +type BootClaimOutcome = 'kernel' | BootFailureReason; class RuntimeUnavailableError extends Error { public readonly code = 'runtime_unavailable'; @@ -44,7 +55,7 @@ function deepFreeze(value: unknown): void { Object.freeze(value); } -function prepareIngress(target: BootstrapInputSnapshotV1['target']): unknown[] { +function prepareIngress(target: BootstrapTarget): unknown[] { const descriptor = Object.getOwnPropertyDescriptor(target, 'que'); const previous = descriptor && 'value' in descriptor && Array.isArray(descriptor.value) ? descriptor.value : []; @@ -78,12 +89,18 @@ function fallbackFields( runtimeSrc: boot.manifest.runtimeSrc, integrations: [], }, - auctionProjection: boot.auctionProjection, + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }, integrations: { version: 1, entries: [] }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, }; deepFreeze(safeBoot); + const knownSlots = new Set(); let level = 'warn'; const levels = ['silent', 'error', 'warn', 'info', 'debug']; const observe = (..._values: readonly unknown[]): void => undefined; @@ -103,11 +120,22 @@ function fallbackFields( debug: observe, }), _registerIntegration: () => false, - addAdUnits: (_units: unknown): never => { + addAdUnits: (units: unknown): never => { + validateProgrammaticAdUnits(units, knownSlots); throw new RuntimeUnavailableError(RELEASE, reason); }, - requestAds: async (): Promise => { - throw new RuntimeUnavailableError(RELEASE, reason); + requestAds: async (options?: unknown): Promise => { + const validated = validateRequestAdsOptions(options); + const selected = validated.slots ?? []; + const result = { + slots: selected.map((slot) => + validated.aborted + ? { slot, path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' } + : { slot, path: 'primary', outcome: 'failed', reason: 'slot_unresolved' } + ), + }; + deepFreeze(result); + return result; }, }; Object.defineProperty(fields, '_internal', { @@ -170,7 +198,7 @@ function publishFallback( } } -function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): void { +function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSnapshotV1): void { const ingress = prepareIngress(target); const firstDisplay = boot.manifest.firstDisplay; const trustedOrigin = (): string | undefined => { @@ -219,7 +247,9 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): return false; } }; - let bootClaimed = false; + let bootClaimState: 'available' | 'claiming' | 'claimed' = 'available'; + let bootClaimCompleted = false; + let completeClaimedBoot: ((outcome: BootClaimOutcome) => void) | undefined; const removeBootClaim = (): void => { try { const descriptor = Object.getOwnPropertyDescriptor(target, '_claimBootSnapshot'); @@ -230,19 +260,43 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): // The closure-retained snapshot remains authoritative after hostile replacement. } }; - const claimBoot = (source: unknown): Readonly | undefined => { - const expectedId = firstDisplay === null ? 'trustedserver-js' : 'trustedserver-js-runtime'; + const completeBootClaim = (outcome: BootClaimOutcome): void => { + const acceptedOutcome = + outcome === 'kernel' || outcome === 'abi_mismatch' || outcome === 'bundle_partial'; if ( - bootClaimed || - !(source instanceof HTMLScriptElement) || - document.currentScript !== source || - !authentic(source, boot.manifest.runtimeSrc, expectedId) + bootClaimState !== 'claimed' || + bootClaimCompleted || + !completeClaimedBoot || + !acceptedOutcome ) { + return; + } + bootClaimCompleted = true; + const complete = completeClaimedBoot; + completeClaimedBoot = undefined; + complete(outcome); + }; + const claimedBoot = Object.freeze({ boot, integrity, complete: completeBootClaim }); + const claimBoot = (source: unknown): Readonly | undefined => { + if (bootClaimState !== 'available') return undefined; + bootClaimState = 'claiming'; + let accepted = false; + try { + const expectedId = firstDisplay === null ? 'trustedserver-js' : 'trustedserver-js-runtime'; + accepted = + source instanceof HTMLScriptElement && + document.currentScript === source && + authentic(source, boot.manifest.runtimeSrc, expectedId); + } catch { + // The claim stays reserved only until this authentication attempt fails. + } + if (!accepted) { + bootClaimState = 'available'; return undefined; } - bootClaimed = true; + bootClaimState = 'claimed'; removeBootClaim(); - return boot; + return claimedBoot; }; const installBootClaim = (): void => { Object.defineProperty(target, '_claimBootSnapshot', { @@ -264,7 +318,7 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): // A hostile replacement cannot make the captured claim callable again. } }; - const fallback = (): void => { + const fallback = (reason: BootFailureReason = 'bundle_partial'): void => { if (terminal) return; terminal = true; if (timer !== undefined) window.clearTimeout(timer); @@ -275,7 +329,10 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): } removeClaim(); removeBootClaim(); - publishFallback(target, ingress, fallbackFields(boot, 'bundle_partial', false)); + publishFallback(target, ingress, fallbackFields(boot, reason, false)); + }; + completeClaimedBoot = (outcome) => { + if (outcome === 'abi_mismatch') fallback(outcome); }; const claim = ( source: unknown, @@ -383,6 +440,9 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): disposeAgent(); publishFallback(target, ingress, fallbackFields(boot, reason, initialDisplayCommitted)); }; + completeClaimedBoot = (outcome) => { + if (outcome !== 'kernel') commitFallback(outcome); + }; const now = (): number => performance.now(); const startedAtMs = now(); const controller: FirstDisplayBootstrapController = Object.freeze({ @@ -529,27 +589,15 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): }; const binding = (id: string): unknown => { const product = id.endsWith('_initial') ? id.slice(0, -'_initial'.length) : ''; - const config = - id === 'creative_initial' - ? boot.creative - : [ - 'aps', - 'datadome', - 'didomi', - 'google_tag_manager', - 'gpt', - 'lockr', - 'osano', - 'permutive', - 'prebid', - 'sourcepoint', - 'testlight', - ].includes(product) - ? integrationConfigValueV1( - boot.integrations, - product as Parameters[1] - ) - : undefined; + let config: unknown = id === 'creative_initial' ? boot.creative : undefined; + if (config === undefined && product !== '') { + for (const entry of boot.integrations.entries) { + if (entry.id === product) { + config = entry.config; + break; + } + } + } return Object.freeze({ bindings: rawBinding(id), config }); }; const host: FirstDisplayAgentRegistrationHostV1 = Object.freeze({ @@ -689,8 +737,27 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): } } -const input = snapshotBootstrapInputV1( - typeof __TSJS_SERVER_BOOT_INPUT_V1__ === 'undefined' ? undefined : __TSJS_SERVER_BOOT_INPUT_V1__, +function bootstrapTarget(): BootstrapTarget | undefined { + try { + const namespace = window as unknown as { tsjs?: unknown }; + const current = namespace.tsjs; + if ((typeof current === 'object' || typeof current === 'function') && current !== null) { + return current as BootstrapTarget; + } + if (current) return undefined; + const target: BootstrapTarget = {}; + namespace.tsjs = target; + return target; + } catch { + return undefined; + } +} + +const transport = snapshotServerBootTransportV1( + typeof __TSJS_SERVER_BOOT_TRANSPORT_V1__ === 'undefined' + ? undefined + : __TSJS_SERVER_BOOT_TRANSPORT_V1__, RELEASE ); -if (input) installBootstrap(input); +const target = transport && bootstrapTarget(); +if (transport && target) installBootstrap(Object.freeze({ ...transport, target })); 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 372225f34..5a8676529 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 @@ -11,6 +11,9 @@ import type { } from '../types'; import { validateApsRenderer } from './aps_renderer'; +import { validBoundedString } from './bounded_string'; + +export { validBoundedString }; export const MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024; @@ -22,8 +25,6 @@ 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; const candidateIdPattern = /^[A-Za-z0-9_-]{12}$/; const reservationIdPattern = /^r1_[A-Za-z0-9_-]{22}$/; @@ -103,46 +104,6 @@ export function ownDataArray(value: unknown, maximum: number): unknown[] | undef } } -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 undefined; - index += 1; - } else if (code >= 0xdc00 && code <= 0xdfff) { - return undefined; - } - scalars += 1; - } - return scalars; -} - -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; -} - -export function validBoundedString( - value: unknown, - maximumBytes: number, - options: { allowControls?: boolean; maximumScalars?: number } = {} -): value is string { - if (typeof value !== 'string' || value.length === 0) return false; - const scalarCount = unicodeScalarCount(value); - return ( - scalarCount !== undefined && - (options.allowControls === true || !hasAsciiControl(value)) && - (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; } diff --git a/crates/trusted-server-js/lib/src/core/contracts/boot.ts b/crates/trusted-server-js/lib/src/core/contracts/boot.ts index f88eb261b..ba67e4e6d 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/boot.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/boot.ts @@ -318,23 +318,33 @@ export function snapshotTsjsBootV1(candidate: unknown, releaseId: string): TsjsB } } -/** Revalidate a bootstrap snapshot while retaining its exact object identity. */ -export function retainTsjsBootSnapshotV1( +/** Revalidate one recursively frozen boot and return the independent accepted snapshot. */ +export function snapshotFrozenTsjsBootV1( candidate: unknown, releaseId: string -): Readonly | undefined { +): TsjsBootV1 | undefined { try { const accepted = snapshotTsjsBootV1(candidate, releaseId); return accepted && recursivelyFrozenPlainData(candidate) && JSON.stringify(candidate) === JSON.stringify(accepted) - ? (candidate as Readonly) + ? accepted : undefined; } catch { return undefined; } } +/** Revalidate a bootstrap snapshot while retaining its exact object identity. */ +export function retainTsjsBootSnapshotV1( + candidate: unknown, + releaseId: string +): Readonly | undefined { + return snapshotFrozenTsjsBootV1(candidate, releaseId) + ? (candidate as Readonly) + : undefined; +} + /** Capture the exact server lexical, including the one retained immutable boot copy. */ export function snapshotBootstrapInputV1( candidate: unknown, diff --git a/crates/trusted-server-js/lib/src/core/contracts/bounded_string.ts b/crates/trusted-server-js/lib/src/core/contracts/bounded_string.ts new file mode 100644 index 000000000..ef89f69e5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/bounded_string.ts @@ -0,0 +1,44 @@ +const reflectApplyIntrinsic = Reflect.apply; +const textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; + +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 undefined; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return undefined; + } + scalars += 1; + } + return scalars; +} + +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 a nonempty, well-formed UTF-16 string against byte and scalar bounds. */ +export function validBoundedString( + value: unknown, + maximumBytes: number, + options: { allowControls?: boolean; maximumScalars?: number } = {} +): value is string { + if (typeof value !== 'string' || value.length === 0) return false; + const scalarCount = unicodeScalarCount(value); + return ( + scalarCount !== undefined && + (options.allowControls === true || !hasAsciiControl(value)) && + (reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [value]) as Uint8Array) + .length <= maximumBytes && + (options.maximumScalars === undefined || scalarCount <= options.maximumScalars) + ); +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts b/crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts index 353bc7301..692e33d27 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts @@ -6,24 +6,15 @@ import { type IntegrationConfigsV1, } from '../types'; +import { sha256HexUtf8V1 } from './sha256'; + +export { sha256HexUtf8V1 } from './sha256'; + const MAX_DEPTH = 16; const MAX_VALUES = 4_096; const MAX_STRING_BYTES = 4_096; const MAX_ENTRY_BYTES = 65_536; const MAX_CARRIER_BYTES = 524_288; -const SHA256_INITIAL = Object.freeze([ - 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, -]); -const SHA256_ROUNDS = Object.freeze([ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, -]); const CONFIG_ORDER = new Map(INTEGRATION_CONFIG_IDS_V1.map((id, index) => [id, index] as const)); @@ -31,65 +22,6 @@ function utf8Length(value: string): number { return new TextEncoder().encode(value).byteLength; } -function rotateRight(value: number, count: number): number { - return (value >>> count) | (value << (32 - count)); -} - -/** Calculate SHA-256 synchronously so parser-time boot validation remains effect-inert. */ -export function sha256HexUtf8V1(value: string): string { - const source = new TextEncoder().encode(value); - const paddedLength = Math.ceil((source.length + 9) / 64) * 64; - const padded = new Uint8Array(paddedLength); - padded.set(source); - padded[source.length] = 0x80; - const bitLength = source.length * 8; - const view = new DataView(padded.buffer); - view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x1_0000_0000)); - view.setUint32(paddedLength - 4, bitLength >>> 0); - - const hash = [...SHA256_INITIAL]; - const words = new Uint32Array(64); - for (let offset = 0; offset < paddedLength; offset += 64) { - for (let index = 0; index < 16; index += 1) { - words[index] = view.getUint32(offset + index * 4); - } - for (let index = 16; index < 64; index += 1) { - const left = words[index - 15]!; - const right = words[index - 2]!; - const sigma0 = rotateRight(left, 7) ^ rotateRight(left, 18) ^ (left >>> 3); - const sigma1 = rotateRight(right, 17) ^ rotateRight(right, 19) ^ (right >>> 10); - words[index] = (words[index - 16]! + sigma0 + words[index - 7]! + sigma1) >>> 0; - } - - let [a, b, c, d, e, f, g, h] = hash; - for (let index = 0; index < 64; index += 1) { - const sum1 = rotateRight(e!, 6) ^ rotateRight(e!, 11) ^ rotateRight(e!, 25); - const choose = (e! & f!) ^ (~e! & g!); - const temporary1 = (h! + sum1 + choose + SHA256_ROUNDS[index]! + words[index]!) >>> 0; - const sum0 = rotateRight(a!, 2) ^ rotateRight(a!, 13) ^ rotateRight(a!, 22); - const majority = (a! & b!) ^ (a! & c!) ^ (b! & c!); - const temporary2 = (sum0 + majority) >>> 0; - h = g; - g = f; - f = e; - e = (d! + temporary1) >>> 0; - d = c; - c = b; - b = a; - a = (temporary1 + temporary2) >>> 0; - } - hash[0] = (hash[0]! + a!) >>> 0; - hash[1] = (hash[1]! + b!) >>> 0; - hash[2] = (hash[2]! + c!) >>> 0; - hash[3] = (hash[3]! + d!) >>> 0; - hash[4] = (hash[4]! + e!) >>> 0; - hash[5] = (hash[5]! + f!) >>> 0; - hash[6] = (hash[6]! + g!) >>> 0; - hash[7] = (hash[7]! + h!) >>> 0; - } - return hash.map((word) => word.toString(16).padStart(8, '0')).join(''); -} - function ownPlainDataRecord(value: unknown): Record | undefined { if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; diff --git a/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts new file mode 100644 index 000000000..49917c005 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts @@ -0,0 +1,325 @@ +import type { TakeoverOutlineV1 } from '../../shared/first_display_contracts'; +import type { BootManifestV1, TsjsBootV1 } from '../types'; + +const MAX_TRANSPORT_BYTES = 10 * 1024 * 1024; +const MAX_TAKEOVER_INTEGRATIONS = 14; +const RELEASE_ID = /^[0-9a-f]{64}$/; +const HASH = /^[0-9a-f]{64}$/; +const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const FIRST_DISPLAY_SRC = + /^\/static\/tsjs=tsjs-first-display\.min\.js\?m=([0-9a-f]{4})&v=[0-9a-f]{64}$/; +const RUNTIME_SRC = /^\/static\/tsjs=tsjs-unified\.min\.js\?v=[0-9a-f]{64}$/; +const DEFERRED_SRC = /^\/static\/tsjs=tsjs-([a-z0-9][a-z0-9_-]{0,63})\.min\.js\?v=[0-9a-f]{64}$/; +const FIRST_DISPLAY_IDS = Object.freeze([ + 'first_display', + 'aps_initial', + 'creative_initial', + 'datadome_initial', + 'didomi_initial', + 'google_tag_manager_initial', + 'gpt_initial', + 'lockr_initial', + 'osano_initial', + 'permutive_initial', + 'sourcepoint_initial', + 'prebid_initial', + 'testlight_initial', +] as const); +const CONFIG_IDS = Object.freeze([ + 'aps', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'lockr', + 'osano', + 'permutive', + 'prebid', + 'sourcepoint', + 'testlight', +] as const); + +export interface ServerBootIntegrityV1 { + readonly version: 1; + readonly projectionDigest: string; + readonly integrationConfigDigest: string; +} + +export interface ServerBootTransportSnapshotV1 { + readonly version: 1; + readonly boot: Readonly; + readonly integrity: Readonly; + readonly outline: TakeoverOutlineV1 | null; +} + +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function exact(value: unknown, keys: readonly string[]): Record | undefined { + const candidate = record(value); + if (!candidate || Object.getPrototypeOf(candidate) !== Object.prototype) return undefined; + const actual = Object.keys(candidate); + return actual.length === keys.length && actual.every((key) => keys.includes(key)) + ? candidate + : undefined; +} + +function deepFreeze(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return; + for (const key of Object.keys(value)) deepFreeze((value as Record)[key]); + Object.freeze(value); +} + +function validManifest(candidate: unknown, releaseId: string): BootManifestV1 | undefined { + const manifest = exact(candidate, [ + 'version', + 'releaseId', + 'firstDisplay', + 'runtimeSrc', + 'integrations', + ]); + if ( + !manifest || + manifest.version !== 1 || + manifest.releaseId !== releaseId || + typeof manifest.runtimeSrc !== 'string' || + !RUNTIME_SRC.test(manifest.runtimeSrc) || + !Array.isArray(manifest.integrations) || + manifest.integrations.length > 20 + ) { + return undefined; + } + const seen = new Set(); + let deferred = false; + let takeoverCount = 0; + for (const value of manifest.integrations) { + const integration = record(value); + if ( + !integration || + typeof integration.id !== 'string' || + !INTEGRATION_ID.test(integration.id) || + seen.has(integration.id) + ) { + return undefined; + } + seen.add(integration.id); + if (integration.phase === 'takeover') { + takeoverCount += 1; + if ( + deferred || + takeoverCount > MAX_TAKEOVER_INTEGRATIONS || + !exact(integration, ['id', 'phase']) + ) { + return undefined; + } + continue; + } + const deferredSource = + typeof integration.src === 'string' ? DEFERRED_SRC.exec(integration.src) : null; + if ( + integration.phase !== 'deferred' || + !exact(integration, ['id', 'phase', 'trigger', 'src']) || + integration.trigger !== 'first_display_or_idle' || + deferredSource?.[1] !== integration.id + ) { + return undefined; + } + deferred = true; + } + if (manifest.firstDisplay === null) return manifest as unknown as BootManifestV1; + const firstDisplay = exact(manifest.firstDisplay, ['src', 'slices']); + const slices = firstDisplay?.slices; + if ( + !firstDisplay || + typeof firstDisplay.src !== 'string' || + !Array.isArray(slices) || + slices.length === 0 || + slices.length > FIRST_DISPLAY_IDS.length + ) { + return undefined; + } + const match = FIRST_DISPLAY_SRC.exec(firstDisplay.src); + if (!match) return undefined; + const mask = Number.parseInt(match[1]!, 16); + const selected = FIRST_DISPLAY_IDS.filter((_id, index) => (mask & (1 << index)) !== 0); + if ( + (mask & 1) === 0 || + mask >>> FIRST_DISPLAY_IDS.length !== 0 || + selected.length !== slices.length || + selected.some((id, index) => slices[index] !== id) + ) { + return undefined; + } + return manifest as unknown as BootManifestV1; +} + +function validBoot(candidate: unknown, releaseId: string): Readonly | undefined { + const boot = exact(candidate, [ + 'abi', + 'releaseId', + 'manifest', + 'auctionProjection', + 'integrations', + 'creative', + 'diagnostics', + ]); + if (!boot || boot.abi !== 1 || boot.releaseId !== releaseId) return undefined; + if (!validManifest(boot.manifest, releaseId)) return undefined; + + const projection = exact(boot.auctionProjection, ['version', 'auction', 'slots', 'bids']); + const auction = exact(projection?.auction, ['version', 'auctionId', 'results']); + if ( + !projection || + projection.version !== 1 || + !auction || + auction.version !== 1 || + typeof auction.auctionId !== 'string' || + !Array.isArray(auction.results) || + auction.results.length > 256 || + !Array.isArray(projection.slots) || + projection.slots.length > 256 || + !Array.isArray(projection.bids) + ) { + return undefined; + } + + const integrations = exact(boot.integrations, ['version', 'entries']); + if ( + !integrations || + integrations.version !== 1 || + !Array.isArray(integrations.entries) || + integrations.entries.length > CONFIG_IDS.length + ) { + return undefined; + } + let previous = -1; + for (const value of integrations.entries) { + const entry = exact(value, ['id', 'config']); + const order = + entry && typeof entry.id === 'string' + ? CONFIG_IDS.indexOf(entry.id as (typeof CONFIG_IDS)[number]) + : -1; + if (!entry || order <= previous || !record(entry.config)) return undefined; + previous = order; + } + + const creative = exact(boot.creative, ['version', 'enabled', 'clickGuard', 'renderGuard']); + const diagnostics = exact(boot.diagnostics, ['version', 'renderTraceOverlay', 'gpt']); + const gpt = exact(diagnostics?.gpt, ['active']); + if ( + !creative || + creative.version !== 1 || + typeof creative.enabled !== 'boolean' || + typeof creative.clickGuard !== 'boolean' || + typeof creative.renderGuard !== 'boolean' || + (!creative.enabled && (creative.clickGuard || creative.renderGuard)) || + !diagnostics || + diagnostics.version !== 1 || + typeof diagnostics.renderTraceOverlay !== 'boolean' || + !gpt || + typeof gpt.active !== 'boolean' + ) { + return undefined; + } + return boot as unknown as Readonly; +} + +function validIntegrity(candidate: unknown): Readonly | undefined { + const integrity = exact(candidate, ['version', 'projectionDigest', 'integrationConfigDigest']); + return integrity && + integrity.version === 1 && + typeof integrity.projectionDigest === 'string' && + HASH.test(integrity.projectionDigest) && + typeof integrity.integrationConfigDigest === 'string' && + HASH.test(integrity.integrationConfigDigest) + ? (integrity as unknown as Readonly) + : undefined; +} + +function validOutline( + candidate: unknown, + releaseId: string, + integrity: Readonly, + boot: Readonly +): TakeoverOutlineV1 | null | undefined { + if (candidate === null) return boot.manifest.firstDisplay === null ? null : undefined; + const outline = exact(candidate, [ + 'version', + 'releaseId', + 'generation', + 'projectionDigest', + 'integrationConfigDigest', + 'slices', + 'slotCount', + 'outcomeCount', + 'capabilities', + 'objectKinds', + ]); + const firstDisplay = boot.manifest.firstDisplay; + const projection = boot.auctionProjection; + if ( + !outline || + !firstDisplay || + outline.version !== 1 || + outline.releaseId !== releaseId || + !Number.isInteger(outline.generation) || + (outline.generation as number) < 1 || + (outline.generation as number) > 4_294_967_295 || + outline.projectionDigest !== integrity.projectionDigest || + outline.integrationConfigDigest !== integrity.integrationConfigDigest || + !Array.isArray(outline.slices) || + outline.slices.length !== firstDisplay.slices.length || + outline.slices.some((id, index) => id !== firstDisplay.slices[index]) || + !Number.isInteger(outline.slotCount) || + outline.slotCount !== projection.slots.length || + (outline.slotCount as number) < 1 || + !Number.isInteger(outline.outcomeCount) || + outline.outcomeCount !== projection.auction.results.length || + outline.outcomeCount !== outline.slotCount || + !Array.isArray(outline.capabilities) || + outline.capabilities.length !== 0 || + !Array.isArray(outline.objectKinds) + ) { + return undefined; + } + const expectedKinds = projection.bids.length === 0 ? [] : ['gpt_slot', 'dom_artifact']; + if ( + outline.objectKinds.length !== expectedKinds.length || + outline.objectKinds.some((kind, index) => kind !== expectedKinds[index]) + ) { + return undefined; + } + return outline as unknown as TakeoverOutlineV1; +} + +/** Parse the one server-sealed lexical boot value without importing domain validators. */ +export function snapshotServerBootTransportV1( + payload: unknown, + releaseId: string +): ServerBootTransportSnapshotV1 | undefined { + try { + if ( + typeof payload !== 'string' || + typeof releaseId !== 'string' || + !RELEASE_ID.test(releaseId) || + payload.length > MAX_TRANSPORT_BYTES || + new TextEncoder().encode(payload).byteLength > MAX_TRANSPORT_BYTES + ) { + return undefined; + } + const root = exact(JSON.parse(payload), ['version', 'boot', 'integrity', 'outline']); + if (!root || root.version !== 1) return undefined; + const boot = validBoot(root.boot, releaseId); + const integrity = validIntegrity(root.integrity); + if (!boot || !integrity) return undefined; + const outline = validOutline(root.outline, releaseId, integrity, boot); + if (outline === undefined) return undefined; + deepFreeze(root); + return root as unknown as ServerBootTransportSnapshotV1; + } catch { + return undefined; + } +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/sha256.ts b/crates/trusted-server-js/lib/src/core/contracts/sha256.ts new file mode 100644 index 000000000..49887c590 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/sha256.ts @@ -0,0 +1,75 @@ +const H: number[] = []; +const K: number[] = []; +const ENCODER = new TextEncoder(); +for (let n = 2; K.length < 64; n += 1) { + let prime = true; + for (let d = 2; d * d <= n; d += 1) { + if (n % d === 0) { + prime = false; + break; + } + } + if (!prime) continue; + if (H.length < 8) H.push((Math.sqrt(n) * 0x1_0000_0000) >>> 0); + K.push((Math.cbrt(n) * 0x1_0000_0000) >>> 0); +} + +function rotr(word: number, bits: number): number { + return (word >>> bits) | (word << (32 - bits)); +} + +/** Calculate SHA-256 synchronously so boot validation remains effect-inert. */ +export function sha256HexUtf8V1(text: string): string { + const bytes = ENCODER.encode(text); + const length = Math.ceil((bytes.length + 9) / 64) * 64; + const data = new Uint8Array(length); + data.set(bytes); + data[bytes.length] = 0x80; + const bits = bytes.length * 8; + const view = new DataView(data.buffer); + view.setUint32(length - 8, Math.floor(bits / 0x1_0000_0000)); + view.setUint32(length - 4, bits >>> 0); + const state = [...H]; + const w = new Uint32Array(64); + for (let offset = 0; offset < length; offset += 64) { + for (let i = 0; i < 16; i += 1) w[i] = view.getUint32(offset + i * 4); + for (let i = 16; i < 64; i += 1) { + const left = w[i - 15]!; + const right = w[i - 2]!; + const s0 = rotr(left, 7) ^ rotr(left, 18) ^ (left >>> 3); + const s1 = rotr(right, 17) ^ rotr(right, 19) ^ (right >>> 10); + w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0; + } + let [a, b, c, d, e, f, g, h] = state; + for (let i = 0; i < 64; i += 1) { + const s1 = rotr(e!, 6) ^ rotr(e!, 11) ^ rotr(e!, 25); + const ch = (e! & f!) ^ (~e! & g!); + const t1 = (h! + s1 + ch + K[i]! + w[i]!) >>> 0; + const s0 = rotr(a!, 2) ^ rotr(a!, 13) ^ rotr(a!, 22); + const maj = (a! & b!) ^ (a! & c!) ^ (b! & c!); + const t2 = (s0 + maj) >>> 0; + h = g; + g = f; + f = e; + e = (d! + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + state[0] = (state[0]! + a!) >>> 0; + state[1] = (state[1]! + b!) >>> 0; + state[2] = (state[2]! + c!) >>> 0; + state[3] = (state[3]! + d!) >>> 0; + state[4] = (state[4]! + e!) >>> 0; + state[5] = (state[5]! + f!) >>> 0; + state[6] = (state[6]! + g!) >>> 0; + state[7] = (state[7]! + h!) >>> 0; + } + return state.map((word) => word.toString(16).padStart(8, '0')).join(''); +} + +/** Measure UTF-8 with the same intrinsic used by the synchronous digest. */ +export function utf8LengthV1(text: string): number { + return ENCODER.encode(text).byteLength; +} diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 6dbce226d..1d38e5250 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -14,10 +14,15 @@ export type { export type { Runtime, RuntimeOptions, RuntimeState } from '../kernel/runtime'; import type { Runtime, RuntimeOptions } from '../kernel/runtime'; +import { validateRuntimeManifestV1 } from '../kernel/integration_registry'; import { consumeFirstDisplayTakeoverTransport } from '../shared/takeover'; -import type { TsjsBootV1 } from './types'; +import { ownDataObject } from './contracts/auction_projection'; +import { snapshotFrozenTsjsBootV1 } from './contracts/boot'; +import type { ServerBootIntegrityV1 } from './contracts/server_boot_transport'; +import { sha256HexUtf8V1 } from './contracts/sha256'; import { EMBEDDED_INTEGRATION_IDS, EMBEDDED_RELEASE_ID, EMBEDDED_RUNTIME_CATALOG } from './release'; +import type { TsjsBootV1 } from './types'; type BootstrapTarget = object & { boot?: unknown; @@ -26,6 +31,30 @@ type BootstrapTarget = object & { type DirectRuntimeCompletion = (outcome: 'kernel' | 'runtime_fallback' | 'failed_start') => void; +interface ClaimedServerBootV1 { + readonly boot: Readonly; + readonly integrity: Readonly; + readonly complete: (outcome: 'kernel' | 'abi_mismatch' | 'bundle_partial') => void; +} + +function retainServerBoot(candidate: unknown, releaseId: string): Readonly | undefined { + try { + const boot = snapshotFrozenTsjsBootV1(candidate, releaseId); + if (!boot) return undefined; + const manifest = validateRuntimeManifestV1( + boot.manifest, + releaseId, + EMBEDDED_RUNTIME_CATALOG, + true + ); + return manifest && JSON.stringify(boot.manifest) === JSON.stringify(manifest) + ? boot + : undefined; + } catch { + return undefined; + } +} + function directRuntimeClaim( target: BootstrapTarget ): ((source: unknown, cancel: unknown) => DirectRuntimeCompletion | undefined) | null | undefined { @@ -71,7 +100,7 @@ function bootstrapTarget(): BootstrapTarget | undefined { function bootSnapshotClaim( target: BootstrapTarget -): ((source: unknown) => Readonly | undefined) | null | undefined { +): ((source: unknown) => Readonly | undefined) | null | undefined { try { const descriptor = Object.getOwnPropertyDescriptor(target, '_claimBootSnapshot'); if (!descriptor) return undefined; @@ -84,20 +113,72 @@ function bootSnapshotClaim( ) { return null; } - return descriptor.value as (source: unknown) => Readonly | undefined; + return descriptor.value as (source: unknown) => Readonly | undefined; } catch { return null; } } +function retainClaimedServerBootV1(candidate: unknown): ClaimedServerBootV1 | undefined { + try { + const fields = ownDataObject(candidate, ['boot', 'integrity', 'complete']); + if (!fields || !Object.isFrozen(candidate)) return undefined; + const boot = retainServerBoot(fields['boot'], EMBEDDED_RELEASE_ID); + const integrity = fields['integrity']; + const acceptedIntegrity = ownDataObject(integrity, [ + 'version', + 'projectionDigest', + 'integrationConfigDigest', + ]); + if ( + !boot || + !acceptedIntegrity || + !Object.isFrozen(integrity) || + typeof fields['complete'] !== 'function' + ) { + return undefined; + } + if ( + acceptedIntegrity['version'] !== 1 || + acceptedIntegrity['projectionDigest'] !== + sha256HexUtf8V1(JSON.stringify(boot.auctionProjection)) || + acceptedIntegrity['integrationConfigDigest'] !== + sha256HexUtf8V1(JSON.stringify(boot.integrations)) + ) { + return undefined; + } + return candidate as ClaimedServerBootV1; + } catch { + return undefined; + } +} + +function claimedBootCompletion(candidate: unknown): ClaimedServerBootV1['complete'] | undefined { + const fields = ownDataObject(candidate, ['boot', 'integrity', 'complete']); + return fields && typeof fields['complete'] === 'function' + ? (fields['complete'] as ClaimedServerBootV1['complete']) + : 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 claimBoot = bootSnapshotClaim(target); if (!claimBoot) return; - const boot = claimBoot(document.currentScript); - if (!boot) return; + const source = document.currentScript; + const candidate = claimBoot(source); + const completeBoot = claimedBootCompletion(candidate); + const claimed = retainClaimedServerBootV1(candidate); + if (!claimed) { + try { + completeBoot?.('abi_mismatch'); + } catch { + // The authenticated bootstrap watchdog remains the terminal fallback owner. + } + return; + } + const { boot } = claimed; const takeover = consumeFirstDisplayTakeoverTransport(target); if (takeover.status === 'invalid') return; const claimDirect = takeover.status === 'absent' ? directRuntimeClaim(target) : undefined; @@ -113,6 +194,11 @@ export function startProductionRuntime(createComposition: BrowserRuntimeComposit ...(takeover.status === 'accepted' ? { coordinateTakeover: takeover.coordinate } : {}), autoInstall: true, onInstallComplete: (result) => { + try { + claimed.complete(result.state === 'kernel' ? 'kernel' : result.reason); + } catch { + // Direct completion still closes its independently authenticated watchdog. + } completeDirect?.(result.state === 'kernel' ? 'kernel' : 'runtime_fallback'); }, kernel: { diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 42b8f3603..8250628bb 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -1,6 +1,6 @@ // Programmatic ad-unit validation for the hard-cutover runtime. import type { AddAdUnitsResult, ProgrammaticAdUnit } from './types'; -import { validBoundedString } from './contracts/auction_projection'; +import { validBoundedString } from './contracts/bounded_string'; const MAX_AUCTION_BODY_BYTES = 256 * 1024; const MAX_PROGRAMMATIC_UNITS = 256; diff --git a/crates/trusted-server-js/lib/src/core/release_capacity.ts b/crates/trusted-server-js/lib/src/core/release_capacity.ts deleted file mode 100644 index 93b3c92e2..000000000 --- a/crates/trusted-server-js/lib/src/core/release_capacity.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare const __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__: number; - -/** Build-generated bounded manifest capacity without importing the product catalog. */ -export const EMBEDDED_MAX_MANIFEST_MODULES = __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__; diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index a3fac939b..f88010c51 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -553,6 +553,7 @@ export interface TsjsFallbackApi extends TsjsApiBase { state: 'fallback'; releaseId: string; reason: 'abi_mismatch' | 'bundle_partial'; + initialDisplayCommitted: boolean; }>; } diff --git a/crates/trusted-server-js/lib/src/kernel/contracts/release_capacity.ts b/crates/trusted-server-js/lib/src/kernel/contracts/release_capacity.ts new file mode 100644 index 000000000..6a2fe10c9 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/contracts/release_capacity.ts @@ -0,0 +1,9 @@ +declare const __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__: number; + +/** Build-generated bounded manifest capacity without importing the product catalog. */ +export const EMBEDDED_MAX_MANIFEST_MODULES = + Number.isSafeInteger(__TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__) && + __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__ >= 1 && + __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__ <= 256 + ? __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__ + : 0; diff --git a/crates/trusted-server-js/lib/src/kernel/fallback.ts b/crates/trusted-server-js/lib/src/kernel/fallback.ts index cc3da291c..576ccd701 100644 --- a/crates/trusted-server-js/lib/src/kernel/fallback.ts +++ b/crates/trusted-server-js/lib/src/kernel/fallback.ts @@ -1,5 +1,5 @@ -import { parseBrowserAuctionProjectionV1 } from '../core/contracts/auction_projection'; import { validateRequestAdsOptions } from '../core/contracts/request_ads'; +import { ownDataObject } from '../core/contracts/auction_projection'; import { prepareProgrammaticAdUnits } from '../core/registry'; import type { BootManifestV1, TsjsBootV1 } from '../core/types'; @@ -16,8 +16,6 @@ const SAFE_PROJECTION = { bids: [], } as const; const RUNTIME_SRC_PATTERN = /^\/static\/tsjs=tsjs-unified\.min\.js\?v=[0-9a-f]{64}$/; -const FIRST_DISPLAY_SRC_PATTERN = - /^\/static\/tsjs=tsjs-first-display\.min\.js\?m=[0-9a-f]{4}&v=[0-9a-f]{64}$/; function exactHttpOrigin(candidate: unknown): string | undefined { if (typeof candidate !== 'string') return undefined; @@ -51,68 +49,6 @@ export function trustedArtifactOrigin(runtimeDocument: Document): string | undef } } -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; - const output: Record = Object.create(null) as Record; - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') return undefined; - 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 exactKeys(record: Record, keys: readonly string[]): boolean { - const actual = Object.keys(record); - return actual.length === keys.length && actual.every((key) => keys.includes(key)); -} - -function snapshotOwnArray(value: unknown, maximum: number): readonly unknown[] | undefined { - try { - if (!Array.isArray(value) || value.length > maximum) { - return undefined; - } - if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; - const names = Object.getOwnPropertyNames(value); - if (names.length !== value.length + 1 || !names.includes('length')) return undefined; - const copy: unknown[] = []; - for (let index = 0; index < value.length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; - copy.push(descriptor.value); - } - return copy; - } catch { - return undefined; - } -} - -function snapshotFirstDisplay(candidate: unknown): BootManifestV1['firstDisplay'] | undefined { - if (candidate === null) return null; - const fields = ownDataRecord(candidate); - if (!fields || !exactKeys(fields, ['src', 'slices'])) return undefined; - const slices = snapshotOwnArray(fields.slices, 13); - if ( - typeof fields.src !== 'string' || - !FIRST_DISPLAY_SRC_PATTERN.test(fields.src) || - !slices || - slices.length === 0 || - slices[0] !== 'first_display' || - slices.some((id) => typeof id !== 'string') || - new Set(slices).size !== slices.length - ) { - return undefined; - } - return Object.freeze({ src: fields.src, slices: Object.freeze([...slices] as string[]) }); -} - function deepFreeze(value: T): Readonly { if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return value; for (const key of Reflect.ownKeys(value)) { @@ -122,11 +58,6 @@ function deepFreeze(value: T): Readonly { return Object.freeze(value); } -function readBootField(boot: unknown, key: string): unknown { - const record = ownDataRecord(boot); - return record?.[key]; -} - function captureTrustedArtifactSrc( runtimeDocument: Document, script: HTMLScriptElement, @@ -186,20 +117,19 @@ export function buildKernelBoot( candidate: unknown ): Readonly | undefined { try { - const record = ownDataRecord(candidate); + const record = ownDataObject(candidate, [ + 'abi', + 'releaseId', + 'manifest', + 'auctionProjection', + 'integrations', + 'creative', + 'diagnostics', + ]); if ( !record || Object.getPrototypeOf(candidate) !== Object.prototype || !Object.isFrozen(candidate) || - !exactKeys(record, [ - 'abi', - 'releaseId', - 'manifest', - 'auctionProjection', - 'integrations', - 'creative', - 'diagnostics', - ]) || record.abi !== 1 || record.releaseId !== releaseId || record.manifest !== manifest || @@ -220,21 +150,15 @@ export function buildKernelBoot( /** Build the immutable boot snapshot shared by every terminal fallback. */ export function buildFallbackBoot( releaseId: string, - candidate: unknown, trustedRuntimeSrc: string ): Readonly | undefined { if (!RUNTIME_SRC_PATTERN.test(trustedRuntimeSrc)) { return undefined; } - const projection = - parseBrowserAuctionProjectionV1(readBootField(candidate, 'auctionProjection')) ?? - SAFE_PROJECTION; const manifest: BootManifestV1 = { version: 1, releaseId, - firstDisplay: - snapshotFirstDisplay(readBootField(readBootField(candidate, 'manifest'), 'firstDisplay')) ?? - null, + firstDisplay: null, runtimeSrc: trustedRuntimeSrc, integrations: [], }; @@ -242,7 +166,7 @@ export function buildFallbackBoot( abi: 1, releaseId, manifest, - auctionProjection: projection, + auctionProjection: SAFE_PROJECTION, integrations: { version: 1, entries: [] }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, @@ -252,7 +176,6 @@ export function buildFallbackBoot( export interface FallbackFieldsOptions { readonly releaseId: string; readonly reason: BootFailureReason; - readonly boot: unknown; readonly trustedRuntimeSrc: string; } @@ -260,17 +183,10 @@ export interface FallbackFieldsOptions { export function createFallbackFields( options: FallbackFieldsOptions ): Readonly> | undefined { - const boot = buildFallbackBoot(options.releaseId, options.boot, options.trustedRuntimeSrc); + const boot = buildFallbackBoot(options.releaseId, options.trustedRuntimeSrc); if (!boot) return undefined; - const projection = boot as { - readonly auctionProjection: { - readonly auction: { readonly results: readonly { slot: string }[] }; - }; - }; - const knownSlots = Object.freeze( - projection.auctionProjection.auction.results.map(({ slot }) => slot) - ); - const known = new Set(knownSlots); + const knownSlots = Object.freeze([] as string[]); + const known = new Set(); const fields: Record = {}; Object.defineProperties(fields, { version: { enumerable: true, value: '1.0.0' }, @@ -292,10 +208,8 @@ export function createFallbackFields( const selected = validated.slots ?? knownSlots; return deepFreeze({ slots: selected.map((slot) => - known.has(slot) - ? validated.aborted - ? { slot, path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' } - : { slot, path: 'primary', outcome: 'failed', reason: options.reason } + validated.aborted + ? { slot, path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' } : { slot, path: 'primary', outcome: 'failed', reason: 'slot_unresolved' } ), }); @@ -307,6 +221,7 @@ export function createFallbackFields( state: 'fallback', releaseId: options.releaseId, reason: options.reason, + initialDisplayCommitted: false, }), }, }); diff --git a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts index 72a682ef6..de77b224b 100644 --- a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts +++ b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts @@ -6,12 +6,13 @@ import { import { snapshotPersistentFirstDisplayAdoptionV1 } from '../shared/takeover'; import type { ReleaseConfigSourceV1 } from './release_catalog'; +import { EMBEDDED_MAX_MANIFEST_MODULES } from './contracts/release_capacity'; import { DisposableStack, type DisposeCallback } from './disposable'; import { trustedArtifactOrigin, type BootFailureReason } from './fallback'; const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; const RELEASE_ID = /^[0-9a-f]{64}$/; -const MAX_INTEGRATIONS = 20; +const MAX_INTEGRATIONS = EMBEDDED_MAX_MANIFEST_MODULES; const MAX_KNOWN_INTEGRATIONS = 256; const BOOT_DEADLINE_MS = 10_000; const EMPTY_BINDING = Object.freeze({}); @@ -420,7 +421,7 @@ function validateCatalog( return Object.freeze(catalog); } -function validateManifest( +export function validateRuntimeManifestV1( candidate: unknown, embeddedReleaseId: string, catalog: readonly IntegrationCatalogEntry[], @@ -687,7 +688,7 @@ class IntegrationRegistryOwner { : undefined; this.catalog = catalog ?? Object.freeze([]); this.manifestValue = catalog - ? validateManifest( + ? validateRuntimeManifestV1( options.manifest, options.releaseId, catalog, diff --git a/crates/trusted-server-js/lib/src/kernel/runtime.ts b/crates/trusted-server-js/lib/src/kernel/runtime.ts index a57249b2b..0b1ba5a5f 100644 --- a/crates/trusted-server-js/lib/src/kernel/runtime.ts +++ b/crates/trusted-server-js/lib/src/kernel/runtime.ts @@ -430,11 +430,7 @@ class RuntimeOwner implements Runtime { if (this.options.autoInstall && !this.installPromise) void this.install(); }, }); - this.fallbackBoot = buildFallbackBoot( - EMBEDDED_RELEASE_ID, - bootCandidate, - this.trustedRuntimeSrc - ); + this.fallbackBoot = buildFallbackBoot(EMBEDDED_RELEASE_ID, this.trustedRuntimeSrc); if (!this.fallbackBoot) throw new Error('Trusted runtime artifact source is unavailable'); if (this.registry.manifest) { this.kernelBoot = buildKernelBoot( @@ -498,11 +494,19 @@ class RuntimeOwner implements Runtime { if (!this.registry || !this.ingress || this.runtimeState !== 'installing') { return Promise.resolve(Object.freeze({ state: 'fallback', reason: 'bundle_partial' })); } + const notifyInstallComplete = (result: IntegrationInstallResult): void => { + try { + this.options.onInstallComplete?.(result); + } catch { + // Completion observation cannot change the already committed terminal state. + } + }; if (!this.kernelBoot) { this.registry.dispose(); const result = Object.freeze({ state: 'fallback' as const, reason: 'abi_mismatch' as const }); this.runtimeState = 'failed'; - this.commitFallback(result.reason); + if (!this.options.coordinateTakeover) this.commitFallback(result.reason); + notifyInstallComplete(result); this.installPromise = Promise.resolve(result); return this.installPromise; } @@ -567,13 +571,9 @@ class RuntimeOwner implements Runtime { this.runtimeState = 'kernel'; } else { this.runtimeState = 'failed'; - this.commitFallback(result.reason); - } - try { - this.options.onInstallComplete?.(result); - } catch { - // Completion observation cannot change the already committed terminal state. + if (!this.options.coordinateTakeover) this.commitFallback(result.reason); } + notifyInstallComplete(result); resolveInstall?.(result); }; if (!this.options.coordinateTakeover) { @@ -668,7 +668,6 @@ class RuntimeOwner implements Runtime { const fields = createFallbackFields({ releaseId: EMBEDDED_RELEASE_ID, reason, - boot: this.fallbackBoot, trustedRuntimeSrc: this.trustedRuntimeSrc, }); if (!fields) return; diff --git a/crates/trusted-server-js/lib/src/services/context.ts b/crates/trusted-server-js/lib/src/services/context.ts index 355aef783..98966c2c2 100644 --- a/crates/trusted-server-js/lib/src/services/context.ts +++ b/crates/trusted-server-js/lib/src/services/context.ts @@ -1,5 +1,5 @@ import type { DisposeCallback } from '../kernel/disposable'; -import { MAX_MANIFEST_MODULES } from '../kernel/release_catalog'; +import { EMBEDDED_MAX_MANIFEST_MODULES } from '../kernel/contracts/release_capacity'; const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; // Context shares the existing /auction request-body ceiling. The structural @@ -120,7 +120,7 @@ interface TraversalFrame { } function snapshotManifest(candidate: readonly string[]): readonly string[] { - if (!Object.isFrozen(candidate) || candidate.length > MAX_MANIFEST_MODULES) { + if (!Object.isFrozen(candidate) || candidate.length > EMBEDDED_MAX_MANIFEST_MODULES) { throw new TypeError('Auction context manifest must be frozen and bounded'); } const seen = new Set(); diff --git a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs index 072987da1..662db2f65 100644 --- a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs +++ b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs @@ -16,6 +16,7 @@ const EMPTY_INTEGRATION_CONFIGS = Object.freeze({ version: 1, entries: Object.fr const EMPTY_INTEGRATION_CONFIG_DIGEST = createHash('sha256') .update(JSON.stringify(EMPTY_INTEGRATION_CONFIGS)) .digest('hex'); +const plain = (value) => JSON.parse(JSON.stringify(value)); function createDocument() { const dom = new JSDOM( @@ -86,14 +87,33 @@ function outline() { }; } -function evaluateWithInput(dom) { +function transport(bootValue = boot(), outlineValue = outline()) { + const integrity = { + version: 1, + projectionDigest: createHash('sha256') + .update(JSON.stringify(bootValue.auctionProjection)) + .digest('hex'), + integrationConfigDigest: createHash('sha256') + .update(JSON.stringify(bootValue.integrations)) + .digest('hex'), + }; + if (outlineValue) { + outlineValue.projectionDigest = integrity.projectionDigest; + outlineValue.integrationConfigDigest = integrity.integrationConfigDigest; + } + return { version: 1, boot: bootValue, integrity, outline: outlineValue }; +} + +function evaluateTransport(dom, value) { dom.window.eval( - `const __TSJS_SERVER_BOOT_INPUT_V1__={target:window.tsjs,boot:${JSON.stringify( - boot() - )},outline:${JSON.stringify(outline())}};${source}` + `const __TSJS_SERVER_BOOT_TRANSPORT_V1__=${JSON.stringify(JSON.stringify(value))};${source}` ); } +function evaluateWithInput(dom) { + evaluateTransport(dom, transport()); +} + test('generated bootstrap bytes are stamped exactly once and expose no callable global', () => { assert.equal(source.includes('__TSJS_RELEASE_ID_SENTINEL_V1__'), false); assert.equal(source.split(manifest.releaseId).length - 1, 1); @@ -109,7 +129,7 @@ test('generated bootstrap bytes are stamped exactly once and expose no callable }); test('generated bootstrap leaves the namespace untouched without exact server input', () => { - for (const input of ['', 'const __TSJS_SERVER_BOOT_INPUT_V1__={};']) { + for (const input of ['', 'const __TSJS_SERVER_BOOT_TRANSPORT_V1__={};']) { const { dom } = createDocument(); const target = { publisher: 'retained' }; dom.window.tsjs = target; @@ -130,11 +150,7 @@ test('generated bootstrap transfers the direct persistent watchdog to the select const directBoot = boot(); directBoot.manifest.firstDisplay = null; - dom.window.eval( - `const __TSJS_SERVER_BOOT_INPUT_V1__={target:window.tsjs,boot:${JSON.stringify( - directBoot - )},outline:null};${source}` - ); + evaluateTransport(dom, transport(directBoot, null)); const cancel = () => assert.fail('committed direct runtime must not be cancelled'); const complete = target._claimDirectRuntime(selected, cancel); @@ -146,6 +162,165 @@ test('generated bootstrap transfers the direct persistent watchdog to the select dom.window.close(); }); +test('generated bootstrap classifies a rejected claimed boot as an ABI mismatch', () => { + const { dom, selected } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + + evaluateTransport(dom, transport(directBoot, null)); + + const claim = target._claimBootSnapshot; + assert.equal(typeof claim, 'function'); + const claimed = claim(selected); + assert.equal(claimed.boot, target.boot); + claimed.complete('abi_mismatch'); + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + initialDisplayCommitted: false, + } + ); + dom.window.close(); +}); + +test('generated bootstrap reserves the boot claim across reentrant DOM authentication', () => { + const { dom, selected } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + + evaluateTransport(dom, transport(directBoot, null)); + + const claim = target._claimBootSnapshot; + const querySelectorAll = dom.window.document.querySelectorAll; + let nested; + let reentered = false; + Object.defineProperty(dom.window.document, 'querySelectorAll', { + configurable: true, + value(selector) { + if (!reentered) { + reentered = true; + nested = claim(selected); + } + return Reflect.apply(querySelectorAll, this, [selector]); + }, + }); + + const claimed = claim(selected); + nested?.complete('kernel'); + claimed.complete('abi_mismatch'); + + assert.equal(nested, undefined); + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + initialDisplayCommitted: false, + } + ); + dom.window.close(); +}); + +test('generated bootstrap completes a claimed boot without a reentrant realm callback', () => { + const { dom, selected } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + + evaluateTransport(dom, transport(directBoot, null)); + + const claimed = target._claimBootSnapshot(selected); + const includes = dom.window.Array.prototype.includes; + let reentered = false; + Object.defineProperty(dom.window.Array.prototype, 'includes', { + configurable: true, + value(value, fromIndex) { + if (!reentered) { + reentered = true; + claimed.complete('kernel'); + } + return Reflect.apply(includes, this, [value, fromIndex]); + }, + writable: true, + }); + + assert.doesNotThrow(() => claimed.complete('abi_mismatch')); + assert.equal(reentered, false); + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + initialDisplayCommitted: false, + } + ); + dom.window.close(); +}); + +test('generated takeover completion leaves terminal fallback ownership with bootstrap', () => { + const { dom, selected } = createDocument(); + const target = { que: [] }; + dom.window.tsjs = target; + evaluateWithInput(dom); + + const accepted = dom.window.document.createElement('iframe'); + accepted.id = 'accepted-first-display'; + dom.window.document.body.append(accepted); + const agent = { + state: 'painted', + snapshot: () => ({ initialDisplayCommitted: true }), + }; + const registration = { + abi: 1, + id: 'first_display', + releaseId: manifest.releaseId, + prepare: (host) => + Object.freeze({ + sliceHost: Object.freeze({}), + activate: () => host.options.onAgentReady(agent), + }), + }; + assert.equal(target._registerFirstDisplay.call(target, registration, selected), true); + + const runtime = dom.window.document.createElement('script'); + runtime.id = 'trustedserver-js-runtime'; + runtime.src = runtimeSrc; + dom.window.document.head.append(runtime); + Object.defineProperty(dom.window.document, 'currentScript', { + configurable: true, + value: runtime, + }); + const claimed = target._claimBootSnapshot(runtime); + assert.equal(claimed.boot, target.boot); + claimed.complete('bundle_partial'); + + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'bundle_partial', + initialDisplayCommitted: true, + } + ); + assert.equal(target.boot.manifest.firstDisplay.src, selectedSrc); + assert.equal(accepted.isConnected, true); + dom.window.close(); +}); + test('generated bootstrap commits one non-rendering terminal shell after registration failure', async () => { const { dom, selected } = createDocument(); const drained = []; @@ -190,18 +365,39 @@ test('generated bootstrap commits one non-rendering terminal shell after registr 0 ); assert.deepEqual(drained, [target, 'late']); - assert.throws(() => target.addAdUnits({}), { - name: 'TsjsUnavailableError', - code: 'runtime_unavailable', - releaseId: manifest.releaseId, - reason: 'abi_mismatch', + const malformedUnit = dom.window.eval("({code:'',mediaTypes:{}})"); + assert.throws(() => target.addAdUnits(malformedUnit), { + name: 'AdUnitRegistrationError', + code: 'invalid_code', }); - await assert.rejects(target.requestAds(), { + const validUnit = dom.window.eval( + "({code:'programmatic',mediaTypes:{banner:{sizes:[[300,250]]}}})" + ); + assert.throws(() => target.addAdUnits(validUnit), { name: 'TsjsUnavailableError', code: 'runtime_unavailable', releaseId: manifest.releaseId, reason: 'abi_mismatch', }); + assert.deepEqual(plain(await target.requestAds()), { slots: [] }); + const explicitOptions = dom.window.eval("({slots:['slot-1']})"); + assert.deepEqual(plain(await target.requestAds(explicitOptions)), { + slots: [{ slot: 'slot-1', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }], + }); + const controller = new dom.window.AbortController(); + controller.abort(); + const abortedOptions = dom.window.eval("({slots:['slot-1']})"); + abortedOptions.signal = controller.signal; + assert.deepEqual(plain(await target.requestAds(abortedOptions)), { + slots: [{ slot: 'slot-1', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + assert.deepEqual(plain(target.boot.auctionProjection), { + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }); + assert.deepEqual(plain(target.boot.integrations), { version: 1, entries: [] }); dom.window.close(); }); 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 8c5be3ab6..d0d029694 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 @@ -606,6 +606,50 @@ test('generated takeover transport owns branded render operations without GPT du } }); +test('generated bootstrap uses only the compact sealed transport parser', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + const sources = new Set(metrics.bootstrap.sources.map(({ file }) => file)); + + assert.equal(sources.has('src/core/contracts/server_boot_transport.ts'), true); + for (const forbidden of [ + 'src/core/contracts/boot.ts', + 'src/core/contracts/auction_projection.ts', + 'src/core/contracts/integration_configs.ts', + ]) { + assert.equal(sources.has(forbidden), false, `bootstrap must not reach ${forbidden}`); + } +}); + +test('persistent core consumes generated capacity without bundling the build catalog', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + const core = metrics.modules.find(({ file }) => file === 'tsjs-core.js'); + const sources = new Set(core.sources.map(({ file }) => file)); + const buildSource = fs.readFileSync(path.resolve(libDirectory, 'build-all.mjs'), 'utf8'); + const registrySource = fs.readFileSync( + path.resolve(libDirectory, 'src/kernel/integration_registry.ts'), + 'utf8' + ); + + assert.match( + buildSource, + /__TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__:\s*JSON\.stringify\(releaseCatalog\.length\)/u + ); + assert.match( + registrySource, + /import \{ EMBEDDED_MAX_MANIFEST_MODULES \} from '\.\/contracts\/release_capacity';/u + ); + assert.match(registrySource, /const MAX_INTEGRATIONS = EMBEDDED_MAX_MANIFEST_MODULES;/u); + assert.doesNotMatch(registrySource, /const MAX_INTEGRATIONS = 20;/u); + // The generated scalar is substituted before bundling, so its declaration is + // intentionally tree-shaken along with the build-only catalog. + assert.equal(sources.has('src/kernel/contracts/release_capacity.ts'), false); + assert.equal(sources.has('src/kernel/release_catalog.ts'), false); +}); + test('messaging protocol binding is declared before schema initialization', () => { const source = fs.readFileSync(path.resolve(libDirectory, 'src/adapters/messaging.ts'), 'utf8'); const binding = @@ -717,6 +761,16 @@ test('co-bundled render_runtime and independent GPT start one branded display fl return freeze({ abi: 1, releaseId: '${release.releaseId}', + manifest: freeze({ + version: 1, + releaseId: '${release.releaseId}', + firstDisplay: null, + runtimeSrc: '/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}', + integrations: freeze([ + freeze({ id: 'render_runtime', phase: 'takeover' }), + freeze({ id: 'gpt', phase: 'takeover' }) + ]) + }), auctionProjection: freeze({ version: 1, auction: freeze({ @@ -748,16 +802,6 @@ test('co-bundled render_runtime and independent GPT start one branded display fl version: 1, renderTraceOverlay: false, gpt: freeze({ active: false }) - }), - manifest: freeze({ - version: 1, - releaseId: '${release.releaseId}', - firstDisplay: null, - runtimeSrc: '/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}', - integrations: freeze([ - freeze({ id: 'render_runtime', phase: 'takeover' }), - freeze({ id: 'gpt', phase: 'takeover' }) - ]) }) }); })()`); @@ -765,6 +809,20 @@ test('co-bundled render_runtime and independent GPT start one branded display fl runtimeScript.id = 'trustedserver-js'; runtimeScript.src = `/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}`; dom.window.document.head.append(runtimeScript); + const integrity = dom.window.Object.create(dom.window.Object.prototype); + dom.window.Object.assign(integrity, { + version: 1, + projectionDigest: createHash('sha256') + .update(JSON.stringify(boot.auctionProjection)) + .digest('hex'), + integrationConfigDigest: createHash('sha256') + .update(JSON.stringify(boot.integrations)) + .digest('hex'), + }); + dom.window.Object.freeze(integrity); + const claimedBoot = dom.window.Object.create(dom.window.Object.prototype); + dom.window.Object.assign(claimedBoot, { boot, integrity, complete: () => undefined }); + dom.window.Object.freeze(claimedBoot); Object.defineProperty(dom.window, 'tsjs', { configurable: true, value: {} }); Object.defineProperties(dom.window.tsjs, { boot: { configurable: true, enumerable: true, value: boot, writable: true }, @@ -775,7 +833,7 @@ test('co-bundled render_runtime and independent GPT start one branded display fl value: (source) => { if (source !== runtimeScript) return undefined; Reflect.deleteProperty(dom.window.tsjs, '_claimBootSnapshot'); - return boot; + return claimedBoot; }, writable: false, }, diff --git a/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs b/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs index 9d3730f54..576b389b2 100644 --- a/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs +++ b/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs @@ -554,7 +554,7 @@ git mv crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs crat test('the implementation plan contains no executable retired-source command', () => { const planSource = readFileSync(planPath, 'utf8'); - assert.equal(countExecutableShellFences(planSource), 54); + assert.equal(countExecutableShellFences(planSource), 61); assert.deepEqual(auditRetiredPlanCommands(planSource), []); const mutatedPlanSource = planSource.replace( diff --git a/crates/trusted-server-js/lib/test/core/bootstrap.test.ts b/crates/trusted-server-js/lib/test/core/bootstrap.test.ts index 8c59cb544..be3e643ca 100644 --- a/crates/trusted-server-js/lib/test/core/bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/core/bootstrap.test.ts @@ -8,6 +8,7 @@ import { snapshotIntegrationConfigsV1, } from '../../src/core/contracts/integration_configs'; import { snapshotBootstrapInputV1 } from '../../src/core/contracts/boot'; +import { snapshotServerBootTransportV1 } from '../../src/core/contracts/server_boot_transport'; const RELEASE = 'a'.repeat(64); @@ -90,6 +91,105 @@ function firstDisplayInput(): Record { return input; } +function transportInput(firstDisplay = false): Record { + const input = firstDisplay ? firstDisplayInput() : bootInput(); + const boot = input.boot as Record; + const integrationConfigDigest = createHash('sha256') + .update(JSON.stringify(boot.integrations)) + .digest('hex'); + const projectionDigest = createHash('sha256') + .update(JSON.stringify(boot.auctionProjection)) + .digest('hex'); + const outline = input.outline as Record | null; + if (outline) { + outline.projectionDigest = projectionDigest; + outline.integrationConfigDigest = integrationConfigDigest; + } + return { + version: 1, + boot, + integrity: { version: 1, projectionDigest, integrationConfigDigest }, + outline, + }; +} + +describe('sealed production boot transport', () => { + it.each([false, true])('parses and recursively freezes a fresh %s transport', (firstDisplay) => { + const original = transportInput(firstDisplay); + const accepted = snapshotServerBootTransportV1(JSON.stringify(original), RELEASE); + + expect(accepted).toBeDefined(); + expect(accepted).not.toBe(original); + expect(Object.isFrozen(accepted)).toBe(true); + expect(Object.isFrozen(accepted?.boot)).toBe(true); + expect(Object.isFrozen(accepted?.boot.auctionProjection)).toBe(true); + expect(Object.isFrozen(accepted?.integrity)).toBe(true); + expect(accepted?.outline === null || Object.isFrozen(accepted?.outline)).toBe(true); + }); + + it('accepts strings only and requires the exact root and always-present integrity', () => { + const valid = transportInput(); + expect(snapshotServerBootTransportV1(valid, RELEASE)).toBeUndefined(); + expect( + snapshotServerBootTransportV1(JSON.stringify({ ...valid, extra: true }), RELEASE) + ).toBeUndefined(); + const { integrity: _integrity, ...missingIntegrity } = valid; + expect( + snapshotServerBootTransportV1(JSON.stringify(missingIntegrity), RELEASE) + ).toBeUndefined(); + }); + + it('binds a non-null outline to the always-present integrity value', () => { + const value = transportInput(true); + (value.outline as Record).projectionDigest = 'f'.repeat(64); + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); + }); + + it('rejects a decoded transport above 10 MiB before parsing it', () => { + const encoded = `${JSON.stringify(transportInput())}${' '.repeat(10 * 1024 * 1024)}`; + expect(snapshotServerBootTransportV1(encoded, RELEASE)).toBeUndefined(); + }); + + it('rejects more than fourteen takeover manifest entries', () => { + const value = transportInput(); + const boot = value.boot as Record; + const manifest = boot.manifest as Record; + const integrations = Array.from({ length: 14 }, (_, index) => ({ + id: `takeover_${index}`, + phase: 'takeover', + })); + manifest.integrations = integrations; + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeDefined(); + + integrations.push({ id: 'takeover_14', phase: 'takeover' }); + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); + }); + + it('binds each deferred manifest source to its declared id', () => { + const value = transportInput(); + const boot = value.boot as Record; + const manifest = boot.manifest as Record; + manifest.integrations = [ + { id: 'render_runtime', phase: 'takeover' }, + { + id: 'gpt_later', + phase: 'deferred', + trigger: 'first_display_or_idle', + src: `/static/tsjs=tsjs-gpt_later.min.js?v=${'e'.repeat(64)}`, + }, + ]; + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeDefined(); + + const deferred = (manifest.integrations as Array>)[1]!; + deferred.src = `/static/tsjs=tsjs-evil.min.js?v=${'e'.repeat(64)}`; + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); + }); +}); + describe('bootstrap integration configuration admission', () => { it('captures one complete immutable boot copy without retaining the server literal', () => { const original = bootInput(); 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 8b2dea43c..71405ab1e 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,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { snapshotTsjsBootV1 } from '../../src/core/contracts/boot'; +import { + canonicalIntegrationConfigDigestV1, + sha256HexUtf8V1, +} from '../../src/core/contracts/integration_configs'; import type { TsjsApi, TsjsBootV1 } from '../../src/core/types'; const RELEASE = 'a'.repeat(64); @@ -32,6 +36,112 @@ function boot() { )!; } +function bootWithHostileConfigSurface() { + return snapshotTsjsBootV1( + { + ...boot(), + manifest: { + ...boot().manifest, + integrations: [ + { id: 'render_runtime', phase: 'takeover' }, + { id: 'aps', phase: 'takeover' }, + ], + }, + integrations: { + version: 1, + entries: [ + { + id: 'aps', + config: { + accessorValue: true, + custom: { value: 1 }, + symbolTarget: { value: 1 }, + sparse: [1, null, 3], + aliasLeft: { value: 1 }, + aliasRight: { value: 1 }, + mutable: { value: 1 }, + }, + }, + ], + }, + }, + RELEASE + )!; +} + +function freezeDataGraph(value: unknown, skipped?: object, seen = new Set()): void { + if (typeof value !== 'object' || value === null || value === skipped || seen.has(value)) return; + seen.add(value); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor && 'value' in descriptor) freezeDataGraph(descriptor.value, skipped, seen); + } + Object.freeze(value); +} + +type HostileBootKind = + | 'accessor' + | 'custom_prototype' + | 'symbol' + | 'sparse_array' + | 'repeated_alias' + | 'mutable_config' + | 'mutable_manifest' + | 'mutable_projection' + | 'mutable_diagnostics'; + +function hostileClaimedBoot(kind: HostileBootKind): { + readonly candidate: Readonly; + readonly integrity: ReturnType; + readonly assertUnobserved: () => void; +} { + const accepted = bootWithHostileConfigSurface(); + const candidate = JSON.parse(JSON.stringify(accepted)) as Record; + const integrations = candidate.integrations as { + entries: Array<{ config: Record }>; + }; + const config = integrations.entries[0]!.config; + let accessorReads = 0; + let skipped: object | undefined; + if (kind === 'accessor') { + Object.defineProperty(config, 'accessorValue', { + configurable: true, + enumerable: true, + get: () => { + accessorReads += 1; + return true; + }, + }); + } else if (kind === 'custom_prototype') { + Object.setPrototypeOf(config.custom as object, { inherited: true }); + } else if (kind === 'symbol') { + Object.defineProperty(config.symbolTarget as object, Symbol('hostile'), { + enumerable: true, + value: true, + }); + } else if (kind === 'sparse_array') { + const sparse = [1, null, 3]; + Reflect.deleteProperty(sparse, '1'); + config.sparse = sparse; + } else if (kind === 'repeated_alias') { + config.aliasRight = config.aliasLeft; + } else if (kind === 'mutable_config') { + skipped = config.mutable as object; + } else if (kind === 'mutable_manifest') { + skipped = (candidate.manifest as { integrations: object }).integrations; + } else if (kind === 'mutable_projection') { + skipped = (candidate.auctionProjection as { slots: object }).slots; + } else { + skipped = (candidate.diagnostics as { gpt: object }).gpt; + } + freezeDataGraph(candidate, skipped); + return { + candidate: candidate as unknown as Readonly, + integrity: bootIntegrity(accepted), + assertUnobserved: () => expect(accessorReads).toBe(0), + }; +} + async function loadMinimalProductionRuntime(): Promise { await import('../../src/composition/runtime_transport'); await import('../../src/integrations/render_runtime/index'); @@ -45,11 +155,24 @@ function installRuntimeScript(): void { Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); } -function installBootClaim(target: object, acceptedBoot: Readonly): void { - const claim = (source: unknown): Readonly | undefined => { +function bootIntegrity(acceptedBoot: Readonly) { + return Object.freeze({ + version: 1 as const, + projectionDigest: sha256HexUtf8V1(JSON.stringify(acceptedBoot.auctionProjection)), + integrationConfigDigest: canonicalIntegrationConfigDigestV1(acceptedBoot.integrations), + }); +} + +function installBootClaim( + target: object, + acceptedBoot: Readonly, + integrity = bootIntegrity(acceptedBoot) +): ReturnType { + const completed = vi.fn(); + const claim = (source: unknown) => { if (source !== document.currentScript) return undefined; Reflect.deleteProperty(target, '_claimBootSnapshot'); - return acceptedBoot; + return Object.freeze({ boot: acceptedBoot, integrity, complete: completed }); }; Object.defineProperty(target, '_claimBootSnapshot', { configurable: true, @@ -57,6 +180,7 @@ function installBootClaim(target: object, acceptedBoot: Readonly): v value: claim, writable: false, }); + return completed; } describe('core production bootstrap', () => { @@ -122,6 +246,102 @@ describe('core production bootstrap', () => { } }); + it.each(['projectionDigest', 'integrationConfigDigest'] as const)( + 'rejects a direct boot whose %s does not match before runtime publication', + async (field) => { + const preload = { boot: boot(), que: [] }; + const rejected = installBootClaim( + preload, + preload.boot, + Object.freeze({ ...bootIntegrity(preload.boot), [field]: 'f'.repeat(64) }) + ); + (window as unknown as { tsjs?: unknown }).tsjs = preload; + + await loadMinimalProductionRuntime(); + await Promise.resolve(); + + expect(preload).not.toHaveProperty('_internal'); + expect(preload).not.toHaveProperty('_registerIntegration'); + expect(preload).not.toHaveProperty('requestAds'); + expect(rejected).toHaveBeenCalledOnce(); + expect(rejected).toHaveBeenCalledWith('abi_mismatch'); + } + ); + + it('rejects mismatched integrity before consuming takeover or preparing runtime owners', async () => { + const script = document.currentScript as HTMLScriptElement; + script.id = 'trustedserver-js-runtime'; + const coordinate = vi.fn(); + const preload = { boot: boot(), que: [] }; + Object.defineProperty(preload, '_firstDisplayTakeover', { + configurable: true, + enumerable: false, + value: coordinate, + writable: false, + }); + const rejected = installBootClaim( + preload, + preload.boot, + Object.freeze({ ...bootIntegrity(preload.boot), projectionDigest: 'f'.repeat(64) }) + ); + (window as unknown as { tsjs?: unknown }).tsjs = preload; + + await loadMinimalProductionRuntime(); + await Promise.resolve(); + + expect(coordinate).not.toHaveBeenCalled(); + expect(preload).toHaveProperty('_firstDisplayTakeover'); + expect(preload).not.toHaveProperty('_registerIntegration'); + expect(preload).not.toHaveProperty('_internal'); + expect(rejected).toHaveBeenCalledOnce(); + expect(rejected).toHaveBeenCalledWith('abi_mismatch'); + }); + + it.each( + (['direct', 'takeover'] as const).flatMap((mode) => + ( + [ + 'accessor', + 'custom_prototype', + 'symbol', + 'sparse_array', + 'repeated_alias', + 'mutable_config', + 'mutable_manifest', + 'mutable_projection', + 'mutable_diagnostics', + ] as const + ).map((kind) => [mode, kind] as const) + ) + )('rejects a %s claimed boot with hostile %s data before effects', async (mode, kind) => { + const { candidate, integrity, assertUnobserved } = hostileClaimedBoot(kind); + const preload = { boot: candidate, que: [] }; + const coordinate = vi.fn(); + if (mode === 'takeover') { + Object.defineProperty(preload, '_firstDisplayTakeover', { + configurable: true, + enumerable: false, + value: coordinate, + writable: false, + }); + } + const rejected = installBootClaim(preload, candidate, integrity); + (window as unknown as { tsjs?: unknown }).tsjs = preload; + const { startProductionRuntime } = await import('../../src/core/index'); + const createComposition = vi.fn(() => ({ + runtime: { start: vi.fn(() => true) }, + })) as unknown as Parameters[0]; + + startProductionRuntime(createComposition); + + assertUnobserved(); + expect(createComposition).not.toHaveBeenCalled(); + expect(coordinate).not.toHaveBeenCalled(); + expect(preload).not.toHaveProperty('_internal'); + expect(rejected).toHaveBeenCalledOnce(); + expect(rejected).toHaveBeenCalledWith('abi_mismatch'); + }); + it('publishes no terminal API when the closure boot claim is malformed', async () => { const preload = { boot: boot(), que: [] }; Object.defineProperty(preload, '_claimBootSnapshot', { 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 b5a5ab793..0c60a8b1e 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -1,11 +1,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { snapshotTsjsBootV1 } from '../../src/core/contracts/boot'; +import { + canonicalIntegrationConfigDigestV1, + sha256HexUtf8V1, +} from '../../src/core/contracts/integration_configs'; import type { TsjsApi, TsjsBootV1 } from '../../src/core/types'; +import stringifyCorpus from '../fixtures/contracts/ecmascript-json-stringify-v1.json'; const RELEASE = 'a'.repeat(64); const RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; +describe('server JSON.stringify canonicalization corpus', () => { + it.each(stringifyCorpus)('$name', ({ input, expected }) => { + const canonical = JSON.stringify(JSON.parse(input)); + expect(canonical).toBe(expected); + expect(sha256HexUtf8V1(canonical)).toMatch(/^[0-9a-f]{64}$/); + }); +}); + function boot() { return snapshotTsjsBootV1( { @@ -52,7 +65,15 @@ function installBootClaim(target: object, acceptedBoot: Readonly): v value: (source: unknown) => { if (source !== document.currentScript) return undefined; Reflect.deleteProperty(target, '_claimBootSnapshot'); - return acceptedBoot; + return Object.freeze({ + boot: acceptedBoot, + complete: () => undefined, + integrity: Object.freeze({ + version: 1, + projectionDigest: sha256HexUtf8V1(JSON.stringify(acceptedBoot.auctionProjection)), + integrationConfigDigest: canonicalIntegrationConfigDigestV1(acceptedBoot.integrations), + }), + }); }, writable: false, }); diff --git a/crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json b/crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json new file mode 100644 index 000000000..418c2cfa2 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json @@ -0,0 +1,39 @@ +[ + { "name": "negative zero", "input": "{\"n\":-0.0}", "expected": "{\"n\":0}" }, + { "name": "integral float", "input": "{\"n\":1.0}", "expected": "{\"n\":1}" }, + { + "name": "upper decimal threshold", + "input": "{\"n\":1e20}", + "expected": "{\"n\":100000000000000000000}" + }, + { + "name": "upper exponent threshold", + "input": "{\"n\":1e21}", + "expected": "{\"n\":1e+21}" + }, + { + "name": "lower decimal threshold", + "input": "{\"n\":1e-6}", + "expected": "{\"n\":0.000001}" + }, + { + "name": "lower exponent threshold", + "input": "{\"n\":1e-7}", + "expected": "{\"n\":1e-7}" + }, + { + "name": "binary64 integer rounding", + "input": "{\"n\":9007199254740993}", + "expected": "{\"n\":9007199254740992}" + }, + { + "name": "integer index enumeration", + "input": "{\"a\":1,\"10\":10,\"2\":2,\"01\":1}", + "expected": "{\"2\":2,\"10\":10,\"a\":1,\"01\":1}" + }, + { + "name": "nested number and key ordering", + "input": "{\"values\":[-0.0,1e20,1e-7],\"3\":{\"20\":20,\"4\":4}}", + "expected": "{\"3\":{\"4\":4,\"20\":20},\"values\":[0,100000000000000000000,1e-7]}" + } +] 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 b545f6162..a899198d9 100644 --- a/crates/trusted-server-js/lib/test/kernel/fallback.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts @@ -157,44 +157,10 @@ describe('kernel boot creative ABI', () => { }); describe('terminal fallback boot manifest', () => { - it('uses the independently trusted takeover source when the manifest field is missing', () => { - const fallback = buildFallbackBoot( - RELEASE_ID, - { - ...boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), - manifest: { - version: 1, - releaseId: RELEASE_ID, - integrations: [], - }, - }, - TRUSTED_RUNTIME_SRC - ) as { readonly manifest: unknown }; - - expect(fallback.manifest).toEqual({ - version: 1, - releaseId: RELEASE_ID, - firstDisplay: null, - runtimeSrc: TRUSTED_RUNTIME_SRC, - integrations: [], - }); - }); - - it('uses the independently trusted takeover source when the manifest field is malformed', () => { - const fallback = buildFallbackBoot( - RELEASE_ID, - { - ...boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), - manifest: { - version: 1, - releaseId: RELEASE_ID, - firstDisplay: null, - runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'e'.repeat(64)}&publisher=1`, - integrations: [], - }, - }, - TRUSTED_RUNTIME_SRC - ) as { readonly manifest: unknown }; + it('constructs the exact safe manifest from the independently trusted takeover source', () => { + const fallback = buildFallbackBoot(RELEASE_ID, TRUSTED_RUNTIME_SRC) as { + readonly manifest: unknown; + }; expect(fallback.manifest).toEqual({ version: 1, @@ -206,22 +172,14 @@ describe('terminal fallback boot manifest', () => { }); it('refuses to construct a fallback boot without an independently trusted takeover source', () => { - expect( - buildFallbackBoot( - RELEASE_ID, - boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), - undefined as never - ) - ).toBeUndefined(); + expect(buildFallbackBoot(RELEASE_ID, undefined as never)).toBeUndefined(); }); it('publishes the exact phase-aware fallback manifest with the accepted takeover source', () => { const acceptedManifest = manifest(['render_runtime', 'diagnostics_presentation']); - const fallback = buildFallbackBoot( - RELEASE_ID, - boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), - acceptedManifest.runtimeSrc - ) as { readonly manifest: unknown }; + const fallback = buildFallbackBoot(RELEASE_ID, acceptedManifest.runtimeSrc) as { + readonly manifest: unknown; + }; expect(fallback.manifest).toEqual({ version: 1, diff --git a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts index 7ce3028c1..9edf54779 100644 --- a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { BootManifestV1 } from '../../src/core/types'; +import { EMBEDDED_MAX_MANIFEST_MODULES } from '../../src/kernel/contracts/release_capacity'; import { createIntegrationRegistry as createIntegrationRegistryOwner, snapshotIntegrationRegistration, @@ -535,7 +536,12 @@ describe('integration manifest and registration admission', () => { ], }, ], - ['over capacity', manifest(Array.from({ length: 21 }, (_, index) => `module_${index}`))], + [ + 'over generated capacity', + manifest( + Array.from({ length: EMBEDDED_MAX_MANIFEST_MODULES + 1 }, (_, index) => `module_${index}`) + ), + ], ])('rejects a malformed manifest: %s', async (_name, candidate) => { const registry = createIntegrationRegistry({ manifest: candidate, 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 f46881f54..b125a1730 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -1331,6 +1331,64 @@ describe('Runtime bootstrap owner', () => { ]); }); + it.each(['prepare', 'activate'] as const)( + 'returns a takeover %s failure to bootstrap without publishing a second fallback owner', + async (checkpoint) => { + const target = { que: [] as unknown[] }; + const onInstallComplete = vi.fn(); + const coordinateTakeover = vi.fn((prepared) => { + const candidate = takeoverHandoff(); + const handoff = prepared.validateHandoff(candidate.handoff, candidate.outline); + expect(handoff).toBeDefined(); + prepared.activate( + Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: handoff!, + identities: Object.freeze([]), + }) + ); + prepared.commit(); + }); + const fail = () => { + throw new Error(checkpoint); + }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + ...(checkpoint === 'prepare' ? { prepareOwner: fail } : { activateOwner: fail }), + coordinateTakeover, + onInstallComplete, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + + expect(runtime.state).toBe('failed'); + expect(onInstallComplete).toHaveBeenCalledOnce(); + expect(onInstallComplete).toHaveBeenCalledWith({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(target).not.toHaveProperty('version'); + expect(target).not.toHaveProperty('_internal'); + expect(target).not.toHaveProperty('requestAds'); + if (checkpoint === 'prepare') expect(coordinateTakeover).not.toHaveBeenCalled(); + else expect(coordinateTakeover).toHaveBeenCalledOnce(); + } + ); + it('stops activation when owner activation disposes the installing runtime', async () => { const activateCore = vi.fn(); const activateModule = vi.fn(); @@ -1494,6 +1552,7 @@ describe('Runtime bootstrap owner', () => { state: 'fallback', releaseId: RELEASE, reason, + initialDisplayCommitted: false, }); await expect( (target as unknown as { requestAds(options?: unknown): Promise }).requestAds() @@ -1756,6 +1815,7 @@ describe('Runtime bootstrap owner', () => { state: 'fallback', releaseId: RELEASE, reason: 'bundle_partial', + initialDisplayCommitted: false, }); }); @@ -2071,7 +2131,7 @@ describe('Runtime bootstrap owner', () => { expect(runtime.state).toBe('kernel'); }); - it('validates fallback calls and settles known, unknown, and aborted slots', async () => { + it('validates fallback calls against the exact empty safe projection', async () => { const target = {}; const runtime = createRuntime({ target, @@ -2089,9 +2149,10 @@ describe('Runtime bootstrap owner', () => { boot: unknown; }; + await expect(api.requestAds()).resolves.toEqual({ slots: [] }); await expect(api.requestAds({ slots: ['known', 'unknown'] })).resolves.toEqual({ slots: [ - { slot: 'known', path: 'primary', outcome: 'failed', reason: 'abi_mismatch' }, + { slot: 'known', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, { slot: 'unknown', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, ], }); @@ -2138,7 +2199,7 @@ describe('Runtime bootstrap owner', () => { }); }); - it('snapshots fallback boot before publisher mutation during installation', async () => { + it('never retains projected slot membership in fallback boot', async () => { const target = { boot: boot([{ slot: 'initial', outcome: 'no_bid' }]) }; const runtime = createRuntime({ target, @@ -2156,12 +2217,10 @@ describe('Runtime bootstrap owner', () => { boot: { auctionProjection: { auction: { results: readonly { slot: string }[] } } }; requestAds(options: unknown): Promise; }; - expect(api.boot.auctionProjection.auction.results).toEqual([ - { slot: 'initial', outcome: 'no_bid' }, - ]); + expect(api.boot.auctionProjection.auction.results).toEqual([]); await expect(api.requestAds({ slots: ['initial', 'mutated'] })).resolves.toEqual({ slots: [ - { slot: 'initial', path: 'primary', outcome: 'failed', reason: 'abi_mismatch' }, + { slot: 'initial', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, { slot: 'mutated', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, ], }); @@ -2446,7 +2505,7 @@ describe('Runtime bootstrap owner', () => { } ); - it('applies fallback slot collision and combined registry capacity validation', async () => { + it('does not retain server slot collisions or capacity in fallback validation', async () => { const makeFallback = async (slots: readonly string[]) => { const target = {}; const runtime = createRuntime({ @@ -2464,14 +2523,12 @@ describe('Runtime bootstrap owner', () => { const collision = await makeFallback(['server']); expect(() => collision({ code: 'server', mediaTypes: { banner: { sizes: [[300, 250]] } } }) - ).toThrow(expect.objectContaining({ code: 'slot_collision', unitIndex: 0 })); + ).toThrow(TsjsUnavailableError); const full = await makeFallback(Array.from({ length: 256 }, (_, index) => `slot-${index}`)); - const capacityError = thrownBy(() => + expect(() => full({ code: 'overflow', mediaTypes: { banner: { sizes: [[300, 250]] } } }) - ); - expect(capacityError).toMatchObject({ code: 'registry_capacity' }); - expect(Object.prototype.hasOwnProperty.call(capacityError, 'unitIndex')).toBe(false); + ).toThrow(TsjsUnavailableError); }); it('reports aggregate request overflow before combined registry capacity', async () => { diff --git a/crates/trusted-server-js/lib/vite.config.ts b/crates/trusted-server-js/lib/vite.config.ts index a28bcc092..9ed8544ee 100644 --- a/crates/trusted-server-js/lib/vite.config.ts +++ b/crates/trusted-server-js/lib/vite.config.ts @@ -2,4 +2,8 @@ import { defineConfig } from 'vite'; // Build configuration has moved to build-all.mjs. // This file is retained for vitest, which uses it for test resolution. -export default defineConfig({}); +export default defineConfig({ + define: { + __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__: JSON.stringify(20), + }, +}); 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 6cc1fbcd7..adbabbb19 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 @@ -1133,21 +1133,41 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled **Files:** - Create: `crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts` +- Create: `crates/trusted-server-js/lib/src/core/contracts/sha256.ts` +- Create: `crates/trusted-server-js/lib/src/core/contracts/bounded_string.ts` +- Create: `crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json` +- Move: `crates/trusted-server-js/lib/src/core/release_capacity.ts` to + `crates/trusted-server-js/lib/src/kernel/contracts/release_capacity.ts` +- Modify: `crates/trusted-server-core/src/creative.rs` +- Modify: `crates/trusted-server-core/src/html_processor.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/test_support.rs` - Modify: `crates/trusted-server-core/src/tsjs.rs` - Modify: `crates/trusted-server-js/lib/src/core/bootstrap.ts` +- Modify: `crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts` - Modify: `crates/trusted-server-js/lib/src/core/contracts/boot.ts` +- Modify: `crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts` +- Modify: `crates/trusted-server-js/lib/src/core/index.ts` +- Modify: `crates/trusted-server-js/lib/src/core/registry.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/fallback.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/release_catalog.ts` -- Modify: `crates/trusted-server-js/lib/src/shared/first_display_handoff.ts` -- Modify: `crates/trusted-server-js/lib/src/shared/takeover.ts` -- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/src/services/context.ts` +- Modify: `crates/trusted-server-js/lib/vite.config.ts` - Modify: `crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs` +- Modify: `crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts` +- Test: `crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts` +- Test: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts` - Test: `crates/trusted-server-js/lib/test/core/bootstrap.test.ts` +- Test: `crates/trusted-server-js/lib/test/core/index.test.ts` +- Test: `crates/trusted-server-js/lib/test/core/request.test.ts` +- Test: `crates/trusted-server-js/lib/test/kernel/fallback.test.ts` +- Test: `crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts` - Test: `crates/trusted-server-js/lib/test/kernel/runtime.test.ts` -- Test: `crates/trusted-server-js/lib/test/first_display/handoff.test.ts` -- Test: `crates/trusted-server-js/lib/test/first_display/takeover.test.ts` - Test: `crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs` - Test: `crates/trusted-server-js/lib/test/build/release-v1.test.mjs` +- Test: `crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs` - Test: colocated `crates/trusted-server-core/src/tsjs.rs` tests - [ ] **Step 1: Add the RED production-transport tests.** Require Rust to emit one @@ -1158,21 +1178,29 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled prove exact release, manifest, projection, carrier, creative, diagnostics, always-present boot-integrity digests, the exact `outline:null | TakeoverOutlineV1` union and non-null equality, escaping, and 10 - MiB decoded-size behavior. + MiB decoded-size behavior. Pin a shared Rust/JavaScript ECMAScript + `JSON.stringify` corpus covering negative zero, fixed/exponent thresholds, + IEEE-754 rounding, integer-index key ordering, and nested combinations so the + producer and verifier hash identical admitted bytes. - [ ] **Step 2: Add the RED compact-parser and graph tests.** Define the wished-for `snapshotServerBootTransportV1(payload, releaseId)` contract. A canonical valid string returns one recursively frozen fresh snapshot; malformed JSON, wrong or extra root keys, release mismatch, bad first-display URL/mask/slices, bad - runtime URL, missing/malformed boot integrity, outline parity/count/digest form - or integrity mismatch, invalid dedicated booleans, or an oversized string - returns `undefined` before effects. A direct-runtime snapshot has + runtime URL, more than 14 takeover entries, a deferred source not bound to its + declared id, missing/malformed boot integrity, outline parity/count/digest + form or integrity mismatch, invalid dedicated booleans, or an oversized string + returns `undefined` before effects. Boundary-valid takeover counts and exact + deferred id/source pairs remain accepted. A direct-runtime snapshot has `outline:null` but retains the integrity value. Add RED runtime/handoff tests proving direct boot recomputes both digests, and takeover requires recomputed boot = integrity = outline = handoff. Projection and integration-config mismatches independently produce `abi_mismatch` before preparation, - activation, or publication; a valid carrier succeeds on both paths. The release metafile - must prove `bootstrap` no longer reaches `core/contracts/boot.ts`, + activation, or publication; a valid carrier succeeds on both paths. At both + direct and takeover runtime boundaries, accessors are never invoked and custom + prototypes, symbols, sparse arrays, repeated aliases, or any mutable nested + manifest/projection/carrier/diagnostics value fail immediately. The release + metafile must prove `bootstrap` no longer reaches `core/contracts/boot.ts`, `core/contracts/auction_projection.ts`, or `core/contracts/integration_configs.ts`. @@ -1184,7 +1212,11 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled both `initialDisplayCommitted` values, exact FIFO queue drain, accepted-DOM preservation, no pending call, no artifact retry, and both `abi_mismatch`/`bundle_partial` classifications. Fallback never needs the full - projection validator. + projection validator, but `addAdUnits` still validates before refusing. The + registration validator obtains its bounded-string primitive from an + effect-free bootstrap-safe leaf rather than importing the auction-projection + graph. The persistent-core graph must consume a generated numeric manifest + capacity and exclude the build-time release catalog. - [ ] **Step 3: Run RED and inspect the expected failures.** @@ -1199,11 +1231,14 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled absent, and the bootstrap metafile still reaches the full validators. - [ ] **Step 4: Implement the minimal sealed transport.** Canonicalize and validate - in Rust exactly as today, serialize the payload once, escape it as a JS string, - and keep `window.tsjs` target acquisition outside the JSON. In the compact - parser, use the parsed fresh tree, perform only revision-43 critical - release/manifest/integrity/outline checks, recursively freeze, and return one - snapshot. + in Rust, normalize the admitted projection and every generic configuration to + ECMAScript `JSON.stringify`-equivalent bytes, hash and embed those exact bytes, + serialize the payload once, escape it as a JS string, and keep `window.tsjs` + target acquisition outside the JSON. Do not hash raw `serde_json` spellings: + negative zero, exponent formatting, and integer-index key enumeration are + observably different in JavaScript. In the compact parser, use the parsed fresh + tree, perform only revision-43 critical release/manifest/integrity/outline + checks, recursively freeze, and return one snapshot. Do not accept object form or move executable code into the payload. - [ ] **Step 5: Rewire only the production bootstrap.** Import the compact parser, @@ -1214,29 +1249,52 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled projection and integration-config SHA-256 digests before direct use or handoff adoption and compares them with the always-present integrity value; the controller validates only exact digest form and outline/integrity relations. + Reuse the existing pure manifest validator and full auction-projection parser + in persistent core, validate the generic carrier there without importing its + bootstrap-hostile object graph, and split the synchronous SHA-256 primitive + into an effect-free leaf shared by the two boundaries. On rejection after the + one-use exact-source-authenticated claim, invoke its one-use completion + capability with `abi_mismatch` during the current runtime-script task; do not + let the watchdog later relabel a validation failure as `bundle_partial`. The + claim reserves a closure-private `claiming` state before any mutable DOM or + realm authentication boundary, and completion validates with primitive string + comparisons and consumes its guard before invoking a handler; reentrant claim + or completion cannot obtain or consume the capability twice. In + takeover mode, the persistent runtime never publishes fallback itself: it + reports preparation/activation failure through the same completion capability, + and the bootstrap owner alone preserves accepted DOM, snapshots + `initialDisplayCommitted`, disposes the agent, and commits fallback. Direct mode + retains the persistent runtime's existing terminal-fallback ownership. - [ ] **Step 6: Run GREEN and adjacent hard-cutover checks.** ```bash cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" tsjs::tests + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" html_processor::tests + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" injects_tsjs_creative_when_body_present + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" c2_reader_seam_inserts_one_complete_hard_cutover_boot npm --prefix crates/trusted-server-js/lib test -- --run test/core/bootstrap.test.ts test/kernel/runtime.test.ts test/first_display npm --prefix crates/trusted-server-js/lib run typecheck npm --prefix crates/trusted-server-js/lib run lint 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 check:hard-cutover-absence + npx --prefix crates/trusted-server-integration-tests/browser playwright test --list tests/shared/tsjs-runtime.spec.ts tests/shared/creative-sandbox.spec.ts ``` - [ ] **Step 7: Measure rather than infer transfer improvement.** Read the generated bootstrap raw/gzip/Brotli values and its complete source-owner list. If the graph exclusions pass but the served GPT semantic interval cannot fit beneath the exact rc baseline after including its real inline payload, stop and revisit - the architecture instead of adding mangling or changing membership. + the architecture instead of adding mangling or changing membership. Generate + the manifest-entry capacity as a build constant so persistent core does not + import the release catalog for one number; assert both catalog exclusion and + intentional tree-shaking of the declaration module in the release metafile. - [ ] **Step 8: Commit.** ```bash - git add crates/trusted-server-core/src/tsjs.rs crates/trusted-server-js/lib/src/core/bootstrap.ts crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts crates/trusted-server-js/lib/src/core/contracts/boot.ts crates/trusted-server-js/lib/src/kernel/runtime.ts crates/trusted-server-js/lib/src/kernel/release_catalog.ts crates/trusted-server-js/lib/src/shared/first_display_handoff.ts crates/trusted-server-js/lib/src/shared/takeover.ts crates/trusted-server-js/lib/build-all.mjs crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs crates/trusted-server-js/lib/test/core/bootstrap.test.ts crates/trusted-server-js/lib/test/kernel/runtime.test.ts crates/trusted-server-js/lib/test/first_display/handoff.test.ts crates/trusted-server-js/lib/test/first_display/takeover.test.ts crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs crates/trusted-server-js/lib/test/build/release-v1.test.mjs + git add -A docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md crates/trusted-server-core/src/creative.rs crates/trusted-server-core/src/html_processor.rs crates/trusted-server-core/src/publisher.rs crates/trusted-server-core/src/test_support.rs crates/trusted-server-core/src/tsjs.rs crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts crates/trusted-server-js/lib/src/core crates/trusted-server-js/lib/src/kernel/contracts/release_capacity.ts crates/trusted-server-js/lib/src/kernel/fallback.ts crates/trusted-server-js/lib/src/kernel/integration_registry.ts crates/trusted-server-js/lib/src/kernel/runtime.ts crates/trusted-server-js/lib/src/services/context.ts crates/trusted-server-js/lib/vite.config.ts crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json crates/trusted-server-js/lib/test/core/bootstrap.test.ts crates/trusted-server-js/lib/test/core/index.test.ts crates/trusted-server-js/lib/test/core/request.test.ts crates/trusted-server-js/lib/test/kernel/fallback.test.ts crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts crates/trusted-server-js/lib/test/kernel/runtime.test.ts crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs crates/trusted-server-js/lib/test/build/release-v1.test.mjs crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs git commit -m "Seal the production TSJS boot transport" ``` 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 aea8aef9e..d936ffe0d 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 @@ -2659,8 +2659,13 @@ canonicalizer and embedded as `boot.auctionProjection`. It computes `integrity.integrationConfigDigest` as lowercase SHA-256 over the exact UTF-8 `JSON.stringify`-equivalent serialization of the ordered `IntegrationConfigsV1` embedded as `boot.integrations`; object insertion order is the wire order and no -insignificant whitespace is present. On an agent page Rust copies those exact values -into the outline rather than hashing again. The compact controller checks integrity +insignificant whitespace is present. Rust must normalize both admitted trees to +ECMAScript `JSON.stringify` spellings and enumeration order before hashing and +embedding; hashing `serde_json` output directly is invalid because negative zero, +fixed/exponent thresholds, IEEE-754 rounding, and integer-index object keys differ. +A shared Rust/JavaScript corpus pins those edge cases and nested combinations. On an +agent page Rust copies those exact values into the outline rather than hashing again. +The compact controller checks integrity and outline digest form and, when the outline is non-null, their exact equality, but does not bundle SHA-256 or the full domain validators. The first-display owner completely validates the batch, retains the always-present sealed integrity values, @@ -3006,7 +3011,8 @@ properties and therefore cannot preserve a source accessor, Proxy, symbol, custo prototype, sparse hole, or repeated object identity. Before any DOM, timer, queue, GPT, attribution, or registration effect, the parser requires the exact root keys and version, embedded release equality, exact critical boot keys, manifest release -and URL/mask/slice relationships, the always-present exact integrity shape and digest +and URL/mask/slice relationships, at most 14 takeover entries, every deferred source +bound to its declared module id, the always-present exact integrity shape and digest forms, null/non-null first-display and outline parity, outline release/slice/count/digest forms and digest equality with integrity, dedicated creative/diagnostics booleans, and the configured decoded-size bound. It recursively freezes the parsed tree, @@ -3019,6 +3025,16 @@ first-display base validates its complete immutable projection/batch before activation; every selected slice validates its attenuated product value before its effect; and persistent core performs the complete manifest, projection, carrier, creative, and diagnostics validation before a direct boot or takeover uses them. +If that post-claim validation fails, persistent core returns `abi_mismatch` through +the one-use completion capability returned by the one-use, +exact-source-authenticated closure claim during the current runtime script task; the +controller commits fallback immediately instead of allowing the load watchdog to +relabel the failure `bundle_partial`. Takeover preparation or activation failure is +reported through that same capability. In takeover mode only the bootstrap owner may +publish terminal fallback: it preserves accepted DOM, snapshots the agent's +`initialDisplayCommitted` state, disposes the provisional agent, and commits once. +The persistent runtime never publishes a competing takeover fallback. On a direct +page, the persistent runtime remains the terminal-fallback owner after a valid claim. The full object-form validators retain their existing hostile-object contract for runtime/public/test boundaries: exact `version`/`entries`, unique ordered ids, plain configs, finite JSON values, dense Arrays, own enumerable data properties, no @@ -3026,6 +3042,19 @@ accessors/symbols/custom prototypes/cycles/aliases, depth 16, 4,096 values, 4,09 keys/strings, 65,536-byte entries, and the 524,288-byte carrier. The inline bootstrap artifact must not import those full validators or the complete auction- projection parser merely to revalidate the trusted lexical producer. +The closure claim atomically reserves a private `claiming` state before it touches +any mutable DOM or realm authentication surface and rolls back only after a failed +authentication. Its completion capability admits outcomes through primitive string +comparisons, consumes its one-use guard before invoking the selected handler, and +therefore remains one-shot under reentrant claim and completion attempts. +The compact fallback's `addAdUnits` surface still runs the exact public registration +validator before it refuses a valid call. That validator imports its bounded-string +primitive from an effect-free bootstrap-safe leaf; it does not make bootstrap reach +the auction-projection parser. +The persistent core receives the manifest-entry capacity as a generated numeric +build constant; it does not import the build-time release catalog merely to learn +that bound. Substitution may tree-shake the declaration module completely, and the +release metafile must prove that the persistent core graph excludes the catalog. The release catalog binds every module id to exactly one config source: its product entry, `creative`, `diagnostics`, or none. The integration's generated exact typed From bfae9e26764ffbc34c2822d28179a4a6c7871aa0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:54:46 -0700 Subject: [PATCH 539/844] Validate sealed TSJS boot transport in Rust tests --- .../src/integrations/prebid.rs | 12 +++- crates/trusted-server-core/src/publisher.rs | 55 +++++++++++++------ 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 774ea7b00..8f3abca7d 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2715,7 +2715,7 @@ mod tests { }; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; - use crate::test_support::tests::create_test_settings; + use crate::test_support::tests::{bootstrap_transport, create_test_settings}; use base64::engine::general_purpose::STANDARD as TEST_BASE64_STANDARD; use bytes::Bytes; use http::Method; @@ -3284,7 +3284,15 @@ excluded_gam_ad_unit_path_suffixes = ["{suffix}"] let shim_index = processed .find("id=\"trustedserver-js\"") .expect("should inject one TSJS runtime tag"); - assert!(processed.contains(r#""id":"prebid","phase":"takeover""#)); + let transport = bootstrap_transport(&processed); + let manifest_integrations = transport["boot"]["manifest"]["integrations"] + .as_array() + .expect("manifest integrations should be an array"); + assert!( + manifest_integrations + .iter() + .any(|entry| { entry["id"] == "prebid" && entry["phase"] == "takeover" }) + ); assert!(!processed.contains("tsjs-prebid.min.js")); assert!( bundle_index < shim_index, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index bed26f874..ab04b43aa 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5518,6 +5518,19 @@ mod tests { } } + fn boot_auction_id(html: &str) -> String { + bootstrap_transport(html)["boot"]["auctionProjection"]["auction"]["auctionId"] + .as_str() + .expect("sealed auction projection should carry an auction id") + .to_owned() + } + + fn boot_gpt_diagnostics_active(html: &str) -> bool { + bootstrap_transport(html)["boot"]["diagnostics"]["gpt"]["active"] + .as_bool() + .expect("sealed diagnostics should carry the GPT activation state") + } + mod coordinated_cutover_projection_tests { use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -7077,7 +7090,7 @@ mod tests { "should not inject the removed activation flag" ); assert!( - html.contains(r#""gpt":{"active":true}"#), + boot_gpt_diagnostics_active(&html), "should activate diagnostics through immutable boot data" ); assert!(html.contains("tsjs-unified.min.js?v=")); @@ -9697,7 +9710,7 @@ mod tests { "https://origin.test-publisher.com/article?keep=a%2Fb" ); assert_eq!(result.outbound_cookie.as_deref(), Some("publisher=value")); - assert!(body.contains(r#""gpt":{"active":false}"#)); + assert!(!boot_gpt_diagnostics_active(&body)); assert!(!body.contains("tsjs-gpt_diagnostics.min.js")); assert!(!body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); assert_eq!(body.matches("history.replaceState").count(), 1); @@ -9715,7 +9728,7 @@ mod tests { .await; let active_body = response_body_string(active.response); assert_eq!(active.outbound_cookie.as_deref(), Some("publisher=value")); - assert!(active_body.contains(r#""gpt":{"active":true}"#)); + assert!(boot_gpt_diagnostics_active(&active_body)); assert!(active_body.contains("tsjs-unified.min.js?v=")); assert!(!active_body.contains("tsjs-gpt_diagnostics.min.js")); assert!(!active_body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); @@ -9737,7 +9750,7 @@ mod tests { "https://origin.test-publisher.com/article?keep=%2F" ); let disabled_body = response_body_string(disabled.response); - assert!(disabled_body.contains(r#""gpt":{"active":false}"#)); + assert!(!boot_gpt_diagnostics_active(&disabled_body)); assert!(!disabled_body.contains("tsjs-gpt_diagnostics.min.js")); assert!(!disabled_body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); assert_eq!(disabled_body.matches("history.replaceState").count(), 1); @@ -9753,13 +9766,15 @@ mod tests { ) .await; let body = response_body_string(result.response); + let transport = bootstrap_transport(&body); + let diagnostics = &transport["boot"]["diagnostics"]; assert!( - body.contains(r#""renderTraceOverlay":true"#), + diagnostics["renderTraceOverlay"] == true, "the exact server-owned trace cookie must populate DiagnosticsBootV1: {body}" ); assert_eq!( - body.matches(r#""diagnostics":{"version":1,"renderTraceOverlay":true"#) + body.matches("const __TSJS_SERVER_BOOT_TRANSPORT_V1__=") .count(), 1, "the immutable server boot must carry one active diagnostics object" @@ -9783,7 +9798,7 @@ mod tests { 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(r#""gpt":{"active":false}"#)); + assert!(!boot_gpt_diagnostics_active(&body)); assert!(!body.contains("tsjs-gpt_diagnostics.min.js")); assert!(!body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); assert!(!body.contains("history.replaceState")); @@ -12129,20 +12144,21 @@ mod tests { .expect("stream body with auction should process on async path"); let html = String::from_utf8(output).expect("should be valid UTF-8"); + let auction_id = boot_auction_id(&html); assert!( html.contains("hello"), "should preserve streamed HTML content. Got: {html}" ); assert!( - html.contains(r#""auctionId":"a1_"#), + auction_id.starts_with("a1_"), "the immutable pre-core boot must contain the collected auction projection with a browser-only auction id. Got: {html}" ); assert!( - !html.contains(&format!(r#""auctionId":"{}""#, auction_request.id)), + auction_id != auction_request.id, "the immutable pre-core boot must not expose the upstream auction id. Got: {html}" ); assert!( - !html.contains(r#""auctionId":"initial""#), + auction_id != "initial", "the safe empty projection must not replace a dispatched auction. Got: {html}" ); assert!(!html.contains(".adSlots")); @@ -12207,6 +12223,7 @@ mod tests { .expect("buffered multi-member gzip auction body should process"); let html = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); + let auction_id = boot_auction_id(&html); assert!( html.contains("hello"), "should decode the first gzip member. Got: {html}" @@ -12216,11 +12233,11 @@ mod tests { "should decode the second gzip member that a single-member decoder drops. Got: {html}" ); assert!( - html.contains(r#""auctionId":"a1_"#), + auction_id.starts_with("a1_"), "should emit the collected projection with a browser-only auction id before the head bundle. Got: {html}" ); assert!( - !html.contains(r#""auctionId":"test-auction""#), + auction_id != "test-auction", "should not expose the upstream auction id to the browser. Got: {html}" ); assert!(!html.contains(".bids=")); @@ -12569,20 +12586,21 @@ mod tests { let first = first_lazy_body_chunk(body); let html = String::from_utf8(first.to_vec()).expect("should be valid UTF-8"); + let auction_id = boot_auction_id(&html); assert!( html.contains("hello"), "the origin body should remain lazy after the pre-head collect. Got: {html}" ); assert!( - html.contains(r#""auctionId":"a1_"#), + auction_id.starts_with("a1_"), "the first head chunk must contain the exact collected projection with a browser-only auction id. Got: {html}" ); assert!( - !html.contains(r#""auctionId":"test-auction""#), + auction_id != "test-auction", "the first head chunk must not expose the upstream auction id. Got: {html}" ); assert!( - !html.contains(r#""auctionId":"initial""#), + auction_id != "initial", "a dispatched auction must not boot with the safe empty projection. Got: {html}" ); assert!(!html.contains(".adSlots")); @@ -13077,13 +13095,14 @@ mod tests { .to_vec(); let html = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); + let auction_id = boot_auction_id(&html); assert!( - html.contains(r#""auctionId":"a1_"#), + auction_id.starts_with("a1_"), "should collect the exact projection with a browser-only auction id before the compressed head. Got head: {}", &html[..html.len().min(500)] ); assert!( - !html.contains(r#""auctionId":"test-auction""#), + auction_id != "test-auction", "should not expose the upstream auction id in the compressed head. Got head: {}", &html[..html.len().min(500)] ); @@ -13144,7 +13163,7 @@ mod tests { let html = String::from_utf8(output).expect("should be valid UTF-8"); assert!( - html.contains(r#""auctionId":"initial","results":[]},"slots":[],"bids":[]"#), + boot_auction_id(&html) == "initial", "mixed-case HTML must use the HTML processor and inject the canonical boot projection. Got: {html}" ); assert!( From 558fae833ce1c2642da37d4ecab45a6c66072b91 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:59:36 -0700 Subject: [PATCH 540/844] Thin the APS first-display owner --- .../src/auction/formats.rs | 2 +- .../src/integrations/registry.rs | 74 +- crates/trusted-server-core/src/publisher.rs | 39 +- .../trusted-server-core/src/test_support.rs | 4 + crates/trusted-server-core/src/tsjs.rs | 105 +- .../browser/helpers/tsjs-fixture.ts | 172 +- .../tests/shared/aps-puc-lifecycle.spec.ts | 20 +- .../tests/shared/tsjs-performance.spec.ts | 16 +- .../browser/tests/shared/tsjs-runtime.spec.ts | 271 +++ crates/trusted-server-js/build.rs | 26 +- crates/trusted-server-js/lib/build-all.mjs | 27 +- .../lib/scripts/bundle-metrics.mjs | 14 +- .../lib/scripts/check-bundle-budgets.mjs | 29 +- .../lib/scripts/print-release-id.mjs | 2 +- .../lib/src/core/bootstrap.ts | 63 +- .../lib/src/core/contracts/boot.ts | 3 + .../core/contracts/server_boot_transport.ts | 3 + .../src/first_display/adm_render_bridge.ts | 361 --- .../lib/src/first_display/agent.ts | 102 +- .../lib/src/first_display/composition.ts | 7 + .../src/first_display/leaf/aps_protocol.ts | 89 +- .../lib/src/first_display/render_bridge.ts | 2017 ++++------------- .../lib/src/first_display/render_journal.ts | 1611 +++++++++++++ .../lib/src/first_display/slices/aps.ts | 2 +- .../lib/src/first_display/slices/creative.ts | 2 +- .../lib/src/first_display/slices/datadome.ts | 2 +- .../lib/src/first_display/slices/didomi.ts | 2 +- .../slices/google_tag_manager.ts | 2 +- .../lib/src/first_display/slices/gpt.ts | 2 +- .../lib/src/first_display/slices/lockr.ts | 2 +- .../lib/src/first_display/slices/osano.ts | 2 +- .../lib/src/first_display/slices/permutive.ts | 2 +- .../lib/src/first_display/slices/prebid.ts | 2 +- .../src/first_display/slices/render_owner.ts | 10 + .../src/first_display/slices/sourcepoint.ts | 2 +- .../lib/src/first_display/slices/testlight.ts | 2 +- .../src/integrations/render_runtime/module.ts | 11 +- .../src/kernel/contracts/puc_dynamic_owner.ts | 174 +- .../lib/src/kernel/integration_registry.ts | 2 +- .../lib/src/kernel/release_catalog.ts | 53 +- .../lib/src/shared/first_display_contracts.ts | 13 +- .../src/shared/first_display_registration.ts | 3 +- .../src/shared/first_display_transaction.ts | 83 +- .../test/build/generated-fallback.test.mjs | 50 + .../lib/test/build/release-v1.test.mjs | 100 +- .../lib/test/core/bootstrap.test.ts | 45 + .../first_display/adm_render_bridge.test.ts | 39 +- .../lib/test/first_display/contracts.test.ts | 32 + .../helpers/render_owner_composition.ts | 17 + .../test/first_display/render_bridge.test.ts | 130 +- .../lib/test/first_display/slices.test.ts | 75 +- .../test/first_display/transaction.test.ts | 72 +- .../render_runtime/module.test.ts | 59 + .../lib/test/kernel/release_catalog.test.ts | 59 +- .../lib/test/services/puc_bridge.test.ts | 117 +- crates/trusted-server-js/src/bundle.rs | 14 +- ...8-04-aps-tsjs-resilience-implementation.md | 58 +- ...s-render-fix-and-tsjs-resilience-design.md | 9 + .../validate-tsjs-performance-evidence.mjs | 16 +- 59 files changed, 3926 insertions(+), 2396 deletions(-) delete mode 100644 crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts create mode 100644 crates/trusted-server-js/lib/src/first_display/render_journal.ts create mode 100644 crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts create mode 100644 crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 69db694f8..cccd9c24f 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -354,7 +354,7 @@ pub(crate) mod coordinated_cutover_v1 { value.len() == 12 && value.bytes().all(is_base64url_byte) } - fn valid_renderer_reservation_id(value: &str) -> bool { + pub(crate) fn valid_renderer_reservation_id(value: &str) -> bool { value .strip_prefix("r1_") .is_some_and(|token| token.len() == 22 && token.bytes().all(is_base64url_byte)) diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 8af384ed6..ab7d21a40 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -915,40 +915,46 @@ fn first_display_static_transport_selections( let creative_guard = creative.enabled && (creative.click_guard || creative.render_guard); let mut selections = Vec::new(); for gpt_participates in [false, true] { - for aps_participates in aps_values { - if *aps_participates && !gpt_participates { + for render_owner_participates in [false, true] { + if render_owner_participates && !gpt_participates { continue; } - for prebid_participates in prebid_values { - if *prebid_participates && !gpt_participates { + for aps_participates in aps_values { + if *aps_participates && !render_owner_participates { continue; } - let mut mask = 0_u16; - let mut slices = Vec::new(); - for (index, metadata) in trusted_server_js::all_first_display_metadata() - .into_iter() - .enumerate() - { - let selected = match metadata.include { - Some("eligible_batch") => true, - Some("gpt_initial") => gpt_participates, - Some("aps_participates") => *aps_participates, - Some("creative_guard") => creative_guard, - Some("prebid_participates") => *prebid_participates, - Some(predicate) => predicate - .strip_prefix("integration:") - .is_some_and(|id| inner.enabled_integration_ids.contains(&id)), - None => false, - }; - if selected { - mask |= 1_u16 << index; - slices.push(metadata.id); + for prebid_participates in prebid_values { + if *prebid_participates && !gpt_participates { + continue; + } + let mut mask = 0_u16; + let mut slices = Vec::new(); + for (index, metadata) in trusted_server_js::all_first_display_metadata() + .into_iter() + .enumerate() + { + let selected = match metadata.include { + Some("eligible_batch") => true, + Some("render_owner_participates") => render_owner_participates, + Some("gpt_initial") => gpt_participates, + Some("aps_participates") => *aps_participates, + Some("creative_guard") => creative_guard, + Some("prebid_participates") => *prebid_participates, + Some(predicate) => predicate + .strip_prefix("integration:") + .is_some_and(|id| inner.enabled_integration_ids.contains(&id)), + None => false, + }; + if selected { + mask |= 1_u16 << index; + slices.push(metadata.id); + } + } + if slices.first() == Some(&"first_display") + && trusted_server_js::first_display_mask_is_permitted(mask) + { + selections.push((mask, slices)); } - } - if slices.first() == Some(&"first_display") - && trusted_server_js::first_display_mask_is_permitted(mask) - { - selections.push((mask, slices)); } } } @@ -2707,12 +2713,20 @@ mod tests { .expect("catalog should contain slice") }; let fixed = bit("first_display") | bit("creative_initial"); + let owner = bit("render_owner_initial"); let gpt = bit("gpt_initial"); let aps = bit("aps_initial"); let prebid = bit("prebid_initial"); assert_eq!( masks, - vec![fixed, fixed | gpt, fixed | gpt | aps, fixed | gpt | prebid,], + vec![ + fixed, + fixed | gpt, + fixed | owner | gpt, + fixed | owner | aps | gpt, + fixed | gpt | prebid, + fixed | owner | gpt | prebid, + ], "registry should enumerate every configuration-reachable mask admitted by the fixed transfer ceiling" ); for mask in masks { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index ab04b43aa..a0ca80b48 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -11031,16 +11031,28 @@ mod tests { #[test] fn tsjs_dynamic_serves_only_the_exact_precomputed_first_display_mask_and_hash() { let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "creative", + &serde_json::json!({ + "enabled": false, + "click_guard": false, + "render_guard": false + }), + ) + .expect("should disable the creative guard"); settings .integrations .insert_config("gpt", &serde_json::json!({})) .expect("should enable GPT"); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); - let mask = *registry - .tsjs_first_display_masks() - .first() - .expect("GPT should permit one first-display mask"); + let mask = 0x0083; + assert!( + registry.tsjs_first_display_masks().contains(&mask), + "GPT must precompute the ADM render-owner mask" + ); let selected = trusted_server_js::all_first_display_ids() .into_iter() .enumerate() @@ -11110,6 +11122,25 @@ mod tests { "case {suffix}" ); } + + let owner_hash = + trusted_server_js::concatenated_first_display_hash(&["render_owner_initial"]) + .expect("render owner should have one generated component hash"); + let owner_request = build_request( + Method::GET, + &format!( + "https://publisher.example/static/tsjs=tsjs-render_owner_initial.min.js?v={owner_hash}" + ), + ); + let owner_response = + handle_tsjs_dynamic(&owner_request, ®istry, EdgeCacheHeader::SMaxageFallback) + .expect("standalone owner route should reject locally"); + assert_eq!(owner_response.status(), StatusCode::NOT_FOUND); + assert_eq!( + owner_response.headers().get(header::CACHE_CONTROL), + Some(&HeaderValue::from_static("no-store")) + ); + assert!(!owner_response.headers().contains_key(header::LOCATION)); } #[test] diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 205137155..a2ff76500 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -4,6 +4,10 @@ pub mod tests { use serde::Deserialize as _; /// Decode the one server-sealed lexical TSJS boot transport from generated HTML. + /// + /// # Panics + /// + /// Panics if the script omits the sealed transport or contains invalid JSON. #[must_use] pub fn bootstrap_transport(script: &str) -> serde_json::Value { let encoded = script diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 4f8ed0985..ad716513b 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -922,12 +922,23 @@ pub fn select_first_display_slices_v1( } match &bid.render_source { BidRenderSourceV1::Aps(_) => { + if !bid.renderer_reservation_id.as_deref().is_some_and( + crate::auction::formats::coordinated_cutover_v1::valid_renderer_reservation_id, + ) { + return None; + } if !enabled.contains("aps") { return None; } aps_participates = true; } - BidRenderSourceV1::Adm(_) => {} + BidRenderSourceV1::Adm(_) => { + if !bid.renderer_reservation_id.as_deref().is_some_and( + crate::auction::formats::coordinated_cutover_v1::valid_renderer_reservation_id, + ) { + return None; + } + } BidRenderSourceV1::PbsCache(_) => return None, } winner_count += 1; @@ -941,6 +952,7 @@ pub fn select_first_display_slices_v1( && config.creative.enabled && (config.creative.click_guard || config.creative.render_guard); let gpt_participates = winner_count > 0 || config.gam_attribution_enabled; + let render_owner_participates = winner_count > 0; let prebid_participates = enabled.contains("prebid") && projection.bids.iter().any(|bid| bid.provider == "prebid"); let mut mask = 0_u16; @@ -951,6 +963,7 @@ pub fn select_first_display_slices_v1( { let selected = match metadata.include { Some("eligible_batch") => true, + Some("render_owner_participates") => render_owner_participates, Some("aps_participates") => aps_participates, Some("creative_guard") => creative_guard, Some("gpt_initial") => gpt_participates, @@ -1643,7 +1656,7 @@ mod tests { "typed GAM attribution must select the GPT parser-time owner: {script}" ); assert!( - script.contains("m=0041"), + script.contains("m=0081"), "attribution-only first display must use the admitted GPT mask: {script}" ); } @@ -2118,7 +2131,7 @@ mod tests { targeting: std::collections::BTreeMap::new(), renderer_reservation_id: match render_source { BidRenderSourceV1::Aps(_) | BidRenderSourceV1::Adm(_) => { - Some("reservation-1".to_string()) + Some("r1_aaaaaaaaaaaaaaaaaaaaaa".to_string()) } BidRenderSourceV1::PbsCache(_) => None, }, @@ -2203,8 +2216,28 @@ mod tests { }, ) .expect("closed GPT ADM batch should select GPT initial ownership"); - assert_eq!(adm_selected.mask(), 0x0041); - assert_eq!(adm_selected.slices(), &["first_display", "gpt_initial"]); + assert_eq!(adm_selected.mask(), 0x0083); + assert_eq!( + adm_selected.slices(), + &["first_display", "render_owner_initial", "gpt_initial"] + ); + + for invalid in [None, Some("not-a-reservation".to_string())] { + let mut malformed = adm.clone(); + malformed.bids[0].renderer_reservation_id = invalid; + assert!( + select_first_display_slices_v1( + &malformed, + FirstDisplaySelectionConfigV1 { + enabled_integrations: &enabled, + creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, + }, + ) + .is_none(), + "a rendering mask must require one exact reservation capability" + ); + } assert!( select_first_display_slices_v1( @@ -2252,9 +2285,14 @@ mod tests { assert_eq!( creative_selected.slices(), - &["first_display", "creative_initial", "gpt_initial"] + &[ + "first_display", + "render_owner_initial", + "creative_initial", + "gpt_initial", + ] ); - assert_eq!(creative_selected.mask(), 0x0045); + assert_eq!(creative_selected.mask(), 0x008b); let aps_selected = select_first_display_slices_v1( &first_display_projection(Some(aps_source())), @@ -2267,9 +2305,29 @@ mod tests { .expect("bounded APS/GPT configuration should select the agent"); assert_eq!( aps_selected.slices(), - &["first_display", "aps_initial", "gpt_initial"] + &[ + "first_display", + "render_owner_initial", + "aps_initial", + "gpt_initial", + ] ); - assert_eq!(aps_selected.mask(), 0x0043); + assert_eq!(aps_selected.mask(), 0x0087); + + let aps_creative_selected = select_first_display_slices_v1( + &first_display_projection(Some(aps_source())), + FirstDisplaySelectionConfigV1 { + enabled_integrations: &["aps", "creative", "gpt"], + creative: CreativeBootConfigV1 { + enabled: true, + click_guard: true, + render_guard: false, + }, + gam_attribution_enabled: false, + }, + ) + .expect("bounded APS/creative/GPT configuration should select the agent"); + assert_eq!(aps_creative_selected.mask(), 0x008f); let mut prebid_projection = first_display_projection(Some(adm_source())); prebid_projection.bids[0].provider = "prebid".to_owned(); @@ -2284,9 +2342,14 @@ mod tests { .expect("bounded Prebid/GPT configuration should select the agent"); assert_eq!( prebid_selected.slices(), - &["first_display", "gpt_initial", "prebid_initial"] + &[ + "first_display", + "render_owner_initial", + "gpt_initial", + "prebid_initial", + ] ); - assert_eq!(prebid_selected.mask(), 0x0841); + assert_eq!(prebid_selected.mask(), 0x1083); } #[test] @@ -2307,10 +2370,26 @@ mod tests { ]; let mut projection = first_display_projection(Some(adm_source())); projection.bids[0].provider = "prebid".to_owned(); + assert!( + select_first_display_slices_v1( + &projection, + FirstDisplaySelectionConfigV1 { + enabled_integrations: &enabled, + creative: CreativeBootConfigV1 { + enabled: true, + click_guard: false, + render_guard: true, + }, + gam_attribution_enabled: false, + }, + ) + .is_none(), + "an oversized optional composition must fall through to persistent boot" + ); let selected = select_first_display_slices_v1( &projection, FirstDisplaySelectionConfigV1 { - enabled_integrations: &enabled, + enabled_integrations: &["creative", "gpt", "prebid"], creative: CreativeBootConfigV1 { enabled: true, click_guard: false, @@ -2319,7 +2398,7 @@ mod tests { gam_attribution_enabled: false, }, ) - .expect("the optimized closed composition should fit the generated budget"); + .expect("the bounded owner/creative/GPT/Prebid composition should fit"); assert!(trusted_server_js::first_display_mask_is_permitted( selected.mask() )); diff --git a/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts b/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts index b7ebc8ecc..10dd1595c 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts @@ -8,8 +8,13 @@ const DIST = resolve(TSJS_CRATE, "dist"); interface ReleaseArtifact { id: string; - role: "bootstrap" | "core" | "integration"; - phase: "takeover" | "deferred" | null; + role: + | "bootstrap" + | "core" + | "integration" + | "first_display_base" + | "first_display_slice"; + phase: "first_display" | "takeover" | "deferred" | null; file: string; } @@ -33,6 +38,43 @@ export interface RuntimeTsjsFixture { }; } +export interface FirstDisplayTsjsFixture { + releaseId: string; + bootstrapBody: string; + firstDisplayBody: string; + firstDisplaySrc: string; + runtimeBody: string; + runtimeSrc: string; + boot: Readonly>; + outline: Readonly>; +} + +interface FirstDisplayTsjsFixtureInput { + readonly auctionProjection: Readonly>; + readonly integrations: Readonly>; + readonly creative?: Readonly>; + readonly diagnostics?: Readonly>; + readonly firstDisplayIds: readonly string[]; + readonly takeoverIds: readonly string[]; +} + +const FIRST_DISPLAY_IDS = Object.freeze([ + "first_display", + "render_owner_initial", + "aps_initial", + "creative_initial", + "datadome_initial", + "didomi_initial", + "google_tag_manager_initial", + "gpt_initial", + "lockr_initial", + "osano_initial", + "permutive_initial", + "sourcepoint_initial", + "prebid_initial", + "testlight_initial", +] as const); + interface ServerBootDigestInputV1 { readonly auctionProjection: unknown; readonly integrations: unknown; @@ -73,14 +115,25 @@ function exactArtifact(release: Release, id: string): ReleaseArtifact { return matches[0]!; } -/** Build the same content-addressed persistent-runtime response and manifest as production. */ -export function runtimeTsjsFixture(ids: readonly string[]): RuntimeTsjsFixture { +function jsonDigest(value: unknown): string { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error("TSJS fixture value is not JSON"); + return createHash("sha256").update(encoded, "utf8").digest("hex"); +} + +function releaseFixture(): { release: Release; dist: string } { const release = JSON.parse( readFileSync(resolve(DIST, "tsjs-release-v1.json"), "utf8"), ) as Release; if (release.version !== 1 || !/^[0-9a-f]{64}$/u.test(release.releaseId)) { throw new Error("TSJS release manifest is invalid"); } + return { release, dist: DIST }; +} + +/** Build the same content-addressed persistent-runtime response and manifest as production. */ +export function runtimeTsjsFixture(ids: readonly string[]): RuntimeTsjsFixture { + const { release } = releaseFixture(); const artifacts = [exactArtifact(release, "core")].concat( ids.map((id) => exactArtifact(release, id)), ); @@ -113,6 +166,117 @@ export function runtimeTsjsFixture(ids: readonly string[]): RuntimeTsjsFixture { }; } +/** Build one exact parser-blocking first-display mask and its persistent takeover. */ +export function firstDisplayTsjsFixture( + input: FirstDisplayTsjsFixtureInput, +): FirstDisplayTsjsFixture { + const { release, dist } = releaseFixture(); + const firstDisplayArtifacts = input.firstDisplayIds.map((id, index) => { + const expectedIndex = FIRST_DISPLAY_IDS.indexOf( + id as (typeof FIRST_DISPLAY_IDS)[number], + ); + if ( + expectedIndex < 0 || + (index > 0 && + expectedIndex <= + FIRST_DISPLAY_IDS.indexOf( + input.firstDisplayIds[ + index - 1 + ] as (typeof FIRST_DISPLAY_IDS)[number], + )) + ) { + throw new Error(`TSJS first-display fixture order is invalid at ${id}`); + } + const artifact = exactArtifact(release, id); + if (artifact.phase !== "first_display") { + throw new Error(`TSJS fixture module ${id} is not first-display`); + } + return artifact; + }); + if (input.firstDisplayIds[0] !== "first_display") { + throw new Error("TSJS first-display fixture requires the base slice"); + } + const mask = input.firstDisplayIds.reduce((value, id) => { + const index = FIRST_DISPLAY_IDS.indexOf( + id as (typeof FIRST_DISPLAY_IDS)[number], + ); + return value | (1 << index); + }, 0); + const firstDisplayBody = firstDisplayArtifacts + .map((artifact) => readFileSync(resolve(dist, artifact.file), "utf8")) + .join(";\n"); + const firstDisplayHash = createHash("sha256") + .update(firstDisplayBody, "utf8") + .digest("hex"); + const firstDisplaySrc = `/static/tsjs=tsjs-first-display.min.js?m=${mask + .toString(16) + .padStart(4, "0")}&v=${firstDisplayHash}`; + const runtime = runtimeTsjsFixture(input.takeoverIds); + const manifest = { + ...runtime.manifest, + firstDisplay: { + src: firstDisplaySrc, + slices: [...input.firstDisplayIds], + }, + }; + const creative = input.creative ?? { + version: 1, + enabled: false, + clickGuard: false, + renderGuard: false, + }; + const diagnostics = input.diagnostics ?? { + version: 1, + renderTraceOverlay: false, + gpt: { active: false }, + }; + const boot = { + abi: 1, + releaseId: release.releaseId, + manifest, + auctionProjection: input.auctionProjection, + integrations: input.integrations, + creative, + diagnostics, + }; + const projectionDigest = jsonDigest(input.auctionProjection); + const integrationConfigDigest = jsonDigest(input.integrations); + const slots = input.auctionProjection.slots; + const outcomes = ( + input.auctionProjection.auction as Readonly> + ).results; + const bids = input.auctionProjection.bids; + if ( + !Array.isArray(slots) || + !Array.isArray(outcomes) || + !Array.isArray(bids) + ) { + throw new Error("TSJS first-display fixture projection is invalid"); + } + const outline = { + version: 1, + releaseId: release.releaseId, + generation: 1, + projectionDigest, + integrationConfigDigest, + slices: [...input.firstDisplayIds], + slotCount: slots.length, + outcomeCount: outcomes.length, + capabilities: [], + objectKinds: bids.length === 0 ? [] : ["gpt_slot", "dom_artifact"], + }; + return { + releaseId: release.releaseId, + bootstrapBody: runtime.bootstrapBody, + firstDisplayBody, + firstDisplaySrc, + runtimeBody: runtime.runtimeBody, + runtimeSrc: runtime.runtimeSrc, + boot, + outline, + }; +} + /** Serve a fixture from the exact content-addressed production route. */ export async function routeRuntimeTsjsFixture( page: Page, diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts index 9d8d543b3..5ea4ce5dc 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts @@ -38,12 +38,12 @@ function apsDescriptor() { creativeId: "fictional-creative", tagType: bid.ext.tagtype, creativeUrl: bid.ext.creativeurl, - width: bid.w, - height: bid.h, aaxResponse: Buffer.from( JSON.stringify({ seatbid: [{ bid: [bid] }] }), "utf8", ).toString("base64"), + width: bid.w, + height: bid.h, }; } @@ -184,7 +184,21 @@ async function openLifecyclePage( ).tsjs?._internal?.state, ), ) - .toBe("kernel"); + .toMatch(/^(?:kernel|fallback)$/u); + const runtimeState = await page.evaluate(() => { + const browserWindow = window as unknown as { + tsjs?: { _internal?: unknown }; + }; + return { + internal: browserWindow.tsjs?._internal, + marks: performance + .getEntriesByType("mark") + .map((entry) => ({ name: entry.name, startTime: entry.startTime })), + }; + }); + expect(runtimeState.internal, JSON.stringify(runtimeState)).toMatchObject({ + state: "kernel", + }); await expect .poll(() => page.evaluate(() => 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 375a2f193..0a7b212d4 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 @@ -362,11 +362,17 @@ function loadReleaseFixtureResources( performanceCase === "aps" ? ([ "first_display", + "render_owner_initial", "aps_initial", "creative_initial", "gpt_initial", ] as const) - : (["first_display", "creative_initial", "gpt_initial"] as const); + : ([ + "first_display", + "render_owner_initial", + "creative_initial", + "gpt_initial", + ] as const); const firstDisplayArtifacts = firstDisplayIds.map((id) => exactArtifact(release, id), ); @@ -376,7 +382,7 @@ function loadReleaseFixtureResources( .map((artifact) => readFileSync(resolve(dist, artifact.file), "utf8")) .join(";\n"); const selectedHash = createHash("sha256").update(selectedBody).digest("hex"); - const mask = performanceCase === "aps" ? "0047" : "0045"; + const mask = performanceCase === "aps" ? "008f" : "008b"; const selectedSrc = `/static/tsjs=tsjs-first-display.min.js?m=${mask}&v=${selectedHash}`; const deferred = new Map(); for (const id of DEFERRED_IDS) { @@ -432,8 +438,10 @@ function loadReleaseFixtureResources( expect(controllerDocument.match(/tsjs:bids-script/gu)).toHaveLength(2); expect(controllerDocument.match(/id="trustedserver-js"/gu)).toHaveLength(1); expect(controllerDocument).toContain(selectedTag); - expect(controllerDocument).toContain(`"releaseId":"${release.releaseId}"`); - expect(controllerDocument).toContain(`"runtimeSrc":"${runtimeSrc}"`); + expect(controllerDocument).toContain( + `\\"releaseId\\":\\"${release.releaseId}\\"`, + ); + expect(controllerDocument).toContain(`\\"runtimeSrc\\":\\"${runtimeSrc}\\"`); expect(controllerDocument).not.toContain( ``, ); 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 index b751fe447..b7a117f11 100644 --- 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 @@ -1,5 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; +import { installGptStub } from "../../helpers/gpt-stub.js"; import { + firstDisplayTsjsFixture, loadRuntimeTsjsFixture, runtimeTsjsFixture, serverBootTransportLiteralV1, @@ -8,6 +10,65 @@ import { const KERNEL_FIXTURE = runtimeTsjsFixture(["render_runtime"]); const FALLBACK_FIXTURE = runtimeTsjsFixture([]); +const FIRST_DISPLAY_SLOT = "first-display-owner-slot"; +const FIRST_DISPLAY_FIXTURE = firstDisplayTsjsFixture({ + firstDisplayIds: ["first_display", "render_owner_initial", "gpt_initial"], + takeoverIds: ["render_runtime", "gpt"], + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: "browser-first-display-owner", + results: [ + { + slot: FIRST_DISPLAY_SLOT, + outcome: "winner", + candidateId: "AAAAAAAAAAAA", + }, + ], + }, + slots: [ + { + slot: FIRST_DISPLAY_SLOT, + gamUnitPath: `/123/${FIRST_DISPLAY_SLOT}`, + divId: FIRST_DISPLAY_SLOT, + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [ + { + candidateId: "AAAAAAAAAAAA", + slot: FIRST_DISPLAY_SLOT, + provider: "fictional", + upstreamBidId: "first-display-owner-bid", + cpm: 1.25, + currency: "USD", + targeting: { hb_bidder: "fictional" }, + rendererReservationId: "r1_AAAAAAAAAAAAAAAAAAAAAA", + renderSource: { + type: "adm", + version: 1, + adm: "
fictional first-display creative
", + width: 300, + height: 250, + }, + }, + ], + }, + integrations: { + version: 1, + entries: [ + { + id: "gpt", + config: { + gamAttributionEnabled: false, + pageBidsEnabled: true, + }, + }, + ], + }, +}); function boot(fixture: RuntimeTsjsFixture) { return { @@ -61,7 +122,217 @@ async function openRuntimePage(page: Page) { await page.goto("https://runtime.test/fixture"); } +async function installFirstDisplayResourceProbe(page: Page): Promise { + await page.addInitScript(() => { + const events: Array<{ kind: string; beforePaint: boolean }> = []; + const record = (kind: string): void => { + events.push({ + kind, + beforePaint: + performance.getEntriesByName("tsjs:first-display-paint", "mark") + .length === 0, + }); + }; + + const nativeCreateElement = Document.prototype.createElement; + Document.prototype.createElement = function ( + qualifiedName: string, + options?: ElementCreationOptions, + ): HTMLElement { + const normalized = String(qualifiedName).toLowerCase(); + if (normalized === "script" || normalized === "link") { + record(`create:${normalized}`); + } + return nativeCreateElement.call(this, qualifiedName, options); + }; + + const nativeFetch = window.fetch; + window.fetch = ((...arguments_: Parameters) => { + record("fetch"); + return Reflect.apply(nativeFetch, window, arguments_); + }) as typeof window.fetch; + + const nativeXhrOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (...arguments_: unknown[]): void { + record("xhr"); + Reflect.apply(nativeXhrOpen, this, arguments_); + } as typeof XMLHttpRequest.prototype.open; + + const wrapConstructor = (name: "Worker" | "SharedWorker"): void => { + const nativeConstructor = Reflect.get(window, name); + if (typeof nativeConstructor !== "function") return; + const wrapped = function (this: unknown, ...arguments_: unknown[]) { + record(name.toLowerCase()); + return Reflect.construct(nativeConstructor, arguments_, new.target); + }; + Object.setPrototypeOf(wrapped, nativeConstructor); + Object.defineProperty(wrapped, "prototype", { + value: nativeConstructor.prototype, + }); + Reflect.set(window, name, wrapped); + }; + wrapConstructor("Worker"); + wrapConstructor("SharedWorker"); + + const nativeCreateObjectUrl = URL.createObjectURL; + URL.createObjectURL = ((object: Blob | MediaSource): string => { + record("blob-url"); + return Reflect.apply(nativeCreateObjectUrl, URL, [object]) as string; + }) as typeof URL.createObjectURL; + + Object.defineProperty(window, "__firstDisplayResourceProbe", { + value: () => events.map((entry) => ({ ...entry })), + }); + }); +} + test.describe("TSJS hard-cutover runtime", () => { + test("loads the render owner only inside one parser-blocking first-display request before paint", async ({ + page, + }) => { + await installGptStub(page); + await installFirstDisplayResourceProbe(page); + const firstDisplayUrl = new URL( + FIRST_DISPLAY_FIXTURE.firstDisplaySrc, + "https://runtime.test", + ).toString(); + const runtimeUrl = new URL( + FIRST_DISPLAY_FIXTURE.runtimeSrc, + "https://runtime.test", + ).toString(); + const firstDisplayRequests: string[] = []; + const allRequests: string[] = []; + const pageErrors: string[] = []; + const consoleMessages: string[] = []; + page.on("request", (request) => allRequests.push(request.url())); + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => + consoleMessages.push(`${message.type()}: ${message.text()}`), + ); + await page.route(firstDisplayUrl, (route) => { + firstDisplayRequests.push(route.request().url()); + return route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: FIRST_DISPLAY_FIXTURE.firstDisplayBody, + }); + }); + await page.route(runtimeUrl, (route) => + route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: FIRST_DISPLAY_FIXTURE.runtimeBody, + }), + ); + await page.route("https://runtime.test/first-display-owner", (route) => + route.fulfill({ + status: 200, + contentType: "text/html; charset=utf-8", + body: `
`, + }), + ); + + await page.goto("https://runtime.test/first-display-owner"); + await expect + .poll(() => + page.evaluate( + () => + performance.getEntriesByName("tsjs:first-display-paint", "mark") + .length, + ), + ) + .toBe(1); + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + tsjs?: { _internal?: { state?: string } }; + } + ).tsjs?._internal?.state, + ), + ) + .toMatch(/^(?:kernel|fallback)$/u); + const runtimeDiagnostic = await page.evaluate(() => ({ + internal: ( + window as unknown as { + tsjs?: { _internal?: unknown }; + } + ).tsjs?._internal, + marks: performance + .getEntriesByType("mark") + .map((entry) => ({ name: entry.name, startTime: entry.startTime })), + frames: [...document.querySelectorAll("iframe")].map((frame) => ({ + connected: frame.isConnected, + parent: frame.parentElement?.id ?? null, + title: frame.title, + })), + })); + expect( + runtimeDiagnostic.internal, + JSON.stringify({ + ...runtimeDiagnostic, + allRequests, + consoleMessages, + firstDisplayRequests, + pageErrors, + }), + ).toMatchObject({ state: "kernel" }); + + const observation = await page.evaluate((slotId) => { + const browserWindow = window as unknown as { + __firstDisplayParserObservation: { + async: boolean; + defer: boolean; + firstAction: number; + }; + __firstDisplayResourceProbe(): Array<{ + kind: string; + beforePaint: boolean; + }>; + }; + return { + parser: browserWindow.__firstDisplayParserObservation, + loaderCallsBeforePaint: browserWindow + .__firstDisplayResourceProbe() + .filter((entry) => entry.beforePaint) + .map((entry) => entry.kind), + loaderCallsAfterPaint: browserWindow + .__firstDisplayResourceProbe() + .filter((entry) => !entry.beforePaint) + .map((entry) => entry.kind), + firstDisplayScripts: document.querySelectorAll( + "script#trustedserver-js", + ).length, + creativeFrames: document.querySelectorAll( + `#${slotId} > iframe[title="Ad content"]`, + ).length, + }; + }, FIRST_DISPLAY_SLOT); + + expect(firstDisplayRequests).toEqual([firstDisplayUrl]); + expect(allRequests).toEqual([ + "https://runtime.test/first-display-owner", + firstDisplayUrl, + runtimeUrl, + ]); + expect( + allRequests.filter((url) => /tsjs-render_owner_initial/u.test(url)), + ).toEqual([]); + expect(observation.parser).toEqual({ + async: false, + defer: false, + firstAction: 1, + }); + expect(observation.loaderCallsBeforePaint).toEqual([]); + expect(observation.loaderCallsAfterPaint).toEqual(["create:script"]); + expect(observation.firstDisplayScripts).toBe(1); + expect(observation.creativeFrames).toBe(1); + }); + test("generated bootstrap transfers one direct-runtime watchdog to the persistent owner", async ({ page, }) => { diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index ebcaffd4f..7cd37b913 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -158,8 +158,8 @@ fn read_and_validate_catalog(dist_dir: &Path, release: &ReleaseManifest) -> Cata .collect::>(); assert_eq!( catalog.first_display.len(), - 13, - "tsjs: first-display catalog must contain base plus twelve slices" + 14, + "tsjs: first-display catalog must contain base plus thirteen slices" ); assert_eq!( first_display_artifacts.len(), @@ -181,6 +181,7 @@ fn read_and_validate_catalog(dist_dir: &Path, release: &ReleaseManifest) -> Cata .unwrap_or_else(|| panic!("tsjs: first-display catalog is missing {id}")) }; let gpt_mask = mask_bit("gpt_initial"); + let render_owner_mask = mask_bit("render_owner_initial"); let aps_mask = mask_bit("aps_initial"); let prebid_mask = mask_bit("prebid_initial"); for encoded in &catalog.permitted_first_display_masks { @@ -203,8 +204,12 @@ fn read_and_validate_catalog(dist_dir: &Path, release: &ReleaseManifest) -> Cata "tsjs: permitted first-display mask contains an unknown slice bit" ); assert!( - mask & (aps_mask | prebid_mask) == 0 || mask & gpt_mask != 0, - "tsjs: APS and Prebid participation require GPT initial ownership" + mask & (render_owner_mask | aps_mask | prebid_mask) == 0 || mask & gpt_mask != 0, + "tsjs: render-owner, APS, and Prebid participation require GPT initial ownership" + ); + assert!( + mask & aps_mask == 0 || mask & render_owner_mask != 0, + "tsjs: APS participation requires render-owner initial ownership" ); assert!( previous_mask.is_none_or(|previous| mask > previous), @@ -241,6 +246,7 @@ fn read_and_validate_catalog(dist_dir: &Path, release: &ReleaseManifest) -> Cata assert!(!module.obligation.is_empty()); assert!( module.include == "eligible_batch" + || module.include == "render_owner_participates" || module.include == "aps_participates" || module.include == "creative_guard" || module.include == "gpt_initial" @@ -295,15 +301,15 @@ fn read_and_validate_release(dist_dir: &Path) -> ReleaseManifest { ); assert_eq!( manifest.artifacts.len(), - 35, - "tsjs: release must contain bootstrap, thirteen first-display components, core, and twenty integrations" + 36, + "tsjs: release must contain bootstrap, fourteen first-display components, core, and twenty integrations" ); assert_eq!(manifest.artifacts[0].id, "bootstrap"); assert_eq!(manifest.artifacts[0].role, "bootstrap"); assert_eq!(manifest.artifacts[1].id, "first_display"); assert_eq!(manifest.artifacts[1].role, "first_display_base"); - assert_eq!(manifest.artifacts[14].id, "core"); - assert_eq!(manifest.artifacts[14].role, "core"); + assert_eq!(manifest.artifacts[15].id, "core"); + assert_eq!(manifest.artifacts[15].role, "core"); let mut canonical = Vec::new(); canonical.extend_from_slice(RELEASE_PREFIX); @@ -317,12 +323,12 @@ fn read_and_validate_release(dist_dir: &Path) -> ReleaseManifest { assert!(artifact.phase.is_none() && artifact.trigger.is_none()); } "first_display_base" | "first_display_slice" => { - assert!((1..=13).contains(&index)); + assert!((1..=14).contains(&index)); assert_eq!(artifact.phase.as_deref(), Some("first_display")); assert!(artifact.trigger.is_none()); } "integration" => { - assert!(index >= 15); + assert!(index >= 16); if integration_index < 14 { assert_eq!(artifact.phase.as_deref(), Some("takeover")); assert!(artifact.trigger.is_none()); diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 54dae073c..f5cab24ec 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -35,10 +35,14 @@ const bootstrapPrivateProperties = const firstDisplayBasePrivateProperties = /^(?:options|stateValue|agentBatch|slotResults|handoffOwner|mutationObserver|observedMutationRevision|displayWasCommitted|sealed|failed|pending|reasons|actionStarted|disposedDriver|handoffFinalized|committedArtifactsDetached|lastTimingMs|firstActionAtMs|terminalAtMs|paintAtMs|nextTraceSequence|acceptedTrace|recordTerminal|recordFirstAction|scheduleProtectedPaint|readTiming|captureHandoffData|disposeDriver|installNativeMutationIngress|observeDomMutations|isOwnedRuntimeInsertion|closeNativeMutationIngress|disposeNativeMutationIngress|claimTimer|completionTimer|controlRelease|directFrame|documentAccepted|documentAcceptancePending|documentRelease|documentTimer|documentTransferred|insertionTimer|pendingDocumentTerminal|ownerSource|ownerTicket|bootstrapNonce|rendererNonce|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|recordFailure)$/; -// APS adds one private render state machine inside its independently built slice. -// These fields never enter registration, message, or handoff objects. +// The source-neutral owner keeps these fields inside one independently built IIFE. +const firstDisplayRenderOwnerPrivateProperties = + /^(?:claimTimer|controlRelease|insertionTimer|ownerSource|ownerTicket|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|execution|originalCount|exact|exactShape|ports|port|source)$/; + +// APS keeps these renderer-specific fields inside its independently built IIFE. +// Cross-artifact strategy, protocol, callback, and artifact keys remain authored. const firstDisplayApsPrivateProperties = - /^(?:options|stateValue|agentBatch|slotResults|handoffOwner|mutationObserver|observedMutationRevision|displayWasCommitted|sealed|failed|pending|reasons|actionStarted|disposedDriver|handoffFinalized|committedArtifactsDetached|lastTimingMs|firstActionAtMs|terminalAtMs|paintAtMs|nextTraceSequence|acceptedTrace|recordTerminal|recordFirstAction|scheduleProtectedPaint|readTiming|captureHandoffData|disposeDriver|installNativeMutationIngress|observeDomMutations|isOwnedRuntimeInsertion|closeNativeMutationIngress|disposeNativeMutationIngress|claimTimer|completionTimer|controlRelease|directFrame|documentAccepted|documentAcceptancePending|documentRelease|documentTimer|documentTransferred|insertionTimer|pendingDocumentTerminal|ownerSource|ownerTicket|bootstrapNonce|rendererNonce|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|recordFailure|hostPositionOwned|previousHostPosition|previousHostPositionPriority|frameAttributes|frameContentWindow|frameSource|frameSourceDocument|bootstrapNavigated|bootstrapPolicy|bootstrapSource|originalCount|isReservationId|isLifecycleTicket|isBootstrapNonce|isRendererNonce|parseDocumentMessage|rendererUrl|sandbox|permanentSandbox|deadlines|insertionMs|documentAcceptanceMs|completionMs|ownerSettlementMs)$/; + /^(?:callbacks|overlay|active|accepted|bootstrapNavigated|bootstrapNonceInternal|bootstrapSource|completionTimer|cycle|documentAccepted|documentPort|documentRelease|documentTimer|frame|hostPositionOwned|pendingTerminal|previousHostPosition|previousHostPositionPriority|rendererNonceInternal|originalCount|exact|ports|publisherOrigin|rendererUrl|sandbox|permanentSandbox|deadlines|documentAcceptanceMs|completionMs|isBootstrapNonce|isRendererNonce|bootstrapPolicy|parseDocumentMessage|parseWindowMessage)$/; // These names are private to the GPT initial IIFE and never cross its protocol // receipt or handoff boundary. Keep the cross-artifact protocol keys authored. @@ -68,8 +72,8 @@ const releaseCatalog = catalogModule.RELEASE_CATALOG; const firstDisplayCatalog = catalogModule.FIRST_DISPLAY_CATALOG; catalogModule.validateReleaseCatalog(releaseCatalog); if (releaseCatalog.length !== 20) throw new Error('[build-all] Catalog must contain 20 rows'); -if (firstDisplayCatalog.length !== 13 || firstDisplayCatalog[0]?.id !== 'first_display') { - throw new Error('[build-all] First-display catalog must contain its base and twelve slices'); +if (firstDisplayCatalog.length !== 14 || firstDisplayCatalog[0]?.id !== 'first_display') { + throw new Error('[build-all] First-display catalog must contain its base and thirteen slices'); } const runtimeCatalog = releaseCatalog.map(({ id, phase, trigger, config, consumes, provides }) => ({ id, @@ -106,6 +110,7 @@ const sourceById = Object.freeze({ const firstDisplaySourceById = Object.freeze({ first_display: 'first_display/agent.ts', + render_owner_initial: 'first_display/slices/render_owner.ts', aps_initial: 'first_display/slices/aps.ts', creative_initial: 'first_display/slices/creative.ts', datadome_initial: 'first_display/slices/datadome.ts', @@ -206,11 +211,13 @@ async function buildArtifact(artifact) { ? { esbuild: { mangleProps: bootstrapPrivateProperties } } : artifact.id === 'first_display' ? { esbuild: { mangleProps: firstDisplayBasePrivateProperties } } - : artifact.id === 'aps_initial' - ? { esbuild: { mangleProps: firstDisplayApsPrivateProperties } } - : artifact.id === 'gpt_initial' - ? { esbuild: { mangleProps: firstDisplayGptPrivateProperties } } - : {}), + : artifact.id === 'render_owner_initial' + ? { esbuild: { mangleProps: firstDisplayRenderOwnerPrivateProperties } } + : artifact.id === 'aps_initial' + ? { esbuild: { mangleProps: firstDisplayApsPrivateProperties } } + : artifact.id === 'gpt_initial' + ? { esbuild: { mangleProps: firstDisplayGptPrivateProperties } } + : {}), define: { __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify(RELEASE_SENTINEL), __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: JSON.stringify(catalogIds), diff --git a/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs b/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs index dcf1339a8..d68cf8c7d 100644 --- a/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs +++ b/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs @@ -190,8 +190,8 @@ function concatenateBundleSet(files, contents) { /** Enumerate every reachable base mask; APS and Prebid participation require GPT. */ export function enumerateReachableFirstDisplayMasks(catalog) { - if (!Array.isArray(catalog) || catalog.length !== 13) { - fail('first-display catalog must contain exactly thirteen rows'); + if (!Array.isArray(catalog) || catalog.length !== 14) { + fail('first-display catalog must contain exactly fourteen rows'); } const ids = new Set(); const files = new Set(); @@ -213,17 +213,23 @@ export function enumerateReachableFirstDisplayMasks(catalog) { fail('first-display catalog bit zero must be the base'); } const gpt = catalog.find(({ id }) => id === 'gpt_initial'); + const renderOwner = catalog.find(({ id }) => id === 'render_owner_initial'); const aps = catalog.find(({ id }) => id === 'aps_initial'); const prebid = catalog.find(({ id }) => id === 'prebid_initial'); if (!gpt) fail('first-display catalog must contain gpt_initial'); - if (!aps || !prebid) fail('first-display catalog must contain APS and Prebid participation'); + if (!renderOwner || !aps || !prebid) { + fail('first-display catalog must contain render-owner, APS, and Prebid participation'); + } const required = 1 << catalog[0].maskBit; const maximumMask = 1 << catalog.length; const result = []; for (let mask = 0; mask < maximumMask; mask += 1) { if ((mask & required) !== required) continue; const hasGpt = (mask & (1 << gpt.maskBit)) !== 0; - if (!hasGpt && ((mask & (1 << aps.maskBit)) !== 0 || (mask & (1 << prebid.maskBit)) !== 0)) { + const hasRenderOwner = (mask & (1 << renderOwner.maskBit)) !== 0; + const hasAps = (mask & (1 << aps.maskBit)) !== 0; + const hasPrebid = (mask & (1 << prebid.maskBit)) !== 0; + if ((!hasGpt && (hasRenderOwner || hasAps || hasPrebid)) || (hasAps && !hasRenderOwner)) { continue; } const selected = catalog.filter(({ maskBit }) => (mask & (1 << maskBit)) !== 0); diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs index 758542484..7fe56293e 100644 --- a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -105,6 +105,8 @@ const CURRENT_PROVIDER_SOURCE_OWNERS = Object.freeze({ 'src/integrations/sourcepoint/consent.ts': 'sourcepoint_consent', }); const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ + 'src/first_display/render_journal.ts': 'render_owner_initial', + 'src/first_display/render_bridge.ts': 'aps_initial', 'src/core/adapters/gam_attribution.ts': 'bootstrap', 'src/core/bootstrap.ts': 'bootstrap', 'src/core/contracts/server_boot_transport.ts': 'bootstrap', @@ -267,7 +269,7 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/trace.ts': Object.freeze(['core', 'gpt_diagnostics']), 'src/kernel/contracts/message_protocol.ts': Object.freeze(['core', 'gpt']), 'src/kernel/contracts/release_capacity.ts': Object.freeze(['core']), - 'src/kernel/contracts/puc_dynamic_owner.ts': Object.freeze(['aps_initial', 'gpt']), + 'src/kernel/contracts/puc_dynamic_owner.ts': Object.freeze(['render_owner_initial', 'gpt']), 'src/kernel/diagnostics.ts': Object.freeze(['core']), 'src/kernel/disposable.ts': Object.freeze(['core', 'gpt']), 'src/kernel/fallback.ts': Object.freeze(['bootstrap', 'core']), @@ -637,8 +639,8 @@ function validateMetricSets(sets, label) { /** Validate semantic set membership against exact release artifact ownership. */ export function validateSemanticBundleSets(metrics, release, catalog) { if (catalog?.version !== 1) fail('catalog.version must equal 1'); - if (!Array.isArray(catalog.firstDisplay) || catalog.firstDisplay.length !== 13) { - fail('catalog.firstDisplay must contain the closed thirteen-row inventory'); + if (!Array.isArray(catalog.firstDisplay) || catalog.firstDisplay.length !== 14) { + fail('catalog.firstDisplay must contain the closed fourteen-row inventory'); } if (release?.version !== 1 || !Array.isArray(release.artifacts)) { fail('release inventory is invalid'); @@ -875,6 +877,21 @@ function validateCurrentSourceOwnership(currentGraph, release) { violations.push(`${owner} reaches first-display source ${source}`); } } + const policy = currentExactSourceOwner(source); + if (policy) { + if (!artifactIds.has(policy.owner)) { + violations.push(`${source} requires missing current owner ${policy.owner}`); + } else { + for (const owner of owners) { + if (owner !== policy.owner) { + violations.push(`${owner} reaches ${policy.owner}-owned source ${source}`); + } + } + if (!owners.includes(policy.owner)) { + violations.push(`${source} is not reached by required owner ${policy.owner}`); + } + } + } continue; } const sharedOwners = CURRENT_SHARED_SOURCE_OWNER_POLICIES[source]; @@ -1053,7 +1070,9 @@ export function buildCandidateArchitectureSizeReport({ const largestRaw = largestMask(permittedMasks, 'rawBytes'); const largestGzip = largestMask(permittedMasks, 'gzipBytes'); const largestBrotli = largestMask(permittedMasks, 'brotliBytes'); - const maximalFiles = release.artifacts.map(({ file }) => file); + const maximalFiles = release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .map(({ file }) => file); const maximalTotal = measureBundleSet(maximalFiles, currentArtifactContents); const named = { minimal: namedFirstDisplayMask(masks, ['first_display'], 'minimal'), @@ -1064,7 +1083,7 @@ export function buildCandidateArchitectureSizeReport({ ), aps: namedFirstDisplayMask( masks, - ['first_display', 'aps_initial', 'creative_initial', 'gpt_initial'], + ['first_display', 'render_owner_initial', 'aps_initial', 'creative_initial', 'gpt_initial'], 'APS' ), largestRaw, diff --git a/crates/trusted-server-js/lib/scripts/print-release-id.mjs b/crates/trusted-server-js/lib/scripts/print-release-id.mjs index f503ec1c2..13d37b845 100644 --- a/crates/trusted-server-js/lib/scripts/print-release-id.mjs +++ b/crates/trusted-server-js/lib/scripts/print-release-id.mjs @@ -7,7 +7,7 @@ import { computeReleaseId, RELEASE_SENTINEL } from './release-v1.mjs'; const directory = path.dirname(fileURLToPath(import.meta.url)); const distDirectory = path.resolve(directory, '..', '..', 'dist'); -const FIRST_DISPLAY_ARTIFACT_COUNT = 13; +const FIRST_DISPLAY_ARTIFACT_COUNT = 14; const CORE_ARTIFACT_INDEX = 1 + FIRST_DISPLAY_ARTIFACT_COUNT; const INTEGRATION_ARTIFACT_COUNT = 20; const RELEASE_ARTIFACT_COUNT = CORE_ARTIFACT_INDEX + 1 + INTEGRATION_ARTIFACT_COUNT; diff --git a/crates/trusted-server-js/lib/src/core/bootstrap.ts b/crates/trusted-server-js/lib/src/core/bootstrap.ts index acf383b25..34d091572 100644 --- a/crates/trusted-server-js/lib/src/core/bootstrap.ts +++ b/crates/trusted-server-js/lib/src/core/bootstrap.ts @@ -411,14 +411,14 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn }; const disposeAgent = (): void => { removePrivate('_registerFirstDisplay'); - for (let index = disposers.length - 1; index >= 0; index -= 1) { + while (disposers.length > 0) { + const dispose = disposers.pop(); try { - disposers[index]?.(); + dispose?.(); } catch { // Continue releasing every independently owned provisional effect. } } - disposers.length = 0; registrations.length = 0; agent = undefined; }; @@ -574,6 +574,9 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn register, }); } + if (id === 'render_owner_initial') { + return Object.freeze({ observe, register }); + } if (id === 'aps_initial') { return Object.freeze({ observe, publisherOrigin: location.origin, register }); } @@ -589,7 +592,12 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn }; const binding = (id: string): unknown => { const product = id.endsWith('_initial') ? id.slice(0, -'_initial'.length) : ''; - let config: unknown = id === 'creative_initial' ? boot.creative : undefined; + let config: unknown = + id === 'creative_initial' + ? boot.creative + : id === 'render_owner_initial' + ? Object.freeze({}) + : undefined; if (config === undefined && product !== '') { for (const entry of boot.integrations.entries) { if (entry.id === product) { @@ -654,6 +662,7 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn ) { return fail(); } + if (terminal || !current) return false; const fields = candidate as Readonly<{ abi: unknown; id: unknown; @@ -679,6 +688,7 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn let sliceHost: unknown; for (const component of registrations) { const value = component.prepare(component.id === 'first_display' ? host : sliceHost); + if (terminal || !current) return false; if (typeof value !== 'object' || value === null || !Object.isFrozen(value)) { return fail(); } @@ -693,23 +703,38 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn prepared.push(activate as (context: FirstDisplaySliceActivationContext) => void); } let afterActivate: (() => void) | undefined; - for (let index = 0; index < prepared.length; index += 1) { - prepared[index]!( - Object.freeze({ - own: (dispose: () => void) => { - if (typeof dispose !== 'function') throw new TypeError('tsjs'); - disposers.push(dispose); - }, - afterActivate: (callback: () => void) => { - if (index !== 0 || afterActivate || typeof callback !== 'function') { - throw new TypeError('tsjs'); - } - afterActivate = callback; - }, - }) - ); + let ownershipOpen = true; + try { + for (let index = 0; index < prepared.length; index += 1) { + prepared[index]!( + Object.freeze({ + own: (dispose: () => void) => { + if (!ownershipOpen || terminal || typeof dispose !== 'function') { + throw new TypeError('tsjs'); + } + disposers.push(dispose); + }, + afterActivate: (callback: () => void) => { + if ( + !ownershipOpen || + terminal || + index !== 0 || + afterActivate || + typeof callback !== 'function' + ) { + throw new TypeError('tsjs'); + } + afterActivate = callback; + }, + }) + ); + if (terminal || !current) return false; + } + } finally { + ownershipOpen = false; } afterActivate?.(); + if (terminal || !current) return false; return Boolean(agent && !terminal); } catch { return fail(); diff --git a/crates/trusted-server-js/lib/src/core/contracts/boot.ts b/crates/trusted-server-js/lib/src/core/contracts/boot.ts index ba67e4e6d..3b4b40a87 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/boot.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/boot.ts @@ -24,6 +24,7 @@ const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; const RELEASE_ID = /^[0-9a-f]{64}$/; const FIRST_DISPLAY_IDS = Object.freeze([ 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -160,6 +161,8 @@ function snapshotManifest(candidate: unknown, releaseId: string): BootManifestV1 if ( (mask & 1) === 0 || mask >>> FIRST_DISPLAY_IDS.length !== 0 || + ((mask & 4) !== 0 && (mask & 2) === 0) || + ((mask & 2) !== 0 && (mask & 128) === 0) || selected.length !== slices.length || selected.some((id, index) => id !== slices[index]) ) { diff --git a/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts index 49917c005..eb449e7f1 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts @@ -12,6 +12,7 @@ const RUNTIME_SRC = /^\/static\/tsjs=tsjs-unified\.min\.js\?v=[0-9a-f]{64}$/; const DEFERRED_SRC = /^\/static\/tsjs=tsjs-([a-z0-9][a-z0-9_-]{0,63})\.min\.js\?v=[0-9a-f]{64}$/; const FIRST_DISPLAY_IDS = Object.freeze([ 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -148,6 +149,8 @@ function validManifest(candidate: unknown, releaseId: string): BootManifestV1 | if ( (mask & 1) === 0 || mask >>> FIRST_DISPLAY_IDS.length !== 0 || + ((mask & 4) !== 0 && (mask & 2) === 0) || + ((mask & 2) !== 0 && (mask & 128) === 0) || selected.length !== slices.length || selected.some((id, index) => slices[index] !== id) ) { diff --git a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts deleted file mode 100644 index 19cc2d064..000000000 --- a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts +++ /dev/null @@ -1,361 +0,0 @@ -import type { - FirstDisplayGptBoundCycleV1, - FirstDisplayGptRenderResult, -} from './adapters/googletag'; -import type { FirstDisplayRenderBridgeV1 } from './driver'; - -const ADM_SANDBOX = - 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; -const ADM_LOAD_DEADLINE_MS = 5_000; -const RESERVATION_TTL_MS = 15 * 60 * 1_000; -const MAX_CAPABILITIES = 320; -const MAX_U32 = 4_294_967_295; -const RESERVATION_ID = /^r1_[A-Za-z0-9_-]{22}$/; - -export interface FirstDisplayAdmRenderBridgeOptionsV1 { - readonly clearTimer: (handle: unknown) => void; - readonly document: Document; - readonly now: () => number; - readonly onNativeMutation?: () => boolean; - readonly setTimer: (callback: () => void, delayMs: number) => unknown; -} - -interface AdmAttempt { - readonly cycle: FirstDisplayGptBoundCycleV1; - readonly expiresAtMs: number; - readonly onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void; - readonly ordinal: number; - active: boolean; - frame: HTMLIFrameElement | undefined; - timer: unknown; -} - -function validAdmCycle(cycle: FirstDisplayGptBoundCycleV1): boolean { - try { - return ( - cycle.bid.renderSource.type === 'adm' && - cycle.isCurrent() && - cycle.slotId === cycle.bid.slot && - cycle.slotId === cycle.placement.slot && - cycle.element.ownerDocument.getElementById(cycle.element.id) === cycle.element && - RESERVATION_ID.test(cycle.bid.rendererReservationId) - ); - } catch { - return false; - } -} - -function configureFrame(frame: HTMLIFrameElement, width: number, height: number): void { - frame.setAttribute('sandbox', ADM_SANDBOX); - frame.setAttribute('referrerpolicy', 'no-referrer'); - frame.setAttribute('width', String(width)); - frame.setAttribute('height', String(height)); - frame.setAttribute('scrolling', 'no'); - frame.setAttribute('frameborder', '0'); - frame.setAttribute('marginwidth', '0'); - frame.setAttribute('marginheight', '0'); - frame.setAttribute('title', 'Ad content'); - frame.setAttribute('aria-label', 'Advertisement'); - frame.setAttribute( - 'style', - `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` - ); -} - -/** Own only GPT-mediated ADM completion and the attributable empty-GAM fallback. */ -export function createFirstDisplayAdmRenderBridge( - options: FirstDisplayAdmRenderBridgeOptionsV1 -): FirstDisplayRenderBridgeV1 { - const attempts = new Map(); - const committedFrames = new Map< - string, - Readonly<{ frame: HTMLIFrameElement; host: HTMLElement; token: string }> - >(); - const timers = new Set(); - let nextReservationOrdinal = 1; - let lastNow = Number.NEGATIVE_INFINITY; - let sealed = false; - let ingressClosed = false; - let handoffCaptured = false; - let committedArtifactsDetached = false; - let disposed = false; - - const notifyNativeMutation = (): void => { - try { - options.onNativeMutation?.(); - } catch { - // Observation cannot alter the terminal renderer state. - } - }; - const readNow = (): number | undefined => { - try { - const value = options.now(); - if (!Number.isFinite(value) || value < lastNow) return undefined; - lastNow = value; - return value; - } catch { - return undefined; - } - }; - const clearOwnedTimer = (handle: unknown): void => { - if (handle === undefined || !timers.delete(handle)) return; - try { - options.clearTimer(handle); - } catch { - // The attempt latch remains authoritative. - } - }; - const arm = (callback: () => void, delayMs: number): unknown => { - try { - const timer = { handle: undefined as unknown }; - timer.handle = options.setTimer(() => { - if (!timers.delete(timer.handle)) return; - callback(); - }, delayMs); - if (timer.handle === undefined) return undefined; - timers.add(timer.handle); - return timer.handle; - } catch { - return undefined; - } - }; - const settle = ( - attempt: AdmAttempt, - result: 'accepted' | 'failed' | 'cancelled', - reason: string | null - ): boolean => { - if (!attempt.active) return false; - attempt.active = false; - clearOwnedTimer(attempt.timer); - attempt.timer = undefined; - const frame = attempt.frame; - attempt.frame = undefined; - if (result === 'accepted' && frame?.isConnected) { - committedFrames.set( - attempt.cycle.slotId, - Object.freeze({ - frame, - host: attempt.cycle.element, - token: attempt.cycle.bid.rendererReservationId, - }) - ); - } else if (frame) { - try { - frame.onload = null; - frame.onerror = null; - frame.remove(); - } catch { - // The attempt no longer owns a failed frame. - } - } - try { - attempt.onTerminal(result, result === 'accepted' ? null : reason); - } catch { - // A terminal observer cannot restore renderer authority. - } - notifyNativeMutation(); - return true; - }; - const fail = (attempt: AdmAttempt, reason: string): boolean => settle(attempt, 'failed', reason); - const retireCommitted = (cycle: FirstDisplayGptBoundCycleV1): boolean => { - if (disposed || committedArtifactsDetached) return false; - const entry = committedFrames.get(cycle.slotId); - if (!entry || entry.token !== cycle.bid.rendererReservationId) return false; - committedFrames.delete(cycle.slotId); - try { - entry.frame.onload = null; - entry.frame.onerror = null; - entry.frame.remove(); - } catch { - // The exact TS frame loses authority even when hostile DOM cleanup fails. - } - notifyNativeMutation(); - return true; - }; - const sweepCommittedArtifacts = (): number => { - if (disposed || committedArtifactsDetached) return 0; - let retired = 0; - for (const [slotId, entry] of [...committedFrames.entries()]) { - const current = (() => { - try { - return ( - entry.frame.ownerDocument === options.document && - entry.host.ownerDocument === options.document && - entry.frame.parentNode === entry.host && - entry.frame.isConnected && - entry.host.isConnected && - entry.host.id.length > 0 && - options.document.getElementById(entry.host.id) === entry.host - ); - } catch { - return false; - } - })(); - if (committedFrames.get(slotId) !== entry || current) continue; - committedFrames.delete(slotId); - try { - entry.frame.onload = null; - entry.frame.onerror = null; - entry.frame.remove(); - } catch { - // The exact TS frame loses authority even when hostile DOM cleanup fails. - } - retired += 1; - } - if (retired > 0) notifyNativeMutation(); - return retired; - }; - - return Object.freeze({ - bind: (cycle: FirstDisplayGptBoundCycleV1, onTerminal: AdmAttempt['onTerminal']): boolean => { - const reservationId = cycle.bid.rendererReservationId; - const observedAt = readNow(); - const ordinal = nextReservationOrdinal; - if ( - disposed || - sealed || - ingressClosed || - typeof onTerminal !== 'function' || - !validAdmCycle(cycle) || - attempts.size >= MAX_CAPABILITIES || - attempts.has(reservationId) || - observedAt === undefined || - ordinal > MAX_U32 - ) { - return false; - } - attempts.set(reservationId, { - active: true, - cycle, - expiresAtMs: observedAt + RESERVATION_TTL_MS, - frame: undefined, - onTerminal, - ordinal, - timer: undefined, - }); - nextReservationOrdinal += 1; - return true; - }, - recordGam: ( - cycle: FirstDisplayGptBoundCycleV1, - result: FirstDisplayGptRenderResult - ): boolean => { - const attempt = attempts.get(cycle.bid.rendererReservationId); - if (!attempt?.active || attempt.cycle !== cycle) return false; - if (result === 'nonempty_gam') return settle(attempt, 'accepted', null); - const source = cycle.bid.renderSource; - if (source.type !== 'adm') return fail(attempt, 'winner_not_renderable'); - try { - const frame = options.document.createElement('iframe'); - configureFrame(frame, source.width, source.height); - const intended = `${source.adm}`; - frame.onload = () => { - if ( - attempt.active && - attempt.frame === frame && - frame.parentNode === cycle.element && - frame.srcdoc === intended && - frame.getAttribute('src') === null - ) { - settle(attempt, 'accepted', null); - } - }; - frame.onerror = () => fail(attempt, 'adm_document_no_load'); - frame.srcdoc = intended; - attempt.frame = frame; - attempt.timer = arm(() => fail(attempt, 'adm_document_no_load'), ADM_LOAD_DEADLINE_MS); - if (attempt.timer === undefined) return fail(attempt, 'internal_error'); - cycle.element.appendChild(frame); - return true; - } catch { - return fail(attempt, 'adm_document_no_load'); - } - }, - recordFailure: (cycle: FirstDisplayGptBoundCycleV1): boolean => { - const attempt = attempts.get(cycle.bid.rendererReservationId); - return Boolean( - attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') - ); - }, - retire: retireCommitted, - sweepCommittedArtifacts, - sealTsAdmission: (): void => { - if (disposed || sealed || [...attempts.values()].some(({ active }) => active)) { - throw new TypeError('tsjs'); - } - sealed = true; - }, - closeIngress: (): boolean => { - if (disposed || ingressClosed || !sealed) return false; - ingressClosed = true; - for (const handle of [...timers]) clearOwnedTimer(handle); - return true; - }, - captureHandoff: () => { - if (disposed || !ingressClosed || handoffCaptured) return undefined; - sweepCommittedArtifacts(); - const observedAt = readNow(); - if (observedAt === undefined) return undefined; - handoffCaptured = true; - return Object.freeze({ - artifacts: Object.freeze( - [...committedFrames.entries()].map(([slotId, { frame, token }]) => - Object.freeze({ - hostPosition: null, - hostPositionPriority: null, - identity: frame, - kind: 'gpt_adm' as const, - owner: 'trusted_server' as const, - slotId, - token, - }) - ) - ), - clockEpochMs: observedAt, - nextReservationOrdinal, - nextTicketOrdinal: 1, - tombstones: Object.freeze( - [...attempts.entries()] - .filter(([, attempt]) => !attempt.active && attempt.expiresAtMs > observedAt) - .map(([value, attempt]) => - Object.freeze({ - kind: 'reservation' as const, - value, - expiresAtMs: attempt.expiresAtMs, - ordinal: attempt.ordinal, - }) - ) - ), - }); - }, - detachCommittedArtifacts: (): boolean => { - if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { - return false; - } - if (sweepCommittedArtifacts() > 0) return false; - committedArtifactsDetached = true; - return true; - }, - dispose: (): void => { - if (disposed) return; - disposed = true; - for (const attempt of attempts.values()) { - if (attempt.active) settle(attempt, 'cancelled', 'navigation_disposed'); - } - for (const handle of [...timers]) clearOwnedTimer(handle); - if (!committedArtifactsDetached) { - for (const { frame } of committedFrames.values()) { - try { - frame.onload = null; - frame.onerror = null; - frame.remove(); - } catch { - // A terminal owner cannot regain authority through a retained node. - } - } - } - committedFrames.clear(); - attempts.clear(); - }, - }); -} diff --git a/crates/trusted-server-js/lib/src/first_display/agent.ts b/crates/trusted-server-js/lib/src/first_display/agent.ts index 9b4717ef7..e9407d0fd 100644 --- a/crates/trusted-server-js/lib/src/first_display/agent.ts +++ b/crates/trusted-server-js/lib/src/first_display/agent.ts @@ -29,10 +29,13 @@ import type { FirstDisplayGptDiagnosticCycleV1, FirstDisplayGptHandoffCycleV1, } from './adapters/googletag'; -import { createFirstDisplayAdmRenderBridge } from './adm_render_bridge'; import { createFirstDisplayProjectedDriver, type FirstDisplayRenderBridgeV1 } from './driver'; import type { FirstDisplayApsProtocolV1 } from './leaf/aps_protocol'; import type { FirstDisplayGptProtocolV1 } from './leaf/gpt_protocol'; +import type { + FirstDisplayRenderOwnerOptionsV1, + FirstDisplayRenderOwnerProtocolV1, +} from './render_journal'; import { registerCurrentFirstDisplayComponent } from './registration_client'; import type { FirstDisplaySliceHost, @@ -45,11 +48,11 @@ import { type FirstDisplayBatchOutcomeV1, type FirstDisplayBatchV1, } from './leaf/projection'; -import type { FirstDisplayRenderBridgeOptionsV1 } from './render_bridge'; const MAX_U32 = 4_294_967_295; const BUNDLE_PARTIAL: BootFailureReason = 'bundle_partial'; const AUCTION_PROTOCOLS = ['aps', 'gpt', 'prebid'] as const; +const SLICE_PROTOCOLS = ['render_owner', ...AUCTION_PROTOCOLS] as const; const ACTION_KINDS = new Set(['gpt_adm', 'aps']); const TERMINAL_RESULTS = new Set(['accepted', 'failed', 'cancelled']); @@ -159,7 +162,7 @@ export interface FirstDisplayAgentRegistrationHostV1 { function createBrowserMessageChannel( browser: Window -): ReturnType { +): ReturnType { const constructor = Reflect.get(browser, 'MessageChannel'); if (typeof constructor !== 'function') { throw new TypeError('tsjs'); @@ -170,7 +173,7 @@ function createBrowserMessageChannel( if (typeof port1 !== 'object' || port1 === null || typeof port2 !== 'object' || port2 === null) { throw new TypeError('tsjs'); } - return { port1, port2 } as ReturnType; + return { port1, port2 } as ReturnType; } function fillBrowserRandom(browser: Window, bytes: Uint8Array): void { @@ -249,9 +252,12 @@ function protocolIdentity(candidate: unknown, expected: FirstDisplayAuctionProto } } +type FirstDisplayRegisteredProtocolId = + FirstDisplayAuctionProtocolId | FirstDisplayRenderOwnerProtocolV1['id']; + function fullProtocolIdentity( candidate: unknown, - expected: FirstDisplayAuctionProtocolId + expected: FirstDisplayRegisteredProtocolId ): boolean { try { if ( @@ -280,8 +286,8 @@ function fullProtocolIdentity( function captureProtocolRegistration( candidate: unknown, - protocolId: FirstDisplayAuctionProtocolId, - protocols: Map + protocolId: FirstDisplayRegisteredProtocolId, + protocols: Map ): unknown { try { if ( @@ -960,6 +966,52 @@ export function createFirstDisplayAgent(options: FirstDisplayAgentOptions): Firs }); } +function createNonRenderingBridge(now: () => number): FirstDisplayRenderBridgeV1 { + let phase = 0; + return Object.freeze({ + bind: () => false, + recordGam: () => false, + recordFailure: () => false, + retire: () => false, + sweepCommittedArtifacts: () => 0, + sealTsAdmission: (): void => { + if (phase !== 0) throw new TypeError('tsjs'); + phase = 1; + }, + closeIngress: (): boolean => { + if (phase !== 1) return false; + phase = 2; + return true; + }, + captureHandoff: () => { + if (phase !== 2) return undefined; + let observedAt: number; + try { + observedAt = now(); + } catch { + return undefined; + } + if (!Number.isFinite(observedAt)) return undefined; + phase = 3; + return Object.freeze({ + artifacts: Object.freeze([]), + clockEpochMs: observedAt, + nextReservationOrdinal: 1, + nextTicketOrdinal: 1, + tombstones: Object.freeze([]), + }); + }, + detachCommittedArtifacts: (): boolean => { + if (phase !== 3) return false; + phase = 4; + return true; + }, + dispose: (): void => { + phase = 5; + }, + }); +} + function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { try { if ( @@ -989,7 +1041,7 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { const options = descriptor.value as FirstDisplayAgentRegistrationHostV1['options']; const sliceBindings = bindingsDescriptor.value as (id: string) => unknown; const auctionProtocols = new Map(); - const fullProtocols = new Map(); + const fullProtocols = new Map(); const parserState = createFirstDisplayParserStateCollector(); let agent: FirstDisplayAgent | undefined; const sliceConfigValid = (id: OptionalFirstDisplaySliceId, candidate: unknown): boolean => { @@ -1013,7 +1065,7 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { throw new TypeError('tsjs'); } const protocolId = id.endsWith('_initial') - ? (id.slice(0, -'_initial'.length) as FirstDisplayAuctionProtocolId) + ? (id.slice(0, -'_initial'.length) as FirstDisplayRegisteredProtocolId) : undefined; const transported = sliceBindings(id); if ( @@ -1047,17 +1099,21 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { } ); const installed = install( - protocolId && AUCTION_PROTOCOLS.includes(protocolId) + protocolId && SLICE_PROTOCOLS.includes(protocolId) ? captureProtocolRegistration(candidate, protocolId, fullProtocols) : candidate, own, config.value ); - if (protocolId && AUCTION_PROTOCOLS.includes(protocolId)) { - if (auctionProtocols.has(protocolId) || !protocolIdentity(installed, protocolId)) { + if (protocolId && SLICE_PROTOCOLS.includes(protocolId)) { + if (!fullProtocolIdentity(installed, protocolId)) { throw new TypeError('tsjs'); } - auctionProtocols.set(protocolId, installed); + if (AUCTION_PROTOCOLS.includes(protocolId as FirstDisplayAuctionProtocolId)) { + const auctionProtocolId = protocolId as FirstDisplayAuctionProtocolId; + if (auctionProtocols.has(auctionProtocolId)) throw new TypeError('tsjs'); + auctionProtocols.set(auctionProtocolId, installed); + } } }, }); @@ -1075,13 +1131,21 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { if (!batch) throw new TypeError('tsjs'); const gpt = fullProtocols.get('gpt'); const aps = fullProtocols.get('aps'); + const renderOwner = fullProtocols.get('render_owner'); + const requiresRenderOwner = batch.outcomes.some(({ kind }) => ACTION_KINDS.has(kind)); if (batch.requiredProtocols.includes('gpt') && !fullProtocolIdentity(gpt, 'gpt')) { throw new TypeError('tsjs'); } if (batch.requiredProtocols.includes('aps') && !fullProtocolIdentity(aps, 'aps')) { throw new TypeError('tsjs'); } - const renderOptions: Omit = { + if ( + requiresRenderOwner !== fullProtocolIdentity(renderOwner, 'render_owner') || + (aps !== undefined && renderOwner === undefined) + ) { + throw new TypeError('tsjs'); + } + const renderOptions: FirstDisplayRenderOwnerOptionsV1 = { browser: options.gptInput.browser, clearTimer: options.gptInput.clearTimer, createChannel: () => createBrowserMessageChannel(options.gptInput.browser), @@ -1094,9 +1158,13 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { const apsProtocol = fullProtocolIdentity(aps, 'aps') ? (aps as FirstDisplayApsProtocolV1) : undefined; - const renderer = apsProtocol - ? apsProtocol.createRenderBridge(renderOptions) - : createFirstDisplayAdmRenderBridge(renderOptions); + const renderStrategy = apsProtocol?.createRenderStrategy(renderOptions); + const renderer = renderOwner + ? (renderOwner as FirstDisplayRenderOwnerProtocolV1).createRenderBridge( + renderOptions, + renderStrategy + ) + : createNonRenderingBridge(renderOptions.now); rendererOwner.value = renderer; const driver = createFirstDisplayProjectedDriver({ batch, diff --git a/crates/trusted-server-js/lib/src/first_display/composition.ts b/crates/trusted-server-js/lib/src/first_display/composition.ts index 01b6805f1..53e0c71fc 100644 --- a/crates/trusted-server-js/lib/src/first_display/composition.ts +++ b/crates/trusted-server-js/lib/src/first_display/composition.ts @@ -11,10 +11,12 @@ import { LOCKR_INITIAL_SLICE } from './slices/lockr'; import { OSANO_INITIAL_SLICE } from './slices/osano'; import { PERMUTIVE_INITIAL_SLICE } from './slices/permutive'; import { PREBID_INITIAL_SLICE } from './slices/prebid'; +import { RENDER_OWNER_INITIAL_SLICE } from './slices/render_owner'; import { SOURCEPOINT_INITIAL_SLICE } from './slices/sourcepoint'; import { TESTLIGHT_INITIAL_SLICE } from './slices/testlight'; export const INITIAL_SLICE_DEFINITIONS: readonly InitialSliceDefinition[] = Object.freeze([ + RENDER_OWNER_INITIAL_SLICE, APS_INITIAL_SLICE, CREATIVE_INITIAL_SLICE, DATADOME_INITIAL_SLICE, @@ -38,6 +40,11 @@ export function selectInitialSliceDefinitions( } const requested = new Set(selected.slice(1)); if (requested.size !== selected.length - 1) return undefined; + if ( + (requested.has('aps_initial') && !requested.has('render_owner_initial')) || + (requested.has('render_owner_initial') && !requested.has('gpt_initial')) + ) + return undefined; const definitions = INITIAL_SLICE_DEFINITIONS.filter(({ id }) => requested.has(id)); if (definitions.length !== requested.size) return undefined; const canonical = ['first_display', ...definitions.map(({ id }) => id)]; diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts index 1f2caeea8..d7ef24bc3 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts @@ -1,8 +1,9 @@ import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; -import { - createFirstDisplayRenderBridge, - type FirstDisplayRenderBridgeOptionsV1, -} from '../render_bridge'; +import { createFirstDisplayApsRenderStrategy } from '../render_bridge'; +import type { + FirstDisplayRenderOwnerOptionsV1, + FirstDisplayRenderStrategyV1, +} from '../render_journal'; export type FirstDisplayApsDocumentMessageV1 = | Readonly<{ kind: 'document_accepted' }> @@ -13,6 +14,14 @@ export type FirstDisplayApsDocumentMessageV1 = reason: 'descriptor_invalid' | 'runner_no_load' | 'runner_failed'; }>; +export type FirstDisplayApsWindowMessageV1 = + | Readonly<{ kind: 'bootstrap_ready'; bootstrap: string }> + | Readonly<{ + kind: 'container_ready'; + bootstrap: string; + renderer: string; + }>; + export interface FirstDisplayApsBootstrapPolicyV2 { readonly creativeOrigin: string; readonly tagType: 'iframe' | 'script'; @@ -26,13 +35,9 @@ export interface FirstDisplayApsProtocolV1 { readonly sandbox: string; readonly permanentSandbox: string; readonly deadlines: Readonly<{ - insertionMs: 1_000; documentAcceptanceMs: 3_000; completionMs: 10_000; - ownerSettlementMs: 20_000; }>; - readonly isReservationId: (candidate: unknown) => candidate is string; - readonly isLifecycleTicket: (candidate: unknown) => candidate is string; readonly isBootstrapNonce: (candidate: unknown) => candidate is string; readonly isRendererNonce: (candidate: unknown) => candidate is string; readonly bootstrapPolicy: ( @@ -42,9 +47,10 @@ export interface FirstDisplayApsProtocolV1 { candidate: unknown, expectedNonce: string ) => FirstDisplayApsDocumentMessageV1 | undefined; - readonly createRenderBridge: ( - options: Omit - ) => ReturnType; + readonly parseWindowMessage: (candidate: unknown) => FirstDisplayApsWindowMessageV1 | undefined; + readonly createRenderStrategy: ( + options: FirstDisplayRenderOwnerOptionsV1 + ) => FirstDisplayRenderStrategyV1; } interface ApsInitialBindings { @@ -53,7 +59,7 @@ interface ApsInitialBindings { readonly register: (protocol: FirstDisplayApsProtocolV1) => () => void; } -const OPAQUE_ID = /^[abrtn]1_[A-Za-z0-9_-]{22}$/; +const OPAQUE_ID = /^[bn]1_[A-Za-z0-9_-]{22}$/; const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/; const FAILURE_REASONS = new Set(['descriptor_invalid', 'runner_no_load', 'runner_failed']); const SANDBOX = @@ -130,10 +136,7 @@ function bindings(candidate: unknown): ApsInitialBindings | undefined { } } -function exactOpaqueId( - candidate: unknown, - prefix: 'r1_' | 't1_' | 'b1_' | 'n1_' -): candidate is string { +function exactOpaqueId(candidate: unknown, prefix: 'b1_' | 'n1_'): candidate is string { return typeof candidate === 'string' && candidate.startsWith(prefix) && OPAQUE_ID.test(candidate); } @@ -170,6 +173,51 @@ function parseDocumentMessage( return undefined; } +function parseWindowMessage(candidate: unknown): FirstDisplayApsWindowMessageV1 | undefined { + if (typeof candidate !== 'string' || candidate.length > 4_096) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(candidate) as unknown; + } catch { + return undefined; + } + const bootstrap = exactRecord(parsed, ['message', 'version', 'bootstrapNonce']); + if ( + bootstrap?.message === 'TS APS Bootstrap Ready' && + bootstrap.version === 1 && + exactOpaqueId(bootstrap.bootstrapNonce, 'b1_') && + candidate === + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: bootstrap.bootstrapNonce, + }) + ) { + return Object.freeze({ kind: 'bootstrap_ready', bootstrap: bootstrap.bootstrapNonce }); + } + const container = exactRecord(parsed, ['message', 'version', 'bootstrapNonce', 'rendererNonce']); + if ( + container?.message === 'TS APS Container Ready' && + container.version === 1 && + exactOpaqueId(container.bootstrapNonce, 'b1_') && + exactOpaqueId(container.rendererNonce, 'n1_') && + candidate === + JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce: container.bootstrapNonce, + rendererNonce: container.rendererNonce, + }) + ) { + return Object.freeze({ + kind: 'container_ready', + bootstrap: container.bootstrapNonce, + renderer: container.rendererNonce, + }); + } + return undefined; +} + function bootstrapPolicy( candidate: unknown, publisherOrigin: string @@ -243,19 +291,16 @@ export function installApsInitial( sandbox: SANDBOX, permanentSandbox: PERMANENT_SANDBOX, deadlines: Object.freeze({ - insertionMs: 1_000, documentAcceptanceMs: 3_000, completionMs: 10_000, - ownerSettlementMs: 20_000, }), - isReservationId: (input: unknown): input is string => exactOpaqueId(input, 'r1_'), - isLifecycleTicket: (input: unknown): input is string => exactOpaqueId(input, 't1_'), isBootstrapNonce: (input: unknown): input is string => exactOpaqueId(input, 'b1_'), isRendererNonce: (input: unknown): input is string => exactOpaqueId(input, 'n1_'), bootstrapPolicy: (renderer: unknown) => bootstrapPolicy(renderer, value.publisherOrigin), parseDocumentMessage, - createRenderBridge: (options: Omit) => - createFirstDisplayRenderBridge({ ...options, getAps: () => protocol }), + parseWindowMessage, + createRenderStrategy: (options: FirstDisplayRenderOwnerOptionsV1) => + createFirstDisplayApsRenderStrategy(options, protocol), }); const release = value.register(protocol); if (typeof release !== 'function') throw new TypeError('tsjs'); diff --git a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts index 38add67dc..64a74e53f 100644 --- a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts @@ -1,38 +1,19 @@ -import { PUC_DYNAMIC_OWNER } from '../kernel/contracts/puc_dynamic_owner'; - -import type { - FirstDisplayGptBoundCycleV1, - FirstDisplayGptRenderResult, -} from './adapters/googletag'; -import type { FirstDisplayRenderBridgeV1 } from './driver'; +import type { FirstDisplayGptBoundCycleV1 } from './adapters/googletag'; import type { FirstDisplayApsProtocolV1 } from './leaf/aps_protocol'; +import type { + FirstDisplayCommittedRenderArtifactV1, + FirstDisplayRenderOwnerOptionsV1, + FirstDisplayRenderStrategyAttemptV1, + FirstDisplayRenderStrategyCallbacksV1, + FirstDisplayRenderStrategyV1, +} from './render_journal'; -const ADM_SANDBOX = - 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; const RENDERER_DOCUMENT_NO_LOAD = 'renderer_document_no_load'; const INTERNAL_ERROR = 'internal_error'; const RUNNER_FAILED = 'runner_failed'; -const ADM_DOCUMENT_NO_LOAD = 'adm_document_no_load'; const WINNER_NOT_RENDERABLE = 'winner_not_renderable'; -const NAVIGATION_DISPOSED = 'navigation_disposed'; -const SLOT_UNRESOLVED = 'slot_unresolved'; -const CLAIM_DEADLINE_MS = 3_000; -const ADM_LOAD_DEADLINE_MS = 5_000; -const TICKET_TTL_MS = 3_000; -const RESERVATION_TTL_MS = 15 * 60 * 1_000; -const MAX_CAPABILITIES = 320; -const MAX_NONCES = 256; const MAX_DRAWS = 8; -const MAX_GLOBAL_MESSAGE_BYTES = 4_096; -const MAX_DOMAIN_BYTES = 2_048; -const MAX_OWNER_BYTES = 64 * 1_024; -const MAX_RESPONSE_BYTES = 72 * 1_024; -const RESERVATION_ID = /^r1_[A-Za-z0-9_-]{22}$/; -const TICKET_ID = /^t1_[A-Za-z0-9_-]{22}$/; -const BOOTSTRAP_NONCE_ID = /^b1_[A-Za-z0-9_-]{22}$/; -const NONCE_ID = /^n1_[A-Za-z0-9_-]{22}$/; const BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; -const textEncoder = new TextEncoder(); interface PortLike { readonly addEventListener?: (name: string, listener: (event: unknown) => void) => void; @@ -42,278 +23,31 @@ interface PortLike { readonly start?: () => void; } -interface ChannelLike { - readonly port1: PortLike; - readonly port2: PortLike; -} - -export interface FirstDisplayRenderBridgeOptionsV1 { - readonly browser: Window; - readonly clearTimer: (handle: unknown) => void; - readonly createChannel: () => ChannelLike; - readonly document: Document; - readonly fillRandom: (bytes: Uint8Array) => void; - readonly getAps: () => FirstDisplayApsProtocolV1 | undefined; - readonly now: () => number; - readonly onNativeMutation?: () => boolean; - readonly setTimer: (callback: () => void, delayMs: number) => unknown; -} - -interface PendingClaim { - readonly port: PortLike; - readonly source: object; -} - -interface Attempt { +interface ApsAttempt { + readonly callbacks: FirstDisplayRenderStrategyCallbacksV1; readonly cycle: FirstDisplayGptBoundCycleV1; - readonly onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void; - readonly reservationId: string; + readonly overlay: boolean; active: boolean; + accepted: boolean; bootstrapNavigated: boolean; - bootstrapNonce: string | undefined; + bootstrapNonceInternal: string; bootstrapSource: object | undefined; - claim: PendingClaim | undefined; - claimTimer: unknown; completionTimer: unknown; - controlPort: PortLike | undefined; - controlRelease: (() => void) | undefined; - directFrame: HTMLIFrameElement | undefined; documentAccepted: boolean; - documentAcceptancePending: boolean; documentPort: PortLike | undefined; documentRelease: (() => void) | undefined; documentTimer: unknown; - documentTransferred: PortLike | undefined; - gam: FirstDisplayGptRenderResult | undefined; + frame: HTMLIFrameElement; hostPositionOwned: boolean; - inserted: boolean; - insertionTimer: unknown; - ownerTicket: string | undefined; - overlay: boolean; - previousHostPosition: string; - previousHostPositionPriority: string; - rendererNonce: string | undefined; - ownerSource: object | undefined; - pendingDocumentTerminal: + pendingTerminal: | 'completed' | typeof WINNER_NOT_RENDERABLE | 'runner_no_load' | typeof RUNNER_FAILED | undefined; - phaseValue: - | 'waiting_for_gam_and_claim' - | 'waiting_for_owner' - | 'waiting_for_insertion' - | 'rendering_direct'; - ticket: string | undefined; -} - -interface LiveTicket { - readonly attempt: Attempt; - readonly expiresAtInternal: number; - readonly ordinalInternal: number; - readonly registryState: 'live'; - timer?: unknown; -} - -interface TicketTombstone { - readonly expiresAtInternal: number; - readonly ordinalInternal: number; - readonly registryState: 'tombstone'; - timer?: unknown; -} - -type TicketEntry = LiveTicket | TicketTombstone; - -interface ReservationEntry { - readonly expiresAtInternal: number; - readonly ordinalInternal: number; - readonly registryState: 'live' | 'tombstone'; -} - -function utf8Length(value: string): number { - return textEncoder.encode(value).byteLength; -} - -function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { - try { - if ( - typeof value !== 'object' || - value === null || - Array.isArray(value) || - Object.getPrototypeOf(value) !== Object.prototype || - Object.getOwnPropertySymbols(value).length !== 0 - ) { - return undefined; - } - const names = Object.getOwnPropertyNames(value).sort(); - const expected = [...keys].sort(); - if (names.length !== expected.length) return undefined; - const result: Record = {}; - for (let index = 0; index < expected.length; index += 1) { - const name = expected[index]; - if (!name || names[index] !== name) return undefined; - const descriptor = Object.getOwnPropertyDescriptor(value, name); - if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; - result[name] = descriptor.value; - } - return result; - } catch { - return undefined; - } -} - -function 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; -} - -function 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; -} - -function 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 { - key = JSON.parse(source.slice(index, keyEnd)) as unknown; - } catch { - 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; -} - -function parseJson(source: string): unknown { - if (utf8Length(source) > MAX_GLOBAL_MESSAGE_BYTES) return undefined; - const end = scanJsonValue(source, 0); - if (end === undefined || skipJsonWhitespace(source, end) !== source.length) return undefined; - try { - return JSON.parse(source) as unknown; - } catch { - return undefined; - } -} - -function routingMessage(data: unknown): Readonly<{ - adId?: string; - lifecycleTicket?: string; - message?: string; -}> { - const value = typeof data === 'string' ? parseJson(data) : data; - try { - if ( - typeof value !== 'object' || - value === null || - Array.isArray(value) || - Object.getPrototypeOf(value) !== Object.prototype - ) { - return Object.freeze({}); - } - const read = (name: string): string | undefined => { - const descriptor = Object.getOwnPropertyDescriptor(value, name); - return descriptor?.enumerable && 'value' in descriptor && typeof descriptor.value === 'string' - ? descriptor.value - : undefined; - }; - const message = read('message'); - const adId = read('adId'); - const lifecycleTicket = read('lifecycleTicket'); - return Object.freeze({ - ...(message ? { message } : {}), - ...(adId ? { adId } : {}), - ...(lifecycleTicket ? { lifecycleTicket } : {}), - }); - } catch { - return Object.freeze({}); - } -} - -function exactPrebidRequest(data: unknown): Record | undefined { - if (typeof data !== 'string') return undefined; - const fields = exactRecord(parseJson(data), ['message', 'adId', 'adServerDomain']); - return fields?.message === 'Prebid Request' && - typeof fields.adId === 'string' && - RESERVATION_ID.test(fields.adId) && - typeof fields.adServerDomain === 'string' && - fields.adServerDomain.length > 0 && - utf8Length(fields.adServerDomain) <= MAX_DOMAIN_BYTES - ? fields - : undefined; -} - -function exactOwnerRegistration(data: unknown): Record | undefined { - if (typeof data !== 'string') return undefined; - const fields = exactRecord(parseJson(data), ['message', 'adId', 'version', 'lifecycleTicket']); - return fields?.message === 'TS Render Owner Register' && - fields.version === 1 && - typeof fields.adId === 'string' && - RESERVATION_ID.test(fields.adId) && - typeof fields.lifecycleTicket === 'string' && - TICKET_ID.test(fields.lifecycleTicket) - ? fields - : undefined; + previousHostPosition: string; + previousHostPositionPriority: string; + rendererNonceInternal: string; } function eventField( @@ -347,22 +81,18 @@ function usablePort(value: unknown): value is PortLike { } } -function inspectPorts( +function exactPorts( event: unknown, - trustedPrototype: object | undefined -): - | Readonly<{ - exact: boolean; - originalCount: number; - ports: readonly PortLike[]; - }> - | undefined { + trustedPrototype: object | undefined, + expected: 0 | 1 +): readonly PortLike[] | undefined { const value = eventField(event, 'ports', trustedPrototype); try { if (!Array.isArray(value)) return undefined; const length = Object.getOwnPropertyDescriptor(value, 'length'); if (!length || !('value' in length) || !Number.isSafeInteger(length.value)) return undefined; let exact = + length.value === expected && Object.getPrototypeOf(value) === Array.prototype && Object.getOwnPropertySymbols(value).length === 0 && Object.getOwnPropertyNames(value).length === length.value + 1; @@ -381,7 +111,9 @@ function inspectPorts( seen.add(descriptor.value); ports.push(descriptor.value); } - return Object.freeze({ exact, originalCount: length.value, ports: Object.freeze(ports) }); + if (exact && ports.length === expected) return ports; + for (const port of ports) closePort(port); + return undefined; } catch { return undefined; } @@ -394,60 +126,31 @@ function eventSource(event: unknown, trustedPrototype: object | undefined): obje : 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 closePort(port: PortLike | undefined): void { try { port?.close(); } catch { - // Authority is already inert; endpoint cleanup remains best-effort. + // The endpoint is already generation-inert. } } -function post(port: PortLike, data: unknown, transfer: readonly PortLike[] = []): boolean { +function post(port: PortLike, message: unknown): boolean { try { - Reflect.apply(port.postMessage, port, [data, transfer]); + Reflect.apply(port.postMessage, port, [message, []]); return true; } catch { return false; } } -function listen( - port: PortLike, - receive: (event: unknown) => void, - receiveError: () => void -): (() => void) | undefined { +function postWindow(target: object, message: string): boolean { try { - if (typeof port.addEventListener !== 'function') return undefined; - Reflect.apply(port.addEventListener, port, ['message', receive]); - Reflect.apply(port.addEventListener, port, ['messageerror', receiveError]); - if (typeof port.start === 'function') Reflect.apply(port.start, port, []); - let live = true; - return () => { - if (!live) return; - live = false; - try { - if (typeof port.removeEventListener === 'function') { - Reflect.apply(port.removeEventListener, port, ['message', receive]); - Reflect.apply(port.removeEventListener, port, ['messageerror', receiveError]); - } - } catch { - // Port closure below remains authoritative. - } - }; + const postMessage = Reflect.get(target, 'postMessage'); + if (typeof postMessage !== 'function') return false; + Reflect.apply(postMessage, target, [message, '*', []]); + return true; } catch { - return undefined; + return false; } } @@ -468,155 +171,24 @@ function encodeOpaque(bytes: Uint8Array): string { return output; } -function validCycle(cycle: FirstDisplayGptBoundCycleV1): boolean { - try { - const source = cycle.bid.renderSource; - const document = cycle.element.ownerDocument; - let exactElementMatches = 0; - const elements = document.getElementsByTagName('*'); - for (let index = 0; index < elements.length; index += 1) { - const candidate = elements.item(index); - if (candidate?.id !== cycle.element.id) continue; - if (candidate !== cycle.element) return false; - exactElementMatches += 1; - } - return ( - cycle.isCurrent() && - cycle.slotId === cycle.bid.slot && - cycle.slotId === cycle.placement.slot && - exactElementMatches === 1 && - document.getElementById(cycle.element.id) === cycle.element && - RESERVATION_ID.test(cycle.bid.rendererReservationId) && - (source.type === 'adm' || source.type === 'aps') - ); - } catch { - return false; - } -} - -function refusedResponse(adId: string): string { - return JSON.stringify({ - message: 'Prebid Response', - adId, - rendererVersion: '4', - tsOwner: { version: 1, status: 'refused' }, - }); -} - -function ownerRefused(adId: string): string { - return JSON.stringify({ message: 'TS Render Owner Refused', adId, version: 1 }); -} - -function resizeCollapsedPucShell( - document: Document, - source: object, - width: number, - height: number -): boolean { +function snapshotFrameAttributes(frame: HTMLIFrameElement): string | undefined { try { - const browser = document.defaultView; - if ( - !browser || - !Number.isFinite(width) || - !Number.isFinite(height) || - width <= 0 || - height <= 0 - ) { - return false; - } - const frames = document.querySelectorAll('iframe'); - let selected: HTMLIFrameElement | undefined; - for (let index = 0; index < frames.length; index += 1) { - const candidate = frames.item(index); - if (candidate?.isConnected && candidate.contentWindow === source) { - if (selected) return false; - selected = candidate; - } - } - const onePixelAttribute = (element: Element, name: 'width' | 'height'): boolean => { - const value = element.getAttribute(name); - if (value === null || !/^\d+(?:\.\d+)?$/.test(value)) return false; - const parsed = Number(value); - return Number.isFinite(parsed) && parsed <= 1; - }; - const ordinaryCollapsed = (element: HTMLElement): boolean => { - const style = browser.getComputedStyle(element); - const pixel = (value: string): boolean => { - const match = /^(\d+(?:\.\d+)?)px$/.exec(value); - return match !== null && Number(match[1]) <= 1; - }; - return ( - style.position !== 'fixed' && - style.position !== 'sticky' && - pixel(style.width) && - pixel(style.height) - ); - }; - if ( - !selected || - !onePixelAttribute(selected, 'width') || - !onePixelAttribute(selected, 'height') || - !ordinaryCollapsed(selected) || - selected.closest('a,[data-anchor-status]') !== null - ) { - return false; - } - const wrapper = selected.parentElement; - if ( - !wrapper || - wrapper === document.body || - wrapper === document.documentElement || - wrapper.tagName === 'A' || - !wrapper.isConnected || - !ordinaryCollapsed(wrapper) || - wrapper.closest('a,[data-anchor-status]') !== null - ) { - return false; - } - selected.style.setProperty('width', `${width}px`); - selected.style.setProperty('height', `${height}px`); - wrapper.style.setProperty('width', `${width}px`); - wrapper.style.setProperty('height', `${height}px`); - return true; + return frame.outerHTML; } catch { - return false; + return undefined; } } -/** Own the bounded APS/ADM authority used only by the immutable first-display batch. */ -export function createFirstDisplayRenderBridge( - options: FirstDisplayRenderBridgeOptionsV1 -): FirstDisplayRenderBridgeV1 { - const attempts = new Map(); - const reservations = new Map(); - const tickets = new Map(); - const bootstrapNonces = new Map(); - const rendererNonces = new Map(); - const committedFrames = new Map< - string, - Readonly<{ - frame: HTMLIFrameElement; - frameAttributes: string; - frameContentWindow: Window; - frameSource: string; - frameSourceDocument: string; - host: HTMLElement; - hostPosition: string | null; - hostPositionPriority: string | null; - kind: 'gpt_adm' | 'aps'; - owner: 'trusted_server'; - token: string; - }> - >(); +/** Create the APS-owned URL, nonce, document-port, and overlay strategy. */ +export function createFirstDisplayApsRenderStrategy( + options: FirstDisplayRenderOwnerOptionsV1, + aps: FirstDisplayApsProtocolV1 +): FirstDisplayRenderStrategyV1 { + const attempts = new Set(); + const bootstrapNonces = new Map(); + const rendererNonces = new Map(); const timers = new Set(); let disposed = false; - let sealed = false; - let ingressClosed = false; - let handoffCaptured = false; - let committedArtifactsDetached = false; - let nextTicketOrdinal = 1; - let nextReservationOrdinal = 1; - let lastNow = Number.NEGATIVE_INFINITY; const messageEventPrototype = (() => { try { const constructor = Reflect.get(options.browser, 'MessageEvent'); @@ -628,128 +200,12 @@ export function createFirstDisplayRenderBridge( } })(); - const apsProtocol = (): FirstDisplayApsProtocolV1 | undefined => { - try { - return options.getAps(); - } catch { - return undefined; - } - }; - - const readNow = (): number | undefined => { - try { - const value = options.now(); - if (!Number.isFinite(value) || value < 0 || value < lastNow) return undefined; - lastNow = value; - return value; - } catch { - return undefined; - } - }; - const notifyNativeMutation = (): void => { try { options.onNativeMutation?.(); } catch { - // Mutation observation cannot alter the admitted publisher or browser event. - } - }; - - const disposeCommittedFrame = ( - entry: Readonly<{ - frame: HTMLIFrameElement; - host: HTMLElement; - hostPosition: string | null; - hostPositionPriority: string | null; - }> - ): void => { - try { - entry.frame.onload = null; - entry.frame.onerror = null; - entry.frame.remove(); - } catch { - // The exact TS node loses authority even when hostile DOM cleanup fails. - } - if (entry.hostPosition === null) return; - try { - const style = entry.host.style; - if ( - style.getPropertyValue('position') !== 'relative' || - style.getPropertyPriority('position') !== '' - ) { - return; - } - if (entry.hostPosition === '') style.removeProperty('position'); - else style.setProperty('position', entry.hostPosition, entry.hostPositionPriority ?? ''); - } catch { - // Compare-owned restoration cannot overwrite publisher style changes. - } - }; - - const snapshotFrameAttributes = (frame: HTMLIFrameElement): string | undefined => { - try { - return JSON.stringify( - [...frame.attributes] - .map((attribute) => [attribute.name, attribute.value] as const) - .sort(([left], [right]) => left.localeCompare(right)) - ); - } catch { - return undefined; - } - }; - - const retireCommitted = (cycle: FirstDisplayGptBoundCycleV1): boolean => { - if (disposed || committedArtifactsDetached) return false; - const entry = committedFrames.get(cycle.slotId); - if (!entry || entry.token !== cycle.bid.rendererReservationId) return false; - committedFrames.delete(cycle.slotId); - disposeCommittedFrame(entry); - notifyNativeMutation(); - return true; - }; - - const committedFrameCurrent = (entry: { - readonly frame: HTMLIFrameElement; - readonly frameAttributes: string; - readonly frameContentWindow: Window; - readonly frameSource: string; - readonly frameSourceDocument: string; - readonly host: HTMLElement; - readonly hostPosition: string | null; - }): boolean => { - try { - return ( - entry.frame.ownerDocument === options.document && - entry.host.ownerDocument === options.document && - entry.frame.parentNode === entry.host && - entry.frame.isConnected && - entry.frame.contentWindow === entry.frameContentWindow && - entry.frame.src === entry.frameSource && - entry.frame.srcdoc === entry.frameSourceDocument && - snapshotFrameAttributes(entry.frame) === entry.frameAttributes && - entry.host.isConnected && - entry.host.id.length > 0 && - options.document.getElementById(entry.host.id) === entry.host && - (entry.hostPosition === null || - (entry.host.style.getPropertyValue('position') === 'relative' && - entry.host.style.getPropertyPriority('position') === '')) - ); - } catch { - return false; - } - }; - - const sweepCommittedArtifacts = (): number => { - if (disposed || committedArtifactsDetached) return 0; - let retired = 0; - for (const [slotId, entry] of [...committedFrames.entries()]) { - if (committedFrames.get(slotId) !== entry || committedFrameCurrent(entry)) continue; - committedFrames.delete(slotId); - disposeCommittedFrame(entry); - retired += 1; + // Observation cannot alter admitted APS state. } - if (retired > 0) notifyNativeMutation(); - return retired; }; const clearOwnedTimer = (handle: unknown): void => { @@ -757,26 +213,42 @@ export function createFirstDisplayRenderBridge( try { options.clearTimer(handle); } catch { - // The state guarded by the timer is already generation-inert. + // Timer state is already detached. } }; const arm = (callback: () => void, delayMs: number): unknown => { let handle: unknown; + let scheduling = true; + let firedSynchronously = false; try { handle = options.setTimer(() => { + if (scheduling) { + firedSynchronously = true; + return; + } if (!timers.delete(handle)) return; callback(); }, delayMs); } catch { handle = undefined; } - if (handle !== undefined) timers.add(handle); + scheduling = false; + if (handle === undefined) return undefined; + if (firedSynchronously) { + try { + options.clearTimer(handle); + } catch { + // Synchronous timers are refused regardless of cleanup outcome. + } + return undefined; + } + timers.add(handle); return handle; }; const mint = ( - prefix: 't1_' | 'b1_' | 'n1_', + prefix: 'b1_' | 'n1_', registry: ReadonlyMap ): string | undefined => { for (let draw = 0; draw < MAX_DRAWS; draw += 1) { @@ -792,879 +264,413 @@ export function createFirstDisplayRenderBridge( return undefined; }; - const retireTicket = (attempt: Attempt): void => { - const ticket = attempt.ticket; - attempt.ticket = undefined; - if (!ticket) return; - const entry = tickets.get(ticket); - if (entry?.registryState !== 'live' || entry.attempt !== attempt) return; - tickets.set(ticket, { - registryState: 'tombstone', - expiresAtInternal: entry.expiresAtInternal, - ordinalInternal: entry.ordinalInternal, - timer: entry.timer, - }); - notifyNativeMutation(); - }; - - const retireReservation = (attempt: Attempt): void => { - const entry = reservations.get(attempt.reservationId); - if (entry?.registryState !== 'live') return; - reservations.set(attempt.reservationId, { - expiresAtInternal: entry.expiresAtInternal, - ordinalInternal: entry.ordinalInternal, - registryState: 'tombstone', - }); - notifyNativeMutation(); + const configureFrame = ( + frame: HTMLIFrameElement, + width: number, + height: number, + overlay: boolean + ): void => { + frame.setAttribute('sandbox', aps.sandbox); + frame.setAttribute('referrerpolicy', 'no-referrer'); + frame.setAttribute('width', String(width)); + frame.setAttribute('height', String(height)); + frame.setAttribute('scrolling', 'no'); + frame.setAttribute('frameborder', '0'); + frame.setAttribute('marginwidth', '0'); + frame.setAttribute('marginheight', '0'); + frame.setAttribute('title', 'Ad content'); + frame.setAttribute('aria-label', 'Advertisement'); + frame.setAttribute( + 'style', + overlay + ? `border: 0; display: block; height: ${height}px; inset: 0; margin: 0; overflow: hidden; position: absolute; visibility: hidden; width: ${width}px; z-index: 2147483647;` + : `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + ); }; - const releaseAttempt = (attempt: Attempt, removeFrame: boolean): void => { - clearOwnedTimer(attempt.claimTimer); - clearOwnedTimer(attempt.completionTimer); - clearOwnedTimer(attempt.documentTimer); - clearOwnedTimer(attempt.insertionTimer); - attempt.claimTimer = undefined; - attempt.completionTimer = undefined; - attempt.documentTimer = undefined; - attempt.insertionTimer = undefined; - const claim = attempt.claim; - attempt.claim = undefined; - closePort(claim?.port); - attempt.controlRelease?.(); - attempt.documentRelease?.(); - attempt.controlRelease = undefined; - attempt.documentRelease = undefined; - closePort(attempt.controlPort); - closePort(attempt.documentPort); - closePort(attempt.documentTransferred); - attempt.controlPort = undefined; - attempt.documentPort = undefined; - attempt.documentTransferred = undefined; - if (attempt.bootstrapNonce && bootstrapNonces.get(attempt.bootstrapNonce) === attempt) { - bootstrapNonces.delete(attempt.bootstrapNonce); - } - if (attempt.rendererNonce && rendererNonces.get(attempt.rendererNonce) === attempt) { - rendererNonces.delete(attempt.rendererNonce); - } - attempt.bootstrapNavigated = false; - attempt.bootstrapNonce = undefined; - attempt.bootstrapSource = undefined; - attempt.rendererNonce = undefined; - retireTicket(attempt); - retireReservation(attempt); - attempts.delete(attempt.reservationId); - if (attempt.directFrame) { - try { - attempt.directFrame.onload = null; - attempt.directFrame.onerror = null; - if (removeFrame) attempt.directFrame.remove(); - } catch { - // The exact frame is no longer authoritative after terminal settlement. - } - } - if (removeFrame && attempt.hostPositionOwned) { - attempt.hostPositionOwned = false; - try { - const style = attempt.cycle.element.style; - if ( - style.getPropertyValue('position') === 'relative' && - style.getPropertyPriority('position') === '' - ) { - if (attempt.previousHostPosition === '') style.removeProperty('position'); - else { - style.setProperty( - 'position', - attempt.previousHostPosition, - attempt.previousHostPositionPriority - ); - } - } - } catch { - // Compare-owned publisher style restoration is best-effort. - } + const restoreHostPosition = (attempt: ApsAttempt): void => { + if (!attempt.hostPositionOwned) return; + attempt.hostPositionOwned = false; + try { + const style = attempt.cycle.element.style; + if ( + style.getPropertyValue('position') !== 'relative' || + style.getPropertyPriority('position') !== '' + ) + return; + if (attempt.previousHostPosition === '') style.removeProperty('position'); + else + style.setProperty( + 'position', + attempt.previousHostPosition, + attempt.previousHostPositionPriority + ); + } catch { + // Compare-owned restoration never overwrites publisher changes. } - attempt.directFrame = undefined; }; - const settle = ( - attempt: Attempt, - result: 'accepted' | 'failed' | 'cancelled', - reason = result === 'cancelled' ? NAVIGATION_DISPOSED : INTERNAL_ERROR - ): boolean => { - if (!attempt.active) return false; - attempt.active = false; - if (attempt.controlPort && attempt.ownerTicket) { - const settlement: Record = { - message: 'TS Owner Settled', - version: 1, - lifecycleTicket: attempt.ownerTicket, - outcome: result, - }; - if (result !== 'accepted') settlement.reason = reason; - post(attempt.controlPort, settlement); - } - const committedFrame = result === 'accepted' ? attempt.directFrame : undefined; - releaseAttempt(attempt, result !== 'accepted'); - const frameAttributes = committedFrame ? snapshotFrameAttributes(committedFrame) : undefined; - const frameContentWindow = committedFrame?.contentWindow; - if (committedFrame?.isConnected && frameAttributes !== undefined && frameContentWindow) { - committedFrames.set( - attempt.cycle.slotId, - Object.freeze({ - frame: committedFrame, - frameAttributes, - frameContentWindow, - frameSource: committedFrame.src, - frameSourceDocument: committedFrame.srcdoc, - host: attempt.cycle.element, - hostPosition: - attempt.overlay && attempt.hostPositionOwned ? attempt.previousHostPosition : null, - hostPositionPriority: - attempt.overlay && attempt.hostPositionOwned - ? attempt.previousHostPositionPriority - : null, - kind: attempt.cycle.bid.renderSource.type === 'adm' ? 'gpt_adm' : 'aps', - owner: 'trusted_server', - token: attempt.reservationId, - }) - ); - } + const acquireHostPosition = (attempt: ApsAttempt): boolean => { + if (!attempt.overlay) return true; try { - attempt.onTerminal(result, result === 'accepted' ? null : reason); + const host = attempt.cycle.element; + const browser = host.ownerDocument.defaultView; + if (!browser || !attempt.cycle.isCurrent()) return false; + if (browser.getComputedStyle(host).position !== 'static') return true; + attempt.previousHostPosition = host.style.getPropertyValue('position'); + attempt.previousHostPositionPriority = host.style.getPropertyPriority('position'); + host.style.setProperty('position', 'relative'); + attempt.hostPositionOwned = + host.style.getPropertyValue('position') === 'relative' && + host.style.getPropertyPriority('position') === ''; + return attempt.hostPositionOwned && attempt.cycle.isCurrent(); } catch { - // A consumer callback cannot restore released render authority. + return false; } - notifyNativeMutation(); - return true; }; - const fail = (attempt: Attempt, reason: string, refuseClaim = false): boolean => { - if (!attempt.active) return false; - if (refuseClaim && attempt.claim) { - const claim = attempt.claim; - attempt.claim = undefined; - post(claim.port, refusedResponse(attempt.reservationId)); - closePort(claim.port); - } - return settle(attempt, 'failed', reason); - }; + const exactFrame = (attempt: ApsAttempt, permanent: boolean): boolean => { + try { + return ( + attempt.active && + attempt.cycle.isCurrent() && + attempt.frame.isConnected && + attempt.frame.parentNode === attempt.cycle.element && + attempt.frame.contentWindow === attempt.bootstrapSource && + attempt.frame.getAttribute('src') === + `${aps.rendererUrl}#${attempt.bootstrapNonceInternal}` && + attempt.frame.src === `${aps.rendererUrl}#${attempt.bootstrapNonceInternal}` && + attempt.frame.getAttribute('sandbox') === + (permanent ? aps.permanentSandbox : aps.sandbox) && + (!attempt.hostPositionOwned || + (attempt.cycle.element.style.getPropertyValue('position') === 'relative' && + attempt.cycle.element.style.getPropertyPriority('position') === '')) + ); + } catch { + return false; + } + }; - const issueBootstrapNonce = (attempt: Attempt): string | undefined => { - if (bootstrapNonces.size >= MAX_NONCES || attempt.bootstrapNonce) return undefined; - const nonce = mint('b1_', bootstrapNonces); - if (!nonce || !BOOTSTRAP_NONCE_ID.test(nonce)) return undefined; - bootstrapNonces.set(nonce, attempt); - attempt.bootstrapNonce = nonce; - return nonce; + const releasePort = (attempt: ApsAttempt): void => { + const release = attempt.documentRelease; + const port = attempt.documentPort; + attempt.documentRelease = undefined; + attempt.documentPort = undefined; + try { + release?.(); + } catch { + // Port closure remains authoritative. + } + closePort(port); }; - const issueRendererNonce = (attempt: Attempt): string | undefined => { - if (rendererNonces.size >= MAX_NONCES || attempt.rendererNonce) return undefined; - const nonce = mint('n1_', rendererNonces); - if (!nonce || !NONCE_ID.test(nonce)) return undefined; - rendererNonces.set(nonce, attempt); - attempt.rendererNonce = nonce; - return nonce; + const detachAttempt = (attempt: ApsAttempt): void => { + clearOwnedTimer(attempt.documentTimer); + clearOwnedTimer(attempt.completionTimer); + attempt.documentTimer = undefined; + attempt.completionTimer = undefined; + if (bootstrapNonces.get(attempt.bootstrapNonceInternal) === attempt) { + bootstrapNonces.delete(attempt.bootstrapNonceInternal); + } + if (rendererNonces.get(attempt.rendererNonceInternal) === attempt) { + rendererNonces.delete(attempt.rendererNonceInternal); + } + attempts.delete(attempt); + releasePort(attempt); }; - const exactApsFrame = (attempt: Attempt, permanent: boolean): boolean => { - const aps = apsProtocol(); - const frame = attempt.directFrame; - const bootstrapNonce = attempt.bootstrapNonce; - if (!aps || !frame || !bootstrapNonce || !attempt.bootstrapSource) return false; + const cancelAttempt = (attempt: ApsAttempt): void => { + if (!attempt.active && !attempt.accepted) return; + attempt.active = false; + attempt.accepted = false; + detachAttempt(attempt); + attempt.frame.onload = null; + attempt.frame.onerror = null; try { - return ( - attempt.active && - (attempt.phaseValue === 'rendering_direct' || - (attempt.overlay && attempt.phaseValue === 'waiting_for_insertion')) && - validCycle(attempt.cycle) && - attempt.inserted && - frame.isConnected && - frame.parentNode === attempt.cycle.element && - frame.contentWindow === attempt.bootstrapSource && - frame.getAttribute('src') === aps.rendererUrl + '#' + bootstrapNonce && - frame.src === aps.rendererUrl + '#' + bootstrapNonce && - frame.getAttribute('sandbox') === (permanent ? aps.permanentSandbox : aps.sandbox) - ); + attempt.frame.remove(); } catch { - return false; + // The exact node cannot regain authority. } + restoreHostPosition(attempt); + notifyNativeMutation(); }; - const postWindow = (target: object, message: string): boolean => { + const fail = (attempt: ApsAttempt, reason: string): void => { + if (!attempt.active) return; + attempt.active = false; + detachAttempt(attempt); + attempt.frame.onload = null; + attempt.frame.onerror = null; try { - const postMessage = Reflect.get(target, 'postMessage'); - if (typeof postMessage !== 'function') return false; - Reflect.apply(postMessage, target, [message, '*', []]); - return true; + attempt.frame.remove(); } catch { - return false; + // Failed APS identity is already detached. + } + restoreHostPosition(attempt); + notifyNativeMutation(); + try { + attempt.callbacks.fail(reason); + } catch { + // Consumers cannot restore APS authority. } }; - const handleApsWindowMessage = ( - event: unknown, - data: unknown, - message: string | undefined + const installDocumentPort = ( + attempt: ApsAttempt, + port: PortLike, + receive: (event: unknown) => void, + receiveError: () => void ): boolean => { - if (message !== 'TS APS Bootstrap Ready' && message !== 'TS APS Container Ready') { + try { + if (typeof port.addEventListener !== 'function') return false; + let live = true; + const release = (): void => { + if (!live) return; + live = false; + try { + if (typeof port.removeEventListener === 'function') { + Reflect.apply(port.removeEventListener, port, ['message', receive]); + Reflect.apply(port.removeEventListener, port, ['messageerror', receiveError]); + } + } catch { + // Port closure remains authoritative. + } + }; + Reflect.apply(port.addEventListener, port, ['message', receive]); + Reflect.apply(port.addEventListener, port, ['messageerror', receiveError]); + attempt.documentRelease = release; + if (typeof port.start === 'function') Reflect.apply(port.start, port, []); + return live && attempt.active && attempt.documentPort === port; + } catch { return false; } - if (typeof data !== 'string') return true; - const origin = eventField(event, 'origin', messageEventPrototype); - const source = eventSource(event, messageEventPrototype); - if (origin !== 'null' || !source) return true; + }; - if (message === 'TS APS Bootstrap Ready') { - const fields = exactRecord(parseJson(data), ['message', 'version', 'bootstrapNonce']); - const bootstrapNonce = fields?.['bootstrapNonce']; - if ( - fields?.message !== message || - fields.version !== 1 || - typeof bootstrapNonce !== 'string' || - !BOOTSTRAP_NONCE_ID.test(bootstrapNonce) || - data !== - JSON.stringify({ - message, - version: 1, - ['bootstrapNonce']: bootstrapNonce, - }) - ) { - return true; - } - const attempt = bootstrapNonces.get(bootstrapNonce); - if (!attempt || source !== attempt.bootstrapSource) return true; - const inspection = inspectPorts(event, messageEventPrototype); - if (!inspection?.exact || inspection.originalCount !== 0 || inspection.ports.length !== 0) { - for (const port of inspection?.ports ?? []) closePort(port); - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; - } - const aps = apsProtocol(); - if (!aps || attempt.bootstrapNavigated || !exactApsFrame(attempt, false)) { - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; - } - const rendererNonce = attempt.rendererNonce; - const policy = aps.bootstrapPolicy(attempt.cycle.bid.renderSource); - if (!rendererNonce || !policy) { - fail(attempt, WINNER_NOT_RENDERABLE); - return true; - } + const complete = (attempt: ApsAttempt): void => { + if (!attempt.active || !exactFrame(attempt, true)) { + fail(attempt, 'slot_unresolved'); + return; + } + if (attempt.overlay) { try { - attempt.directFrame?.setAttribute('sandbox', aps.permanentSandbox); + attempt.frame.style.setProperty('visibility', 'visible'); + if (attempt.frame.style.getPropertyValue('visibility') !== 'visible') { + fail(attempt, INTERNAL_ERROR); + return; + } } catch { - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; - } - const navigation = JSON.stringify({ - message: 'TS APS Bootstrap Configure', - version: 2, - ['bootstrapNonce']: bootstrapNonce, - ['rendererNonce']: rendererNonce, - creativeOrigin: policy.creativeOrigin, - tagType: policy.tagType, - }); - if (!exactApsFrame(attempt, true) || !postWindow(source, navigation)) { - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; + fail(attempt, INTERNAL_ERROR); + return; } - attempt.bootstrapNavigated = true; - return true; - } - - const fields = exactRecord(parseJson(data), [ - 'message', - 'version', - 'bootstrapNonce', - 'rendererNonce', - ]); - const bootstrapNonce = fields?.['bootstrapNonce']; - const rendererNonce = fields?.['rendererNonce']; - if ( - fields?.message !== message || - fields.version !== 1 || - typeof bootstrapNonce !== 'string' || - !BOOTSTRAP_NONCE_ID.test(bootstrapNonce) || - typeof rendererNonce !== 'string' || - !NONCE_ID.test(rendererNonce) || - data !== - JSON.stringify({ - message, - version: 1, - ['bootstrapNonce']: bootstrapNonce, - ['rendererNonce']: rendererNonce, - }) - ) { - return true; } - const attempt = bootstrapNonces.get(bootstrapNonce); - if ( - !attempt || - rendererNonces.get(rendererNonce) !== attempt || - source !== attempt.bootstrapSource - ) { - return true; - } - const inspection = inspectPorts(event, messageEventPrototype); - const port = inspection?.ports[0]; - if ( - !inspection?.exact || - inspection.originalCount !== 1 || - inspection.ports.length !== 1 || - !port || - !attempt.bootstrapNavigated || - attempt.documentPort || - !exactApsFrame(attempt, true) - ) { - for (const candidate of inspection?.ports ?? []) closePort(candidate); - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; - } - attempt.documentPort = port; - attempt.documentRelease = listen( - port, - (portEvent) => handleApsDocument(attempt, portEvent), - () => fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD) - ); - if (!attempt.documentRelease) { - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; + const attributes = snapshotFrameAttributes(attempt.frame); + const frameWindow = attempt.frame.contentWindow; + const frameSource = attempt.frame.src; + const frameSourceDocument = attempt.frame.srcdoc; + if (attributes === undefined || !frameWindow) { + fail(attempt, INTERNAL_ERROR); + return; } - bootstrapNonces.delete(bootstrapNonce); - const aps = apsProtocol(); - if ( - !aps || - !post(port, { - version: 1, - nonce: rendererNonce, - publisherOrigin: aps.publisherOrigin, - renderer: attempt.cycle.bid.renderSource, - }) || - !exactApsFrame(attempt, true) - ) { - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + attempt.active = false; + attempt.accepted = true; + detachAttempt(attempt); + attempt.frame.onload = null; + attempt.frame.onerror = null; + let artifactLive = true; + const retire = (): void => { + if (!artifactLive) return; + artifactLive = false; + attempt.accepted = false; + try { + attempt.frame.remove(); + } catch { + // Exact-node retirement is best-effort. + } + restoreHostPosition(attempt); + notifyNativeMutation(); + }; + const artifact: FirstDisplayCommittedRenderArtifactV1 = Object.freeze({ + hostPosition: attempt.hostPositionOwned ? attempt.previousHostPosition : null, + hostPositionPriority: attempt.hostPositionOwned ? attempt.previousHostPositionPriority : null, + identity: attempt.frame, + kind: 'aps', + owner: 'trusted_server', + slotId: attempt.cycle.slotId, + token: attempt.cycle.bid.rendererReservationId, + current: () => { + try { + return ( + artifactLive && + attempt.accepted && + attempt.cycle.isCurrent() && + attempt.frame.isConnected && + attempt.frame.parentNode === attempt.cycle.element && + attempt.frame.contentWindow === frameWindow && + attempt.frame.src === frameSource && + attempt.frame.srcdoc === frameSourceDocument && + snapshotFrameAttributes(attempt.frame) === attributes && + (!attempt.hostPositionOwned || + (attempt.cycle.element.style.getPropertyValue('position') === 'relative' && + attempt.cycle.element.style.getPropertyPriority('position') === '')) + ); + } catch { + return false; + } + }, + retire, + }); + notifyNativeMutation(); + try { + attempt.callbacks.accept(artifact); + } catch { + retire(); } - return true; }; - function handleApsDocument(attempt: Attempt, event: unknown): void { - const aps = apsProtocol(); - if (!attempt.active || !attempt.rendererNonce || !aps) return; - if (!exactApsFrame(attempt, true)) { + const handleDocument = (attempt: ApsAttempt, event: unknown): void => { + if (!attempt.active || !exactFrame(attempt, true)) { fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } - const inspection = inspectPorts(event, messageEventPrototype); - if (!inspection?.exact || inspection.originalCount !== 0 || inspection.ports.length !== 0) { + if (!exactPorts(event, messageEventPrototype, 0)) { fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } const parsed = aps.parseDocumentMessage( eventField(event, 'data', messageEventPrototype), - attempt.rendererNonce + attempt.rendererNonceInternal ); if (!parsed) { fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } - const acceptDocument = (): void => { - if (!attempt.active || attempt.documentAccepted || !attempt.inserted) return; + if (parsed.kind === 'document_accepted') { + if (attempt.documentAccepted) return; attempt.documentAccepted = true; - if (rendererNonces.get(attempt.rendererNonce!) === attempt) { - rendererNonces.delete(attempt.rendererNonce!); + if (rendererNonces.get(attempt.rendererNonceInternal) === attempt) { + rendererNonces.delete(attempt.rendererNonceInternal); } - attempt.documentAcceptancePending = false; clearOwnedTimer(attempt.documentTimer); attempt.documentTimer = undefined; attempt.completionTimer = arm(() => fail(attempt, RUNNER_FAILED), aps.deadlines.completionMs); - const pending = attempt.pendingDocumentTerminal; - attempt.pendingDocumentTerminal = undefined; - if (pending === 'completed') completeAps(attempt); + if (attempt.completionTimer === undefined) { + fail(attempt, INTERNAL_ERROR); + return; + } + const pending = attempt.pendingTerminal; + attempt.pendingTerminal = undefined; + if (pending === 'completed') complete(attempt); else if (pending) fail(attempt, pending); - }; - if (parsed.kind === 'document_accepted') { - if (attempt.documentAccepted) return; - if (!attempt.inserted) attempt.documentAcceptancePending = true; - else acceptDocument(); - return; - } - if (parsed.kind === 'runner_loaded') { return; } + if (parsed.kind === 'runner_loaded') return; if (parsed.kind === 'render_completed') { - if (attempt.documentAccepted) completeAps(attempt); - else if (attempt.documentAcceptancePending && !attempt.pendingDocumentTerminal) { - attempt.pendingDocumentTerminal = 'completed'; - } else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + if (attempt.documentAccepted) complete(attempt); + else if (!attempt.pendingTerminal) attempt.pendingTerminal = 'completed'; + else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return; } const failureReason = parsed.reason === 'descriptor_invalid' ? WINNER_NOT_RENDERABLE : parsed.reason; if (attempt.documentAccepted) fail(attempt, failureReason); - else if (attempt.documentAcceptancePending && !attempt.pendingDocumentTerminal) { - attempt.pendingDocumentTerminal = failureReason; - } else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - } + else if (!attempt.pendingTerminal) attempt.pendingTerminal = failureReason; + else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + }; - function completeAps(attempt: Attempt): void { - if (!attempt.active || !exactApsFrame(attempt, true)) { - fail(attempt, SLOT_UNRESOLVED); - return; - } - if (attempt.overlay) { + const dispatch = (event: unknown): void => { + if (disposed) return; + const data = eventField(event, 'data', messageEventPrototype); + const message = aps.parseWindowMessage(data); + if (!message) return; + if (message.kind === 'bootstrap_ready') { + const nonce = message.bootstrap; + const attempt = bootstrapNonces.get(nonce); + const source = eventSource(event, messageEventPrototype); + if ( + !attempt || + eventField(event, 'origin', messageEventPrototype) !== 'null' || + source !== attempt.bootstrapSource + ) + return; + if (!exactPorts(event, messageEventPrototype, 0)) { + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; + } + if (attempt.bootstrapNavigated || !exactFrame(attempt, false)) { + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; + } + const policy = aps.bootstrapPolicy(attempt.cycle.bid.renderSource); + if (!policy) { + fail(attempt, WINNER_NOT_RENDERABLE); + return; + } try { - const frame = attempt.directFrame; - if (!frame) { - fail(attempt, SLOT_UNRESOLVED); - return; - } - frame.style.setProperty('visibility', 'visible'); - if (frame.style.getPropertyValue('visibility') !== 'visible') { - fail(attempt, INTERNAL_ERROR); - return; - } + attempt.frame.setAttribute('sandbox', aps.permanentSandbox); } catch { - fail(attempt, INTERNAL_ERROR); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return; } - } - settle(attempt, 'accepted'); - } - - const ownerInserted = (attempt: Attempt): void => { - if (!attempt.active || attempt.inserted) return; - attempt.inserted = true; - clearOwnedTimer(attempt.insertionTimer); - attempt.insertionTimer = undefined; - if (attempt.cycle.bid.renderSource.type === 'adm') { - attempt.documentTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); - } else if (attempt.documentAcceptancePending) { - const nonce = attempt.rendererNonce; - if (nonce) { - handleApsDocument(attempt, { - data: { message: 'TS APS Document Accepted', version: 1, nonce }, - ports: [], - }); + const navigation = JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce: nonce, + rendererNonce: attempt.rendererNonceInternal, + creativeOrigin: policy.creativeOrigin, + tagType: policy.tagType, + }); + if (!exactFrame(attempt, true) || !postWindow(source!, navigation)) { + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; } - } else { - const aps = apsProtocol(); - attempt.documentTimer = arm( - () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD), - aps?.deadlines.documentAcceptanceMs ?? 3_000 - ); - } - }; - - const handleOwnerControl = (attempt: Attempt, event: unknown): void => { - if (!attempt.active) return; - const ports = inspectPorts(event, messageEventPrototype); - if (!ports?.exact || ports.originalCount !== 0 || ports.ports.length !== 0) { - fail(attempt, INTERNAL_ERROR); - return; - } - const data = eventField(event, 'data', messageEventPrototype); - const ticket = attempt.ownerTicket; - const inserted = exactRecord(data, ['message', 'version', 'lifecycleTicket']); - if ( - attempt.cycle.bid.renderSource.type === 'adm' && - inserted?.message === 'TS Owner Inserted' && - inserted.version === 1 && - inserted.lifecycleTicket === ticket - ) { - ownerInserted(attempt); - return; - } - if (attempt.cycle.bid.renderSource.type !== 'adm') { - fail(attempt, INTERNAL_ERROR); - return; - } - const loaded = exactRecord(data, ['message', 'version', 'lifecycleTicket']); - if ( - loaded?.message === 'TS ADM Loaded' && - loaded.version === 1 && - loaded.lifecycleTicket === ticket && - attempt.inserted - ) { - settle(attempt, 'accepted'); - return; - } - if ( - loaded?.message === 'TS ADM Failed' && - loaded.version === 1 && - loaded.lifecycleTicket === ticket - ) { - fail(attempt, ADM_DOCUMENT_NO_LOAD); + attempt.bootstrapNavigated = true; return; } - fail(attempt, ADM_DOCUMENT_NO_LOAD); - }; - const startOwner = (attempt: Attempt): boolean => { - const controlPort = attempt.controlPort; - const ticket = attempt.ownerTicket; - if (!controlPort || !ticket || !attempt.active) return false; - const aps = apsProtocol(); - attempt.insertionTimer = arm( - () => fail(attempt, 'owner_insertion_timeout'), - aps?.deadlines.insertionMs ?? 1_000 - ); - if (attempt.cycle.bid.renderSource.type === 'adm') { - return post(controlPort, { - message: 'TS ADM Start', - version: 1, - lifecycleTicket: ticket, - source: attempt.cycle.bid.renderSource, - }); - } - if (!aps) return fail(attempt, WINNER_NOT_RENDERABLE); - attempt.overlay = true; - if (!renderAps(attempt)) return false; - return ( - post(controlPort, { - message: 'TS APS Top Mount Started', - version: 1, - lifecycleTicket: ticket, - }) || fail(attempt, INTERNAL_ERROR) - ); - }; - - const handleOwnerRegistration = ( - event: unknown, - data: unknown, - routing: Readonly<{ adId?: string; lifecycleTicket?: string }> - ): void => { - const ticket = routing.lifecycleTicket; - if (!ticket) return; - const entry = tickets.get(ticket); - if (!entry || !suppress(event)) return; - const inspection = inspectPorts(event, messageEventPrototype); - const responsePort = inspection?.ports[0]; - const refuse = (): void => { - if (responsePort) post(responsePort, ownerRefused(routing.adId ?? '')); - for (const port of inspection?.ports ?? []) closePort(port); - }; - if (entry.registryState !== 'live') { - refuse(); + const { bootstrap: bootstrapNonce, renderer: rendererNonce } = message; + const attempt = bootstrapNonces.get(bootstrapNonce); + const source = eventSource(event, messageEventPrototype); + if ( + !attempt || + rendererNonces.get(rendererNonce) !== attempt || + eventField(event, 'origin', messageEventPrototype) !== 'null' || + source !== attempt.bootstrapSource + ) return; - } - const exact = exactOwnerRegistration(data); - const attempt = entry.attempt; + const ports = exactPorts(event, messageEventPrototype, 1); + const port = ports?.[0]; if ( - !exact || - !inspection?.exact || - inspection.originalCount !== 1 || - inspection.ports.length !== 1 || - !responsePort || - exact.adId !== attempt.reservationId || - exact.lifecycleTicket !== ticket || - eventSource(event, messageEventPrototype) !== attempt.ownerSource || - !attempt.active || - attempt.phaseValue !== 'waiting_for_owner' + !port || + !attempt.bootstrapNavigated || + attempt.documentPort || + !exactFrame(attempt, true) ) { - refuse(); - if (attempt.active) fail(attempt, 'bridge_id_mismatch'); - return; - } - retireTicket(attempt); - let channel: ChannelLike; - try { - channel = options.createChannel(); - } catch { - post(responsePort, ownerRefused(attempt.reservationId)); - closePort(responsePort); - fail(attempt, INTERNAL_ERROR); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return; } - attempt.ownerTicket = ticket; - attempt.controlPort = channel.port1; - attempt.controlRelease = listen( - channel.port1, - (message) => handleOwnerControl(attempt, message), - () => - fail( - attempt, - attempt.cycle.bid.renderSource.type === 'adm' ? ADM_DOCUMENT_NO_LOAD : INTERNAL_ERROR - ) - ); - attempt.phaseValue = 'waiting_for_insertion'; - const registered = JSON.stringify({ - message: 'TS Render Owner Registered', - adId: attempt.reservationId, - version: 1, - lifecycleTicket: ticket, - }); - const posted = - Boolean(attempt.controlRelease) && post(responsePort, registered, [channel.port2]); - closePort(responsePort); - closePort(channel.port2); - if (!posted || !startOwner(attempt)) { - if (attempt.active) fail(attempt, INTERNAL_ERROR); - } - }; - - const issueTicket = (attempt: Attempt): string | undefined => { - if (tickets.size >= MAX_CAPABILITIES || utf8Length(PUC_DYNAMIC_OWNER) > MAX_OWNER_BYTES) { - return undefined; - } - const ticket = mint('t1_', tickets); - if (!ticket || !TICKET_ID.test(ticket)) return undefined; - const issuedAt = readNow(); - if (issuedAt === undefined) return undefined; - const expiresAt = issuedAt + TICKET_TTL_MS; - const ordinal = nextTicketOrdinal; - nextTicketOrdinal += 1; - const entry: LiveTicket = { - registryState: 'live', + attempt.documentPort = port; + const listening = installDocumentPort( attempt, - expiresAtInternal: expiresAt, - ordinalInternal: ordinal, - }; - tickets.set(ticket, entry); - attempt.ticket = ticket; - entry.timer = arm(() => { - const current = tickets.get(ticket); - if (current !== entry) { - const observedAt = readNow(); - if ( - current?.registryState === 'tombstone' && - observedAt !== undefined && - current.expiresAtInternal <= observedAt - ) { - tickets.delete(ticket); - notifyNativeMutation(); - } - return; - } - tickets.delete(ticket); - attempt.ticket = undefined; - notifyNativeMutation(); - fail(attempt, 'owner_registration_timeout'); - }, TICKET_TTL_MS); - if (entry.timer === undefined) { - tickets.delete(ticket); - attempt.ticket = undefined; - return undefined; - } - return ticket; - }; - - const join = (attempt: Attempt): boolean => { - const claim = attempt.claim; - if ( - !attempt.active || - !claim || - attempt.gam !== 'nonempty_gam' || - attempt.phaseValue !== 'waiting_for_gam_and_claim' - ) { - return false; - } - clearOwnedTimer(attempt.claimTimer); - attempt.claimTimer = undefined; - const ticket = issueTicket(attempt); - if (!ticket) return fail(attempt, 'capability_registry_full', true); - const owner = { - version: 1, - status: 'ready', - kind: attempt.cycle.bid.renderSource.type, - lifecycleTicket: ticket, - }; - const response = JSON.stringify({ - message: 'Prebid Response', - adId: attempt.reservationId, - renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '4', - tsOwner: owner, - }); - attempt.claim = undefined; - const posted = utf8Length(response) <= MAX_RESPONSE_BYTES && post(claim.port, response); - closePort(claim.port); - if (!posted || !attempt.active) { - if (attempt.active) fail(attempt, INTERNAL_ERROR); - return false; - } - attempt.ownerSource = claim.source; - attempt.phaseValue = 'waiting_for_owner'; - retireReservation(attempt); - const source = attempt.cycle.bid.renderSource; - resizeCollapsedPucShell(options.document, claim.source, source.width, source.height); - return true; - }; - - const configureFrame = ( - frame: HTMLIFrameElement, - width: number, - height: number, - sandbox: string, - overlay = false - ): void => { - frame.setAttribute('sandbox', sandbox); - frame.setAttribute('referrerpolicy', 'no-referrer'); - frame.setAttribute('width', String(width)); - frame.setAttribute('height', String(height)); - frame.setAttribute('scrolling', 'no'); - frame.setAttribute('frameborder', '0'); - frame.setAttribute('marginwidth', '0'); - frame.setAttribute('marginheight', '0'); - frame.setAttribute('title', 'Ad content'); - frame.setAttribute('aria-label', 'Advertisement'); - frame.setAttribute( - 'style', - overlay - ? `border: 0; display: block; height: ${height}px; inset: 0; margin: 0; overflow: hidden; position: absolute; visibility: hidden; width: ${width}px; z-index: 2147483647;` - : `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + port, + (portEvent) => handleDocument(attempt, portEvent), + () => fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD) ); - }; - - const acquireOverlayPosition = (attempt: Attempt): boolean => { - if (!attempt.overlay) return true; - try { - if (!validCycle(attempt.cycle)) return false; - const host = attempt.cycle.element; - const browser = host.ownerDocument.defaultView; - if (!browser) return false; - if (browser.getComputedStyle(host).position !== 'static') return true; - attempt.previousHostPosition = host.style.getPropertyValue('position'); - attempt.previousHostPositionPriority = host.style.getPropertyPriority('position'); - host.style.setProperty('position', 'relative'); - attempt.hostPositionOwned = - host.style.getPropertyValue('position') === 'relative' && - host.style.getPropertyPriority('position') === ''; - return attempt.hostPositionOwned && validCycle(attempt.cycle); - } catch { - return false; - } - }; - - const renderDirectAdm = (attempt: Attempt): boolean => { - const source = attempt.cycle.bid.renderSource; - if (source.type !== 'adm') return false; - try { - const frame = options.document.createElement('iframe'); - configureFrame(frame, source.width, source.height, ADM_SANDBOX); - const intended = `${source.adm}`; - frame.onload = () => { - if ( - attempt.active && - attempt.directFrame === frame && - frame.parentNode === attempt.cycle.element && - frame.srcdoc === intended && - frame.getAttribute('src') === null - ) { - settle(attempt, 'accepted'); - } - }; - frame.onerror = () => fail(attempt, ADM_DOCUMENT_NO_LOAD); - frame.srcdoc = intended; - attempt.directFrame = frame; - attempt.documentTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); - attempt.cycle.element.appendChild(frame); - return true; - } catch { - return fail(attempt, ADM_DOCUMENT_NO_LOAD); - } - }; - - const renderAps = (attempt: Attempt): boolean => { - const source = attempt.cycle.bid.renderSource; - const aps = apsProtocol(); - if (source.type !== 'aps' || !aps) return false; - if (attempt.insertionTimer === undefined) { - attempt.insertionTimer = arm( - () => fail(attempt, 'owner_insertion_timeout'), - aps.deadlines.insertionMs - ); - } - if (attempt.insertionTimer === undefined) return fail(attempt, INTERNAL_ERROR); - const bootstrapNonce = issueBootstrapNonce(attempt); - const rendererNonce = issueRendererNonce(attempt); - if (!bootstrapNonce || !rendererNonce) return fail(attempt, 'capability_registry_full'); - let policy: ReturnType; - try { - policy = aps.bootstrapPolicy(source); - } catch { - policy = undefined; - } - if (!policy) return fail(attempt, WINNER_NOT_RENDERABLE); - try { - const frame = options.document.createElement('iframe'); - configureFrame(frame, source.width, source.height, aps.sandbox, attempt.overlay); - const intended = aps.rendererUrl + '#' + bootstrapNonce; - frame.onerror = () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - frame.src = intended; - attempt.directFrame = frame; - if (!acquireOverlayPosition(attempt)) return fail(attempt, SLOT_UNRESOLVED); - attempt.cycle.element.appendChild(frame); - const intendedWindow = frame.contentWindow; - if (!intendedWindow) return fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - attempt.bootstrapSource = intendedWindow; - ownerInserted(attempt); - return exactApsFrame(attempt, false) || fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - } catch { - return fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - } - }; - - const renderDirectFallback = (attempt: Attempt): boolean => { - attempt.phaseValue = 'rendering_direct'; - retireReservation(attempt); - const claim = attempt.claim; - attempt.claim = undefined; - if (claim) { - post(claim.port, refusedResponse(attempt.reservationId)); - closePort(claim.port); - } - return attempt.cycle.bid.renderSource.type === 'adm' - ? renderDirectAdm(attempt) - : renderAps(attempt); - }; - - const dispatch = (event: unknown): void => { - if (disposed || ingressClosed) return; - const data = eventField(event, 'data', messageEventPrototype); - const routing = routingMessage(data); - if (handleApsWindowMessage(event, data, routing.message)) return; - if (routing.message === 'TS Render Owner Register') { - notifyNativeMutation(); - handleOwnerRegistration(event, data, routing); + if (!listening || !attempt.active) { + if (attempt.active) fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return; } - if (routing.message !== 'Prebid Request' || !routing.adId) return; - const reservationState = reservations.get(routing.adId); - if (!reservationState || !suppress(event)) return; - notifyNativeMutation(); - const inspection = inspectPorts(event, messageEventPrototype); - const responsePort = inspection?.ports[0]; - const exact = exactPrebidRequest(data); - const refuse = (): void => { - if (responsePort) post(responsePort, refusedResponse(routing.adId!)); - for (const port of inspection?.ports ?? []) closePort(port); - }; - if ( - reservationState.registryState !== 'live' || - !inspection?.exact || - inspection.originalCount !== 1 || - inspection.ports.length !== 1 || - !responsePort || - !exact || - exact.adId !== routing.adId - ) { - refuse(); - return; - } - const attempt = attempts.get(routing.adId); - const source = eventSource(event, messageEventPrototype); + bootstrapNonces.delete(bootstrapNonce); if ( - !attempt?.active || - attempt.phaseValue !== 'waiting_for_gam_and_claim' || - attempt.claim || - !source - ) { - refuse(); - return; - } - attempt.claim = Object.freeze({ port: responsePort, source }); - if (attempt.gam === 'nonempty_gam') join(attempt); + !post(port, { + version: 1, + nonce: rendererNonce, + publisherOrigin: aps.publisherOrigin, + renderer: attempt.cycle.bid.renderSource, + }) || + !attempt.active || + !exactFrame(attempt, true) + ) + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); }; try { @@ -1674,207 +680,102 @@ export function createFirstDisplayRenderBridge( } return Object.freeze({ - bind: ( - cycle: FirstDisplayGptBoundCycleV1, - onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void - ): boolean => { - if ( - disposed || - ingressClosed || - sealed || - typeof onTerminal !== 'function' || - !validCycle(cycle) || - reservations.size >= MAX_CAPABILITIES || - reservations.has(cycle.bid.rendererReservationId) - ) { + supports: (source: unknown): boolean => { + try { + return aps.bootstrapPolicy(source) !== undefined; + } catch { return false; } - const observedAt = readNow(); - if (observedAt === undefined) return false; - const expiresAt = observedAt + RESERVATION_TTL_MS; - const reservationOrdinal = nextReservationOrdinal; + }, + start: ( + cycle: FirstDisplayGptBoundCycleV1, + overlay: boolean, + callbacks: FirstDisplayRenderStrategyCallbacksV1 + ): FirstDisplayRenderStrategyAttemptV1 | undefined => { + const source = cycle.bid.renderSource; + if (disposed || source.type !== 'aps' || !cycle.isCurrent() || !aps.bootstrapPolicy(source)) { + return undefined; + } + const bootstrapNonce = mint('b1_', bootstrapNonces); + const rendererNonce = mint('n1_', rendererNonces); if ( - !Number.isFinite(expiresAt) || - expiresAt <= observedAt || - reservationOrdinal > 4_294_967_295 - ) { - return false; + !bootstrapNonce || + !rendererNonce || + !aps.isBootstrapNonce(bootstrapNonce) || + !aps.isRendererNonce(rendererNonce) + ) + return undefined; + let frame: HTMLIFrameElement; + try { + frame = options.document.createElement('iframe'); + configureFrame(frame, source.width, source.height, overlay); + } catch { + return undefined; } - const attempt: Attempt = { + const attempt: ApsAttempt = { active: true, + accepted: false, bootstrapNavigated: false, - bootstrapNonce: undefined, + bootstrapNonceInternal: bootstrapNonce, bootstrapSource: undefined, - claim: undefined, - claimTimer: undefined, + callbacks, completionTimer: undefined, - controlPort: undefined, - controlRelease: undefined, cycle, - directFrame: undefined, documentAccepted: false, - documentAcceptancePending: false, documentPort: undefined, documentRelease: undefined, documentTimer: undefined, - documentTransferred: undefined, - gam: undefined, + frame, hostPositionOwned: false, - inserted: false, - insertionTimer: undefined, - ownerTicket: undefined, - overlay: false, + overlay, + pendingTerminal: undefined, previousHostPosition: '', previousHostPositionPriority: '', - rendererNonce: undefined, - onTerminal, - ownerSource: undefined, - pendingDocumentTerminal: undefined, - reservationId: cycle.bid.rendererReservationId, - phaseValue: 'waiting_for_gam_and_claim', - ticket: undefined, + rendererNonceInternal: rendererNonce, }; - attempts.set(attempt.reservationId, attempt); - reservations.set(attempt.reservationId, { - expiresAtInternal: expiresAt, - ordinalInternal: reservationOrdinal, - registryState: 'live', - }); - nextReservationOrdinal += 1; - return true; - }, - recordGam: ( - cycle: FirstDisplayGptBoundCycleV1, - result: FirstDisplayGptRenderResult - ): boolean => { - const attempt = attempts.get(cycle.bid.rendererReservationId); - if ( - !attempt?.active || - attempt.cycle !== cycle || - attempt.gam || - attempt.phaseValue !== 'waiting_for_gam_and_claim' - ) { - return false; - } - attempt.gam = result; - if (result === 'gam_empty') return renderDirectFallback(attempt); - if (attempt.claim) return join(attempt); - attempt.claimTimer = arm(() => fail(attempt, 'bridge_claim_timeout'), CLAIM_DEADLINE_MS); - return attempt.claimTimer !== undefined || fail(attempt, INTERNAL_ERROR); - }, - recordFailure: (cycle: FirstDisplayGptBoundCycleV1): boolean => { - const attempt = attempts.get(cycle.bid.rendererReservationId); - return Boolean( - attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') - ); - }, - retire: retireCommitted, - sweepCommittedArtifacts, - sealTsAdmission: (): void => { - if (disposed || [...attempts.values()].some((attempt) => attempt.active)) { - throw new TypeError('tsjs'); - } - sealed = true; - }, - closeIngress: (): boolean => { - if ( - disposed || - ingressClosed || - !sealed || - [...attempts.values()].some((attempt) => attempt.active) - ) { - return false; - } - ingressClosed = true; + attempts.add(attempt); + bootstrapNonces.set(bootstrapNonce, attempt); + rendererNonces.set(rendererNonce, attempt); try { - options.browser.removeEventListener('message', dispatch as EventListener, true); - } catch { - // The closed generation cannot regain authority through a failed physical removal. - } - for (const handle of [...timers]) clearOwnedTimer(handle); - return true; - }, - captureHandoff: () => { - if (disposed || !ingressClosed || handoffCaptured) return undefined; - sweepCommittedArtifacts(); - const observedAt = readNow(); - if (observedAt === undefined) return undefined; - const reservationTombstones = [...reservations.entries()] - .filter((entry): entry is [string, ReservationEntry] => { - const value = entry[1]; - return value.registryState === 'tombstone' && value.expiresAtInternal > observedAt; - }) - .map(([value, entry]) => - Object.freeze({ - kind: 'reservation' as const, - value, - expiresAtMs: entry.expiresAtInternal, - ordinal: entry.ordinalInternal, - }) - ); - const ticketTombstones = [...tickets.entries()] - .filter((entry): entry is [string, TicketTombstone] => { - const value = entry[1]; - return value.registryState === 'tombstone' && value.expiresAtInternal > observedAt; - }) - .map(([value, entry]) => - Object.freeze({ - kind: 'ticket' as const, - value, - expiresAtMs: entry.expiresAtInternal, - ordinal: entry.ordinalInternal, - }) + frame.onerror = () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + frame.src = `${aps.rendererUrl}#${bootstrapNonce}`; + if (!acquireHostPosition(attempt)) { + cancelAttempt(attempt); + return undefined; + } + cycle.element.appendChild(frame); + const frameWindow = frame.contentWindow; + if (!frameWindow) { + cancelAttempt(attempt); + return undefined; + } + attempt.bootstrapSource = frameWindow; + attempt.documentTimer = arm( + () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD), + aps.deadlines.documentAcceptanceMs ); - const artifacts = [...committedFrames.entries()].map(([slotId, entry]) => - Object.freeze({ - hostPosition: entry.hostPosition, - hostPositionPriority: entry.hostPositionPriority, - identity: entry.frame, - kind: entry.kind, - owner: entry.owner, - slotId, - token: entry.token, - }) - ); - handoffCaptured = true; - return Object.freeze({ - artifacts: Object.freeze(artifacts), - clockEpochMs: observedAt, - nextReservationOrdinal, - nextTicketOrdinal, - tombstones: Object.freeze([...reservationTombstones, ...ticketTombstones]), - }); - }, - detachCommittedArtifacts: (): boolean => { - if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { - return false; + if (attempt.documentTimer === undefined || !exactFrame(attempt, false)) { + cancelAttempt(attempt); + return undefined; + } + } catch { + cancelAttempt(attempt); + return undefined; } - if (sweepCommittedArtifacts() > 0) return false; - committedArtifactsDetached = true; - return true; + notifyNativeMutation(); + return Object.freeze({ cancel: () => cancelAttempt(attempt) }); }, dispose: (): void => { if (disposed) return; disposed = true; - if (!ingressClosed) { - ingressClosed = true; - try { - options.browser.removeEventListener('message', dispatch as EventListener, true); - } catch { - // Generation latching makes a failed physical removal inert. - } - } - for (const attempt of [...attempts.values()]) { - if (attempt.active) settle(attempt, 'cancelled', NAVIGATION_DISPOSED); + try { + options.browser.removeEventListener('message', dispatch as EventListener, true); + } catch { + // Generation state remains authoritative. } + for (const attempt of [...attempts]) cancelAttempt(attempt); for (const handle of [...timers]) clearOwnedTimer(handle); - if (!committedArtifactsDetached) { - for (const entry of committedFrames.values()) disposeCommittedFrame(entry); - } - committedFrames.clear(); attempts.clear(); - reservations.clear(); - tickets.clear(); bootstrapNonces.clear(); rendererNonces.clear(); }, diff --git a/crates/trusted-server-js/lib/src/first_display/render_journal.ts b/crates/trusted-server-js/lib/src/first_display/render_journal.ts new file mode 100644 index 000000000..b41eb203a --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/render_journal.ts @@ -0,0 +1,1611 @@ +import { PUC_DYNAMIC_OWNER } from '../kernel/contracts/puc_dynamic_owner'; +import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; + +import type { + FirstDisplayGptBoundCycleV1, + FirstDisplayGptRenderResult, +} from './adapters/googletag'; +import type { FirstDisplayRenderBridgeV1, FirstDisplayRenderHandoffArtifactV1 } from './driver'; + +const ADM_SANDBOX = + 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; +const INTERNAL_ERROR = 'internal_error'; +const ADM_DOCUMENT_NO_LOAD = 'adm_document_no_load'; +const NAVIGATION_DISPOSED = 'navigation_disposed'; +const CLAIM_DEADLINE_MS = 3_000; +const INSERTION_DEADLINE_MS = 1_000; +const ADM_LOAD_DEADLINE_MS = 5_000; +const TICKET_TTL_MS = 3_000; +const RESERVATION_TTL_MS = 15 * 60 * 1_000; +const MAX_CAPABILITIES = 320; +const MAX_DRAWS = 8; +const MAX_GLOBAL_MESSAGE_BYTES = 4_096; +const MAX_DOMAIN_BYTES = 2_048; +const MAX_OWNER_BYTES = 64 * 1_024; +const MAX_RESPONSE_BYTES = 72 * 1_024; +const RESERVATION_ID = /^r1_[A-Za-z0-9_-]{22}$/; +const TICKET_ID = /^t1_[A-Za-z0-9_-]{22}$/; +const BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const textEncoder = new TextEncoder(); + +export interface FirstDisplayPortLikeV1 { + readonly addEventListener?: (name: string, listener: (event: unknown) => void) => void; + readonly close: () => void; + readonly postMessage: (message: unknown, transfer?: readonly FirstDisplayPortLikeV1[]) => void; + readonly removeEventListener?: (name: string, listener: (event: unknown) => void) => void; + readonly start?: () => void; +} + +export interface FirstDisplayChannelLikeV1 { + readonly port1: FirstDisplayPortLikeV1; + readonly port2: FirstDisplayPortLikeV1; +} + +export interface FirstDisplayRenderOwnerOptionsV1 { + readonly browser: Window; + readonly clearTimer: (handle: unknown) => void; + readonly createChannel: () => FirstDisplayChannelLikeV1; + readonly document: Document; + readonly fillRandom: (bytes: Uint8Array) => void; + readonly now: () => number; + readonly onNativeMutation?: () => boolean; + readonly setTimer: (callback: () => void, delayMs: number) => unknown; +} + +export interface FirstDisplayCommittedRenderArtifactV1 extends FirstDisplayRenderHandoffArtifactV1 { + readonly current: () => boolean; + readonly retire: () => void; +} + +export interface FirstDisplayRenderStrategyCallbacksV1 { + readonly accept: (artifact: FirstDisplayCommittedRenderArtifactV1) => void; + readonly fail: (reason: string) => void; +} + +export interface FirstDisplayRenderStrategyAttemptV1 { + readonly cancel: () => void; +} + +/** Source-specific authority is narrowed before it reaches the render owner. */ +export interface FirstDisplayRenderStrategyV1 { + readonly supports: (source: unknown) => boolean; + readonly start: ( + cycle: FirstDisplayGptBoundCycleV1, + overlay: boolean, + callbacks: FirstDisplayRenderStrategyCallbacksV1 + ) => FirstDisplayRenderStrategyAttemptV1 | undefined; + readonly dispose: () => void; +} + +export interface FirstDisplayRenderOwnerProtocolV1 { + readonly version: 1; + readonly id: 'render_owner'; + readonly createRenderBridge: ( + options: FirstDisplayRenderOwnerOptionsV1, + strategy?: FirstDisplayRenderStrategyV1 + ) => FirstDisplayRenderBridgeV1; +} + +interface RenderOwnerInitialBindings { + readonly observe: (name: 'protocol_version', value: number) => void; + readonly register: (protocol: FirstDisplayRenderOwnerProtocolV1) => () => void; +} + +interface PendingClaim { + readonly port: FirstDisplayPortLikeV1; + readonly source: object; +} + +interface Attempt { + readonly cycle: FirstDisplayGptBoundCycleV1; + readonly onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void; + readonly reservationId: string; + active: boolean; + claim: PendingClaim | undefined; + claimTimer: unknown; + controlPort: FirstDisplayPortLikeV1 | undefined; + controlRelease: (() => void) | undefined; + execution: FirstDisplayRenderStrategyAttemptV1 | undefined; + gam: FirstDisplayGptRenderResult | undefined; + insertionTimer: unknown; + inserted: boolean; + ownerSource: object | undefined; + ownerTicket: string | undefined; + phaseValue: + | 'waiting_for_gam_and_claim' + | 'waiting_for_owner' + | 'waiting_for_insertion' + | 'rendering_direct'; + ticket: string | undefined; +} + +interface LiveTicket { + readonly attempt: Attempt; + readonly expiresAtInternal: number; + readonly ordinalInternal: number; + readonly registryState: 'live'; + timer?: unknown; +} + +interface TicketTombstone { + readonly expiresAtInternal: number; + readonly ordinalInternal: number; + readonly registryState: 'tombstone'; + timer?: unknown; +} + +type TicketEntry = LiveTicket | TicketTombstone; + +interface ReservationEntry { + readonly expiresAtInternal: number; + readonly ordinalInternal: number; + readonly registryState: 'live' | 'tombstone'; +} + +function utf8Length(value: string): number { + return textEncoder.encode(value).byteLength; +} + +function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const names = Object.getOwnPropertyNames(value).sort(); + const expected = [...keys].sort(); + if (names.length !== expected.length) return undefined; + const result: Record = {}; + for (let index = 0; index < expected.length; index += 1) { + const name = expected[index]; + if (!name || names[index] !== name) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[name] = descriptor.value; + } + return result; + } catch { + return undefined; + } +} + +function parseJson(source: string): unknown { + if (utf8Length(source) > MAX_GLOBAL_MESSAGE_BYTES) return undefined; + try { + return JSON.parse(source) as unknown; + } catch { + return undefined; + } +} + +function routingMessage(data: unknown): Readonly<{ + adId?: string; + lifecycleTicket?: string; + message?: string; +}> { + const value = typeof data === 'string' ? parseJson(data) : data; + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return {}; + } + const read = (name: string): string | undefined => { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + return descriptor?.enumerable && 'value' in descriptor && typeof descriptor.value === 'string' + ? descriptor.value + : undefined; + }; + const message = read('message'); + const adId = read('adId'); + const lifecycleTicket = read('lifecycleTicket'); + return { + ...(message ? { message } : {}), + ...(adId ? { adId } : {}), + ...(lifecycleTicket ? { lifecycleTicket } : {}), + }; + } catch { + return {}; + } +} + +function exactPrebidRequest(data: unknown): Record | undefined { + if (typeof data !== 'string') return undefined; + const fields = exactRecord(parseJson(data), ['message', 'adId', 'adServerDomain']); + return fields?.message === 'Prebid Request' && + typeof fields.adId === 'string' && + RESERVATION_ID.test(fields.adId) && + typeof fields.adServerDomain === 'string' && + fields.adServerDomain.length > 0 && + utf8Length(fields.adServerDomain) <= MAX_DOMAIN_BYTES && + data === + JSON.stringify({ + message: 'Prebid Request', + adId: fields.adId, + adServerDomain: fields.adServerDomain, + }) + ? fields + : undefined; +} + +function exactOwnerRegistration(data: unknown): Record | undefined { + if (typeof data !== 'string') return undefined; + const fields = exactRecord(parseJson(data), ['message', 'adId', 'version', 'lifecycleTicket']); + return fields?.message === 'TS Render Owner Register' && + fields.version === 1 && + typeof fields.adId === 'string' && + RESERVATION_ID.test(fields.adId) && + typeof fields.lifecycleTicket === 'string' && + TICKET_ID.test(fields.lifecycleTicket) && + data === + JSON.stringify({ + message: 'TS Render Owner Register', + adId: fields.adId, + version: 1, + lifecycleTicket: fields.lifecycleTicket, + }) + ? fields + : undefined; +} + +function eventField( + event: unknown, + name: 'data' | 'ports' | 'source', + trustedPrototype: object | undefined +): unknown { + try { + if (typeof event !== 'object' || event === null) return undefined; + const own = Object.getOwnPropertyDescriptor(event, name); + if (own) return 'value' in own ? own.value : undefined; + const prototype = Object.getPrototypeOf(event); + if (!trustedPrototype || prototype !== trustedPrototype) return undefined; + const inherited = Object.getOwnPropertyDescriptor(trustedPrototype, name); + return inherited?.get ? Reflect.apply(inherited.get, event, []) : undefined; + } catch { + return undefined; + } +} + +function usablePort(value: unknown): value is FirstDisplayPortLikeV1 { + try { + return ( + typeof value === 'object' && + value !== null && + typeof Reflect.get(value, 'postMessage') === 'function' && + typeof Reflect.get(value, 'close') === 'function' + ); + } catch { + return false; + } +} + +function inspectPorts( + event: unknown, + trustedPrototype: object | undefined +): + | Readonly<{ + exact: boolean; + originalCount: number; + ports: readonly FirstDisplayPortLikeV1[]; + }> + | undefined { + const value = eventField(event, 'ports', trustedPrototype); + try { + if (!Array.isArray(value)) return undefined; + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if (!length || !('value' in length) || !Number.isSafeInteger(length.value)) return undefined; + let exact = + Object.getPrototypeOf(value) === Array.prototype && + Object.getOwnPropertySymbols(value).length === 0 && + Object.getOwnPropertyNames(value).length === length.value + 1; + const ports: FirstDisplayPortLikeV1[] = []; + const seen = new Set(); + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor) || !usablePort(descriptor.value)) { + exact = false; + continue; + } + if (seen.has(descriptor.value)) { + exact = false; + continue; + } + seen.add(descriptor.value); + ports.push(descriptor.value); + } + return { exact, originalCount: length.value, ports }; + } catch { + return undefined; + } +} + +function eventSource(event: unknown, trustedPrototype: object | undefined): object | undefined { + const value = eventField(event, 'source', trustedPrototype); + return (typeof value === 'object' || typeof value === 'function') && value !== null + ? value + : 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 closePort(port: FirstDisplayPortLikeV1 | undefined): void { + try { + port?.close(); + } catch { + // The endpoint is already generation-inert. + } +} + +function post( + port: FirstDisplayPortLikeV1, + data: unknown, + transfer: readonly FirstDisplayPortLikeV1[] = [] +): boolean { + try { + Reflect.apply(port.postMessage, port, [data, transfer]); + return true; + } catch { + return false; + } +} + +function installPortListeners( + port: FirstDisplayPortLikeV1, + receive: (event: unknown) => void, + receiveError: () => void, + publishRelease: (release: () => void) => void +): boolean { + try { + if (typeof port.addEventListener !== 'function') return false; + let live = true; + const release = (): void => { + if (!live) return; + live = false; + try { + if (typeof port.removeEventListener === 'function') { + Reflect.apply(port.removeEventListener, port, ['message', receive]); + Reflect.apply(port.removeEventListener, port, ['messageerror', receiveError]); + } + } catch { + // Port closure remains authoritative. + } + }; + Reflect.apply(port.addEventListener, port, ['message', receive]); + Reflect.apply(port.addEventListener, port, ['messageerror', receiveError]); + publishRelease(release); + if (typeof port.start === 'function') Reflect.apply(port.start, port, []); + return live; + } catch { + return false; + } +} + +function encodeOpaque(bytes: Uint8Array): string { + let output = ''; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 6) { + bits -= 6; + output += BASE64URL[(buffer >>> bits) & 63]; + } + buffer &= (1 << bits) - 1; + } + if (bits > 0) output += BASE64URL[(buffer << (6 - bits)) & 63]; + return output; +} + +function refusedResponse(adId: string): string { + return JSON.stringify({ + message: 'Prebid Response', + adId, + rendererVersion: '4', + tsOwner: { version: 1, status: 'refused' }, + }); +} + +function ownerRefused(adId: string): string { + return JSON.stringify({ message: 'TS Render Owner Refused', adId, version: 1 }); +} + +function snapshotFrameAttributes(frame: HTMLIFrameElement): string | undefined { + try { + return JSON.stringify( + [...frame.attributes] + .map((attribute) => [attribute.name, attribute.value] as const) + .sort(([left], [right]) => left.localeCompare(right)) + ); + } catch { + return undefined; + } +} + +function exactPublisherFrame(document: Document, source: object): HTMLIFrameElement | undefined { + try { + const frames = document.querySelectorAll('iframe'); + let selected: HTMLIFrameElement | undefined; + for (let index = 0; index < frames.length; index += 1) { + const candidate = frames.item(index); + if (candidate?.isConnected && candidate.contentWindow === source) { + if (selected) return undefined; + selected = candidate; + } + } + return selected; + } catch { + return undefined; + } +} + +function resizeCollapsedPucShell( + document: Document, + source: object, + width: number, + height: number +): boolean { + try { + const browser = document.defaultView; + const selected = exactPublisherFrame(document, source); + if (!browser || !selected || width <= 0 || height <= 0) return false; + const onePixelAttribute = (element: Element, name: 'width' | 'height'): boolean => { + const value = element.getAttribute(name); + if (value === null || !/^\d+(?:\.\d+)?$/.test(value)) return false; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed <= 1; + }; + const ordinaryCollapsed = (element: HTMLElement): boolean => { + const style = browser.getComputedStyle(element); + const pixel = (value: string): boolean => { + const match = /^(\d+(?:\.\d+)?)px$/.exec(value); + return match !== null && Number(match[1]) <= 1; + }; + return ( + style.position !== 'fixed' && + style.position !== 'sticky' && + pixel(style.width) && + pixel(style.height) + ); + }; + if ( + !onePixelAttribute(selected, 'width') || + !onePixelAttribute(selected, 'height') || + !ordinaryCollapsed(selected) || + selected.closest('a,[data-anchor-status]') !== null + ) + return false; + const wrapper = selected.parentElement; + if ( + !wrapper || + wrapper === document.body || + wrapper === document.documentElement || + wrapper.tagName === 'A' || + !wrapper.isConnected || + !ordinaryCollapsed(wrapper) || + wrapper.closest('a,[data-anchor-status]') !== null + ) + return false; + selected.style.setProperty('width', `${width}px`); + selected.style.setProperty('height', `${height}px`); + wrapper.style.setProperty('width', `${width}px`); + wrapper.style.setProperty('height', `${height}px`); + return true; + } catch { + return false; + } +} + +function configureAdmFrame(frame: HTMLIFrameElement, width: number, height: number): void { + frame.setAttribute('sandbox', ADM_SANDBOX); + frame.setAttribute('referrerpolicy', 'no-referrer'); + frame.setAttribute('width', String(width)); + frame.setAttribute('height', String(height)); + frame.setAttribute('scrolling', 'no'); + frame.setAttribute('frameborder', '0'); + frame.setAttribute('marginwidth', '0'); + frame.setAttribute('marginheight', '0'); + frame.setAttribute('title', 'Ad content'); + frame.setAttribute('aria-label', 'Advertisement'); + frame.setAttribute( + 'style', + `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + ); +} + +function validCycle( + cycle: FirstDisplayGptBoundCycleV1, + strategy: FirstDisplayRenderStrategyV1 | undefined +): boolean { + try { + const source = cycle.bid.renderSource; + const document = cycle.element.ownerDocument; + let exactElementMatches = 0; + const elements = document.getElementsByTagName('*'); + for (let index = 0; index < elements.length; index += 1) { + const candidate = elements.item(index); + if (candidate?.id !== cycle.element.id) continue; + if (candidate !== cycle.element) return false; + exactElementMatches += 1; + } + return ( + cycle.isCurrent() && + cycle.slotId === cycle.bid.slot && + cycle.slotId === cycle.placement.slot && + exactElementMatches === 1 && + document.getElementById(cycle.element.id) === cycle.element && + RESERVATION_ID.test(cycle.bid.rendererReservationId) && + (source.type === 'adm' || strategy?.supports(source) === true) + ); + } catch { + return false; + } +} + +function publisherArtifact( + document: Document, + attempt: Attempt +): FirstDisplayCommittedRenderArtifactV1 | undefined { + const source = attempt.ownerSource; + if (!source) return undefined; + const frame = exactPublisherFrame(document, source); + const attributes = frame ? snapshotFrameAttributes(frame) : undefined; + const parent = frame?.parentNode; + const frameWindow = frame?.contentWindow; + if (!frame || attributes === undefined || !parent || !frameWindow) return undefined; + return Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + identity: frame, + kind: 'gpt_adm' as const, + owner: 'publisher' as const, + slotId: attempt.cycle.slotId, + token: attempt.reservationId, + current: () => { + try { + return ( + frame.ownerDocument === document && + frame.isConnected && + frame.parentNode === parent && + frame.contentWindow === frameWindow && + frame.contentWindow === source && + snapshotFrameAttributes(frame) === attributes + ); + } catch { + return false; + } + }, + retire: () => undefined, + }); +} + +function directAdmExecution( + document: Document, + cycle: FirstDisplayGptBoundCycleV1, + callbacks: FirstDisplayRenderStrategyCallbacksV1, + setTimer: (callback: () => void, delayMs: number) => unknown, + clearTimer: (handle: unknown) => void +): FirstDisplayRenderStrategyAttemptV1 | undefined { + const source = cycle.bid.renderSource; + if (source.type !== 'adm') return undefined; + let live = true; + let timer: unknown; + let frame: HTMLIFrameElement | undefined; + const retire = (): void => { + if (!live) return; + live = false; + try { + if (timer !== undefined) clearTimer(timer); + } catch { + // Exact-node retirement remains authoritative. + } + if (!frame) return; + frame.onload = null; + frame.onerror = null; + try { + frame.remove(); + } catch { + // The exact node cannot regain authority. + } + }; + try { + const created = document.createElement('iframe'); + frame = created; + configureAdmFrame(created, source.width, source.height); + const intended = `${source.adm}`; + created.onload = () => { + if (!live) return; + const attributes = snapshotFrameAttributes(created); + const frameWindow = created.contentWindow; + if ( + !cycle.isCurrent() || + created.parentNode !== cycle.element || + created.srcdoc !== intended || + created.getAttribute('src') !== null || + attributes === undefined || + !frameWindow + ) { + callbacks.fail(ADM_DOCUMENT_NO_LOAD); + return; + } + if (timer !== undefined) clearTimer(timer); + timer = undefined; + created.onload = null; + created.onerror = null; + callbacks.accept( + Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + identity: created, + kind: 'gpt_adm' as const, + owner: 'trusted_server' as const, + slotId: cycle.slotId, + token: cycle.bid.rendererReservationId, + current: () => { + try { + return ( + live && + cycle.isCurrent() && + created.isConnected && + created.parentNode === cycle.element && + created.contentWindow === frameWindow && + created.srcdoc === intended && + created.getAttribute('src') === null && + snapshotFrameAttributes(created) === attributes + ); + } catch { + return false; + } + }, + retire, + }) + ); + }; + created.onerror = () => callbacks.fail(ADM_DOCUMENT_NO_LOAD); + created.srcdoc = intended; + cycle.element.appendChild(created); + let scheduling = true; + let firedSynchronously = false; + timer = setTimer(() => { + if (scheduling) { + firedSynchronously = true; + return; + } + if (live) callbacks.fail(ADM_DOCUMENT_NO_LOAD); + }, ADM_LOAD_DEADLINE_MS); + scheduling = false; + if (timer === undefined || firedSynchronously) { + if (timer !== undefined) clearTimer(timer); + retire(); + return undefined; + } + return Object.freeze({ cancel: retire }); + } catch { + retire(); + return undefined; + } +} + +/** Own the bounded, source-neutral render journal for one first-display batch. */ +export function createFirstDisplayRenderJournal( + options: FirstDisplayRenderOwnerOptionsV1, + strategy?: FirstDisplayRenderStrategyV1 +): FirstDisplayRenderBridgeV1 { + const attempts = new Map(); + const reservations = new Map(); + const tickets = new Map(); + const committed = new Map(); + const timers = new Set(); + let disposed = false; + let sealed = false; + let ingressClosed = false; + let handoffCaptured = false; + let committedArtifactsDetached = false; + let nextTicketOrdinal = 1; + let nextReservationOrdinal = 1; + let lastNow = Number.NEGATIVE_INFINITY; + const messageEventPrototype = (() => { + try { + const constructor = Reflect.get(options.browser, 'MessageEvent'); + const prototype = + typeof constructor === 'function' ? Reflect.get(constructor, 'prototype') : undefined; + return typeof prototype === 'object' && prototype !== null ? prototype : undefined; + } catch { + return undefined; + } + })(); + + const readNow = (): number | undefined => { + try { + const value = options.now(); + if (!Number.isFinite(value) || value < 0 || value < lastNow) return undefined; + lastNow = value; + return value; + } catch { + return undefined; + } + }; + + const notifyNativeMutation = (): void => { + try { + options.onNativeMutation?.(); + } catch { + // Observation cannot alter the admitted event. + } + }; + + const clearOwnedTimer = (handle: unknown): void => { + if (handle === undefined || !timers.delete(handle)) return; + try { + options.clearTimer(handle); + } catch { + // Timer state is already generation-inert. + } + }; + + const arm = (callback: () => void, delayMs: number): unknown => { + let handle: unknown; + let scheduling = true; + let firedSynchronously = false; + try { + handle = options.setTimer(() => { + if (scheduling) { + firedSynchronously = true; + return; + } + if (!timers.delete(handle)) return; + callback(); + }, delayMs); + } catch { + handle = undefined; + } + scheduling = false; + if (handle === undefined) return undefined; + if (firedSynchronously) { + try { + options.clearTimer(handle); + } catch { + // Synchronous timers are refused regardless of cleanup outcome. + } + return undefined; + } + timers.add(handle); + return handle; + }; + + const mint = (registry: ReadonlyMap): string | undefined => { + for (let draw = 0; draw < MAX_DRAWS; draw += 1) { + const bytes = new Uint8Array(16); + try { + options.fillRandom(bytes); + } catch { + return undefined; + } + const candidate = `t1_${encodeOpaque(bytes)}`; + if (!registry.has(candidate)) return candidate; + } + return undefined; + }; + + const retireTicket = (attempt: Attempt): void => { + const ticket = attempt.ticket; + attempt.ticket = undefined; + if (!ticket) return; + const entry = tickets.get(ticket); + if (entry?.registryState !== 'live' || entry.attempt !== attempt) return; + tickets.set(ticket, { + registryState: 'tombstone', + expiresAtInternal: entry.expiresAtInternal, + ordinalInternal: entry.ordinalInternal, + timer: entry.timer, + }); + }; + + const retireReservation = (attempt: Attempt): void => { + const entry = reservations.get(attempt.reservationId); + if (entry?.registryState !== 'live') return; + reservations.set(attempt.reservationId, { + expiresAtInternal: entry.expiresAtInternal, + ordinalInternal: entry.ordinalInternal, + registryState: 'tombstone', + }); + }; + + const releaseAttempt = ( + attempt: Attempt, + cancelExecution: boolean + ): Readonly<{ + claim?: PendingClaim; + controlPort?: FirstDisplayPortLikeV1; + controlRelease?: () => void; + execution?: FirstDisplayRenderStrategyAttemptV1; + }> => { + clearOwnedTimer(attempt.claimTimer); + clearOwnedTimer(attempt.insertionTimer); + attempt.claimTimer = undefined; + attempt.insertionTimer = undefined; + const claim = attempt.claim; + const controlPort = attempt.controlPort; + const controlRelease = attempt.controlRelease; + const execution = cancelExecution ? attempt.execution : undefined; + attempt.claim = undefined; + attempt.controlPort = undefined; + attempt.controlRelease = undefined; + attempt.execution = undefined; + attempt.ownerSource = undefined; + attempt.ownerTicket = undefined; + retireTicket(attempt); + retireReservation(attempt); + attempts.delete(attempt.reservationId); + return { + ...(claim ? { claim } : {}), + ...(controlPort ? { controlPort } : {}), + ...(controlRelease ? { controlRelease } : {}), + ...(execution ? { execution } : {}), + }; + }; + + const settle = ( + attempt: Attempt, + result: 'accepted' | 'failed' | 'cancelled', + reason = result === 'cancelled' ? NAVIGATION_DISPOSED : INTERNAL_ERROR, + candidateArtifact?: FirstDisplayCommittedRenderArtifactV1 + ): boolean => { + if (!attempt.active) return false; + if (result === 'accepted') { + if ( + !candidateArtifact || + candidateArtifact.slotId !== attempt.cycle.slotId || + candidateArtifact.token !== attempt.reservationId || + candidateArtifact.current() !== true + ) { + return settle(attempt, 'failed', INTERNAL_ERROR); + } + committed.set(attempt.cycle.slotId, candidateArtifact); + } + attempt.active = false; + const ticket = attempt.ownerTicket; + const released = releaseAttempt(attempt, result !== 'accepted'); + notifyNativeMutation(); + try { + released.controlRelease?.(); + } catch { + // State was detached before publisher-controlled cleanup. + } + try { + released.execution?.cancel(); + } catch { + // Strategy authority is detached from this generation. + } + if (released.controlPort && ticket) { + const settlement: Record = { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: ticket, + outcome: result, + }; + if (result !== 'accepted') settlement.reason = reason; + post(released.controlPort, settlement); + } + closePort(released.claim?.port); + closePort(released.controlPort); + try { + attempt.onTerminal(result, result === 'accepted' ? null : reason); + } catch { + // A terminal observer cannot restore detached authority. + } + return true; + }; + + const fail = (attempt: Attempt, reason: string, refuseClaim = false): boolean => { + if (!attempt.active) return false; + if (refuseClaim && attempt.claim) { + const claim = attempt.claim; + attempt.claim = undefined; + post(claim.port, refusedResponse(attempt.reservationId)); + closePort(claim.port); + if (!attempt.active) return false; + } + return settle(attempt, 'failed', reason); + }; + + const issueTicket = (attempt: Attempt): string | undefined => { + if (tickets.size >= MAX_CAPABILITIES || utf8Length(PUC_DYNAMIC_OWNER) > MAX_OWNER_BYTES) { + return undefined; + } + const ticket = mint(tickets); + if (!ticket || !TICKET_ID.test(ticket)) return undefined; + const issuedAt = readNow(); + if (issuedAt === undefined) return undefined; + const expiresAt = issuedAt + TICKET_TTL_MS; + const ordinal = nextTicketOrdinal; + nextTicketOrdinal += 1; + const entry: LiveTicket = { + registryState: 'live', + attempt, + expiresAtInternal: expiresAt, + ordinalInternal: ordinal, + }; + tickets.set(ticket, entry); + attempt.ticket = ticket; + entry.timer = arm(() => { + const current = tickets.get(ticket); + if (current !== entry) { + const observedAt = readNow(); + if ( + current?.registryState === 'tombstone' && + observedAt !== undefined && + current.expiresAtInternal <= observedAt + ) { + tickets.delete(ticket); + notifyNativeMutation(); + } + return; + } + tickets.delete(ticket); + attempt.ticket = undefined; + notifyNativeMutation(); + fail(attempt, 'owner_registration_timeout'); + }, TICKET_TTL_MS); + if (entry.timer === undefined) { + tickets.delete(ticket); + attempt.ticket = undefined; + return undefined; + } + return ticket; + }; + + const join = (attempt: Attempt): boolean => { + const claim = attempt.claim; + if ( + !attempt.active || + !claim || + attempt.gam !== 'nonempty_gam' || + attempt.phaseValue !== 'waiting_for_gam_and_claim' + ) + return false; + clearOwnedTimer(attempt.claimTimer); + attempt.claimTimer = undefined; + const ticket = issueTicket(attempt); + if (!ticket) return fail(attempt, 'capability_registry_full', true); + const response = JSON.stringify({ + message: 'Prebid Response', + adId: attempt.reservationId, + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: attempt.cycle.bid.renderSource.type, + lifecycleTicket: ticket, + }, + }); + attempt.claim = undefined; + attempt.ownerSource = claim.source; + attempt.phaseValue = 'waiting_for_owner'; + retireReservation(attempt); + const source = attempt.cycle.bid.renderSource; + resizeCollapsedPucShell(options.document, claim.source, source.width, source.height); + if ( + !attempt.active || + attempt.phaseValue !== 'waiting_for_owner' || + attempt.ownerSource !== claim.source || + attempt.ticket !== ticket + ) { + closePort(claim.port); + return false; + } + const posted = utf8Length(response) <= MAX_RESPONSE_BYTES && post(claim.port, response); + closePort(claim.port); + if (!posted && attempt.active) return fail(attempt, INTERNAL_ERROR); + return posted; + }; + + const beginExecution = (attempt: Attempt, overlay: boolean): boolean => { + type Pending = + | Readonly<{ kind: 'accept'; artifact: FirstDisplayCommittedRenderArtifactV1 }> + | Readonly<{ kind: 'fail'; reason: string }>; + let starting = true; + let pending: Pending | undefined; + let duplicate = false; + const enqueue = (next: Pending): void => { + if (!attempt.active) return; + if (starting) { + if (pending) duplicate = true; + else pending = next; + return; + } + if (next.kind === 'accept') settle(attempt, 'accepted', INTERNAL_ERROR, next.artifact); + else fail(attempt, next.reason); + }; + const callbacks = Object.freeze({ + accept: (artifact: FirstDisplayCommittedRenderArtifactV1) => + enqueue(Object.freeze({ kind: 'accept' as const, artifact })), + fail: (reason: string) => enqueue(Object.freeze({ kind: 'fail' as const, reason })), + }); + let execution: FirstDisplayRenderStrategyAttemptV1 | undefined; + if (attempt.cycle.bid.renderSource.type === 'adm') { + if (overlay) return false; + execution = directAdmExecution( + options.document, + attempt.cycle, + callbacks, + options.setTimer, + options.clearTimer + ); + } else if (strategy?.supports(attempt.cycle.bid.renderSource) === true) { + try { + execution = strategy.start(attempt.cycle, overlay, callbacks); + } catch { + execution = undefined; + } + } + starting = false; + if (!execution || duplicate) { + try { + execution?.cancel(); + } catch { + // Refused execution cannot retain authority. + } + return false; + } + attempt.execution = execution; + if (pending?.kind === 'accept') settle(attempt, 'accepted', INTERNAL_ERROR, pending.artifact); + else if (pending?.kind === 'fail') fail(attempt, pending.reason); + return true; + }; + + const ownerInserted = (attempt: Attempt): void => { + if (!attempt.active || attempt.phaseValue !== 'waiting_for_insertion' || attempt.inserted) + return; + attempt.inserted = true; + clearOwnedTimer(attempt.insertionTimer); + attempt.insertionTimer = undefined; + if (attempt.cycle.bid.renderSource.type === 'adm') { + attempt.insertionTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); + if (attempt.insertionTimer === undefined) fail(attempt, INTERNAL_ERROR); + } + }; + + const handleOwnerControl = (attempt: Attempt, event: unknown): void => { + if (!attempt.active) return; + const ports = inspectPorts(event, messageEventPrototype); + if (!ports?.exact || ports.originalCount !== 0 || ports.ports.length !== 0) { + fail(attempt, INTERNAL_ERROR); + return; + } + const message = exactRecord(eventField(event, 'data', messageEventPrototype), [ + 'message', + 'version', + 'lifecycleTicket', + ]); + const ticket = attempt.ownerTicket; + if ( + message?.message === 'TS Owner Inserted' && + message.version === 1 && + message.lifecycleTicket === ticket + ) { + ownerInserted(attempt); + return; + } + if (attempt.cycle.bid.renderSource.type !== 'adm') { + fail(attempt, INTERNAL_ERROR); + return; + } + if ( + message?.message === 'TS ADM Loaded' && + message.version === 1 && + message.lifecycleTicket === ticket && + attempt.inserted + ) { + clearOwnedTimer(attempt.insertionTimer); + attempt.insertionTimer = undefined; + const artifact = publisherArtifact(options.document, attempt); + if (artifact) settle(attempt, 'accepted', INTERNAL_ERROR, artifact); + else fail(attempt, ADM_DOCUMENT_NO_LOAD); + return; + } + if ( + message?.message === 'TS ADM Failed' && + message.version === 1 && + message.lifecycleTicket === ticket + ) { + fail(attempt, ADM_DOCUMENT_NO_LOAD); + return; + } + fail(attempt, ADM_DOCUMENT_NO_LOAD); + }; + + const startOwner = (attempt: Attempt): boolean => { + const controlPort = attempt.controlPort; + const ticket = attempt.ownerTicket; + if (!controlPort || !ticket || !attempt.active) return false; + if (!validCycle(attempt.cycle, strategy)) return fail(attempt, 'slot_unresolved'); + attempt.insertionTimer = arm( + () => fail(attempt, 'owner_insertion_timeout'), + INSERTION_DEADLINE_MS + ); + if (attempt.insertionTimer === undefined) return fail(attempt, INTERNAL_ERROR); + if (attempt.cycle.bid.renderSource.type === 'adm') { + return ( + post(controlPort, { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: ticket, + source: attempt.cycle.bid.renderSource, + }) || fail(attempt, INTERNAL_ERROR) + ); + } + if (!beginExecution(attempt, true)) return fail(attempt, 'winner_not_renderable'); + if (attempt.active) ownerInserted(attempt); + return ( + post(controlPort, { + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: ticket, + }) || fail(attempt, INTERNAL_ERROR) + ); + }; + + const handleOwnerRegistration = ( + event: unknown, + data: unknown, + routing: Readonly<{ adId?: string; lifecycleTicket?: string }> + ): void => { + const ticket = routing.lifecycleTicket; + if (!ticket) return; + const beforeSuppress = tickets.get(ticket); + if (!beforeSuppress || !suppress(event)) return; + const entry = tickets.get(ticket); + const inspection = inspectPorts(event, messageEventPrototype); + const responsePort = inspection?.ports[0]; + const refuse = (): void => { + if (responsePort) post(responsePort, ownerRefused(routing.adId ?? '')); + for (const port of inspection?.ports ?? []) closePort(port); + }; + if (entry !== beforeSuppress || entry.registryState !== 'live') { + refuse(); + return; + } + const exact = exactOwnerRegistration(data); + const attempt = entry.attempt; + if ( + !exact || + !inspection?.exact || + inspection.originalCount !== 1 || + inspection.ports.length !== 1 || + !responsePort || + exact.adId !== attempt.reservationId || + exact.lifecycleTicket !== ticket || + eventSource(event, messageEventPrototype) !== attempt.ownerSource || + !attempt.active || + attempt.phaseValue !== 'waiting_for_owner' + ) { + refuse(); + if (attempt.active) fail(attempt, 'bridge_id_mismatch'); + return; + } + let channel: FirstDisplayChannelLikeV1; + try { + channel = options.createChannel(); + } catch { + post(responsePort, ownerRefused(attempt.reservationId)); + closePort(responsePort); + fail(attempt, INTERNAL_ERROR); + return; + } + if ( + tickets.get(ticket) !== entry || + !attempt.active || + attempt.phaseValue !== 'waiting_for_owner' + ) { + closePort(channel.port1); + closePort(channel.port2); + refuse(); + return; + } + retireTicket(attempt); + attempt.ownerTicket = ticket; + attempt.controlPort = channel.port1; + attempt.phaseValue = 'waiting_for_insertion'; + const listening = installPortListeners( + channel.port1, + (message) => handleOwnerControl(attempt, message), + () => + fail( + attempt, + attempt.cycle.bid.renderSource.type === 'adm' ? ADM_DOCUMENT_NO_LOAD : INTERNAL_ERROR + ), + (release) => { + attempt.controlRelease = release; + } + ); + if (!listening || !attempt.active) { + closePort(responsePort); + closePort(channel.port2); + if (attempt.active) fail(attempt, INTERNAL_ERROR); + return; + } + const registered = JSON.stringify({ + message: 'TS Render Owner Registered', + adId: attempt.reservationId, + version: 1, + lifecycleTicket: ticket, + }); + const posted = post(responsePort, registered, [channel.port2]); + closePort(responsePort); + closePort(channel.port2); + if (!posted || !attempt.active || !startOwner(attempt)) { + if (attempt.active) fail(attempt, INTERNAL_ERROR); + } + }; + + const dispatch = (event: unknown): void => { + if (disposed || ingressClosed) return; + const data = eventField(event, 'data', messageEventPrototype); + const routing = routingMessage(data); + if (routing.message === 'TS Render Owner Register') { + notifyNativeMutation(); + handleOwnerRegistration(event, data, routing); + return; + } + if (routing.message !== 'Prebid Request' || !routing.adId) return; + const beforeSuppress = reservations.get(routing.adId); + if (!beforeSuppress || !suppress(event)) return; + notifyNativeMutation(); + const reservationState = reservations.get(routing.adId); + const inspection = inspectPorts(event, messageEventPrototype); + const responsePort = inspection?.ports[0]; + const refuse = (): void => { + if (responsePort) post(responsePort, refusedResponse(routing.adId!)); + for (const port of inspection?.ports ?? []) closePort(port); + }; + if ( + reservationState !== beforeSuppress || + reservationState.registryState !== 'live' || + !inspection?.exact || + inspection.originalCount !== 1 || + inspection.ports.length !== 1 || + !responsePort + ) { + refuse(); + return; + } + const exact = exactPrebidRequest(data); + const attempt = attempts.get(routing.adId); + const source = eventSource(event, messageEventPrototype); + if ( + !exact || + exact.adId !== routing.adId || + !attempt?.active || + attempt.phaseValue !== 'waiting_for_gam_and_claim' || + attempt.claim || + !source + ) { + refuse(); + return; + } + attempt.claim = Object.freeze({ port: responsePort, source }); + if (attempt.gam === 'nonempty_gam') join(attempt); + }; + + try { + options.browser.addEventListener('message', dispatch as EventListener, true); + } catch { + throw new TypeError('tsjs'); + } + + const sweepCommittedArtifacts = (): number => { + if (disposed || committedArtifactsDetached) return 0; + let retired = 0; + for (const [slotId, artifact] of [...committed.entries()]) { + if (committed.get(slotId) !== artifact || artifact.current()) continue; + committed.delete(slotId); + try { + artifact.retire(); + } catch { + // Invalidated identity is already detached from the journal. + } + retired += 1; + } + if (retired > 0) notifyNativeMutation(); + return retired; + }; + + return Object.freeze({ + bind: ( + cycle: FirstDisplayGptBoundCycleV1, + onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void + ): boolean => { + if ( + disposed || + ingressClosed || + sealed || + typeof onTerminal !== 'function' || + !validCycle(cycle, strategy) || + reservations.size >= MAX_CAPABILITIES || + reservations.has(cycle.bid.rendererReservationId) + ) + return false; + const observedAt = readNow(); + if (observedAt === undefined) return false; + const expiresAt = observedAt + RESERVATION_TTL_MS; + const ordinal = nextReservationOrdinal; + if (!Number.isFinite(expiresAt) || expiresAt <= observedAt || ordinal > 4_294_967_295) { + return false; + } + const attempt: Attempt = { + active: true, + claim: undefined, + claimTimer: undefined, + controlPort: undefined, + controlRelease: undefined, + cycle, + execution: undefined, + gam: undefined, + insertionTimer: undefined, + inserted: false, + onTerminal, + ownerSource: undefined, + ownerTicket: undefined, + phaseValue: 'waiting_for_gam_and_claim', + reservationId: cycle.bid.rendererReservationId, + ticket: undefined, + }; + attempts.set(attempt.reservationId, attempt); + reservations.set(attempt.reservationId, { + expiresAtInternal: expiresAt, + ordinalInternal: ordinal, + registryState: 'live', + }); + nextReservationOrdinal += 1; + return true; + }, + recordGam: ( + cycle: FirstDisplayGptBoundCycleV1, + result: FirstDisplayGptRenderResult + ): boolean => { + const attempt = attempts.get(cycle.bid.rendererReservationId); + if ( + !attempt?.active || + attempt.cycle !== cycle || + attempt.gam || + attempt.phaseValue !== 'waiting_for_gam_and_claim' + ) + return false; + attempt.gam = result; + if (result === 'gam_empty') { + attempt.phaseValue = 'rendering_direct'; + retireReservation(attempt); + const claim = attempt.claim; + attempt.claim = undefined; + if (claim) { + post(claim.port, refusedResponse(attempt.reservationId)); + closePort(claim.port); + if (!attempt.active) return false; + } + return beginExecution(attempt, false) || fail(attempt, 'winner_not_renderable'); + } + if (attempt.claim) return join(attempt); + attempt.claimTimer = arm(() => fail(attempt, 'bridge_claim_timeout'), CLAIM_DEADLINE_MS); + return attempt.claimTimer !== undefined || fail(attempt, INTERNAL_ERROR); + }, + recordFailure: (cycle: FirstDisplayGptBoundCycleV1): boolean => { + const attempt = attempts.get(cycle.bid.rendererReservationId); + return Boolean( + attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') + ); + }, + retire: (cycle: FirstDisplayGptBoundCycleV1): boolean => { + if (disposed || committedArtifactsDetached) return false; + const artifact = committed.get(cycle.slotId); + if (!artifact || artifact.token !== cycle.bid.rendererReservationId) return false; + committed.delete(cycle.slotId); + try { + artifact.retire(); + } catch { + // Exact identity is already retired from the journal. + } + notifyNativeMutation(); + return true; + }, + sweepCommittedArtifacts, + sealTsAdmission: (): void => { + if (disposed || [...attempts.values()].some((attempt) => attempt.active)) { + throw new TypeError('tsjs'); + } + sealed = true; + }, + closeIngress: (): boolean => { + if ( + disposed || + ingressClosed || + !sealed || + [...attempts.values()].some((attempt) => attempt.active) + ) + return false; + ingressClosed = true; + try { + options.browser.removeEventListener('message', dispatch as EventListener, true); + } catch { + // Closed generation state remains authoritative. + } + for (const handle of [...timers]) clearOwnedTimer(handle); + return true; + }, + captureHandoff: () => { + if (disposed || !ingressClosed || handoffCaptured) return undefined; + sweepCommittedArtifacts(); + const observedAt = readNow(); + if (observedAt === undefined) return undefined; + const reservationTombstones = [...reservations.entries()] + .filter( + ([, entry]) => entry.registryState === 'tombstone' && entry.expiresAtInternal > observedAt + ) + .map(([value, entry]) => + Object.freeze({ + kind: 'reservation' as const, + value, + expiresAtMs: entry.expiresAtInternal, + ordinal: entry.ordinalInternal, + }) + ); + const ticketTombstones = [...tickets.entries()] + .filter( + ([, entry]) => entry.registryState === 'tombstone' && entry.expiresAtInternal > observedAt + ) + .map(([value, entry]) => + Object.freeze({ + kind: 'ticket' as const, + value, + expiresAtMs: entry.expiresAtInternal, + ordinal: entry.ordinalInternal, + }) + ); + const artifacts = [...committed.values()].map((artifact) => + Object.freeze({ + hostPosition: artifact.hostPosition, + hostPositionPriority: artifact.hostPositionPriority, + identity: artifact.identity, + kind: artifact.kind, + owner: artifact.owner, + slotId: artifact.slotId, + token: artifact.token, + }) + ); + handoffCaptured = true; + return Object.freeze({ + artifacts: Object.freeze(artifacts), + clockEpochMs: observedAt, + nextReservationOrdinal, + nextTicketOrdinal, + tombstones: Object.freeze([...reservationTombstones, ...ticketTombstones]), + }); + }, + detachCommittedArtifacts: (): boolean => { + if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { + return false; + } + if (sweepCommittedArtifacts() > 0) return false; + committedArtifactsDetached = true; + return true; + }, + dispose: (): void => { + if (disposed) return; + disposed = true; + if (!ingressClosed) { + ingressClosed = true; + try { + options.browser.removeEventListener('message', dispatch as EventListener, true); + } catch { + // Generation latching keeps a failed physical removal inert. + } + } + for (const attempt of [...attempts.values()]) { + if (attempt.active) settle(attempt, 'cancelled', NAVIGATION_DISPOSED); + } + for (const handle of [...timers]) clearOwnedTimer(handle); + if (!committedArtifactsDetached) { + for (const artifact of committed.values()) { + try { + artifact.retire(); + } catch { + // Exact artifact retirement is best-effort after ownership removal. + } + } + } + committed.clear(); + attempts.clear(); + reservations.clear(); + tickets.clear(); + try { + strategy?.dispose(); + } catch { + // Strategy authority is generation-latched. + } + }, + }); +} + +function exactBindings(candidate: unknown): RenderOwnerInitialBindings | undefined { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + !Object.isFrozen(candidate) || + Reflect.ownKeys(candidate).length !== 2 + ) + return undefined; + const observe = Object.getOwnPropertyDescriptor(candidate, 'observe'); + const register = Object.getOwnPropertyDescriptor(candidate, 'register'); + if ( + !observe?.enumerable || + !('value' in observe) || + typeof observe.value !== 'function' || + !register?.enumerable || + !('value' in register) || + typeof register.value !== 'function' + ) + return undefined; + return candidate as RenderOwnerInitialBindings; + } catch { + return undefined; + } +} + +/** Install the one-use release-private source-neutral initial render owner. */ +export function installRenderOwnerInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): Readonly<{ version: 1; id: 'render_owner' }> { + const bindings = exactBindings(candidate); + if (!bindings || typeof own !== 'function') throw new TypeError('tsjs'); + let consumed = false; + let created: FirstDisplayRenderBridgeV1 | undefined; + const protocol: FirstDisplayRenderOwnerProtocolV1 = Object.freeze({ + version: 1, + id: 'render_owner', + createRenderBridge: ( + options: FirstDisplayRenderOwnerOptionsV1, + strategy?: FirstDisplayRenderStrategyV1 + ) => { + if (consumed) throw new TypeError('tsjs'); + consumed = true; + created = createFirstDisplayRenderJournal(options, strategy); + return created; + }, + }); + const release = bindings.register(protocol); + if (typeof release !== 'function') throw new TypeError('tsjs'); + own(() => { + const bridge = created; + created = undefined; + try { + bridge?.dispose(); + } finally { + release(); + } + }); + bindings.observe('protocol_version', 1); + return Object.freeze({ version: 1, id: 'render_owner' }); +} diff --git a/crates/trusted-server-js/lib/src/first_display/slices/aps.ts b/crates/trusted-server-js/lib/src/first_display/slices/aps.ts index 03d318ec0..b8b18f2d0 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/aps.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/aps.ts @@ -4,4 +4,4 @@ import { defineInitialSlice, registerInitialSlice } from './definition'; export const APS_INITIAL_SLICE = defineInitialSlice('aps_initial', installApsInitial); -registerInitialSlice(APS_INITIAL_SLICE, 2); +registerInitialSlice(APS_INITIAL_SLICE, 3); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/creative.ts b/crates/trusted-server-js/lib/src/first_display/slices/creative.ts index ef924f406..047a5703e 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/creative.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/creative.ts @@ -7,4 +7,4 @@ export const CREATIVE_INITIAL_SLICE = defineInitialSlice( installCreativeInitial ); -registerInitialSlice(CREATIVE_INITIAL_SLICE, 3); +registerInitialSlice(CREATIVE_INITIAL_SLICE, 4); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts b/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts index 3a3c46936..d9c9ed793 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts @@ -7,4 +7,4 @@ export const DATADOME_INITIAL_SLICE = defineInitialSlice( installDataDomeInitial ); -registerInitialSlice(DATADOME_INITIAL_SLICE, 4); +registerInitialSlice(DATADOME_INITIAL_SLICE, 5); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts b/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts index 044315d21..5dc1999bd 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts @@ -4,4 +4,4 @@ import { defineInitialSlice, registerInitialSlice } from './definition'; export const DIDOMI_INITIAL_SLICE = defineInitialSlice('didomi_initial', installDidomiInitial); -registerInitialSlice(DIDOMI_INITIAL_SLICE, 5); +registerInitialSlice(DIDOMI_INITIAL_SLICE, 6); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts b/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts index ad3239453..c314ad4cb 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts @@ -7,4 +7,4 @@ export const GOOGLE_TAG_MANAGER_INITIAL_SLICE = defineInitialSlice( installGoogleTagManagerInitial ); -registerInitialSlice(GOOGLE_TAG_MANAGER_INITIAL_SLICE, 6); +registerInitialSlice(GOOGLE_TAG_MANAGER_INITIAL_SLICE, 7); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts index 15c319846..748186fe2 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts @@ -12,4 +12,4 @@ export const GPT_INITIAL_SLICE = defineInitialSlice('gpt_initial', (candidate, o ) ); -registerInitialSlice(GPT_INITIAL_SLICE, 7); +registerInitialSlice(GPT_INITIAL_SLICE, 8); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts b/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts index 7c0443e2d..e612a4399 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts @@ -4,4 +4,4 @@ import { defineInitialSlice, registerInitialSlice } from './definition'; export const LOCKR_INITIAL_SLICE = defineInitialSlice('lockr_initial', installLockrInitial); -registerInitialSlice(LOCKR_INITIAL_SLICE, 8); +registerInitialSlice(LOCKR_INITIAL_SLICE, 9); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/osano.ts b/crates/trusted-server-js/lib/src/first_display/slices/osano.ts index 5826690bb..ca2541646 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/osano.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/osano.ts @@ -4,4 +4,4 @@ import { defineInitialSlice, registerInitialSlice } from './definition'; export const OSANO_INITIAL_SLICE = defineInitialSlice('osano_initial', installOsanoInitial); -registerInitialSlice(OSANO_INITIAL_SLICE, 9); +registerInitialSlice(OSANO_INITIAL_SLICE, 10); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts b/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts index 2ef87996e..2bd29b352 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts @@ -7,4 +7,4 @@ export const PERMUTIVE_INITIAL_SLICE = defineInitialSlice( installPermutiveInitial ); -registerInitialSlice(PERMUTIVE_INITIAL_SLICE, 10); +registerInitialSlice(PERMUTIVE_INITIAL_SLICE, 11); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts b/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts index 6d5068e76..f66d0d0fe 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts @@ -4,4 +4,4 @@ import { defineInitialSlice, registerInitialSlice } from './definition'; export const PREBID_INITIAL_SLICE = defineInitialSlice('prebid_initial', installPrebidInitial); -registerInitialSlice(PREBID_INITIAL_SLICE, 12); +registerInitialSlice(PREBID_INITIAL_SLICE, 13); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts b/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts new file mode 100644 index 000000000..54a956ca4 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts @@ -0,0 +1,10 @@ +import { installRenderOwnerInitial } from '../render_journal'; + +import { defineInitialSlice, registerInitialSlice } from './definition'; + +export const RENDER_OWNER_INITIAL_SLICE = defineInitialSlice( + 'render_owner_initial', + installRenderOwnerInitial +); + +registerInitialSlice(RENDER_OWNER_INITIAL_SLICE, 2); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts b/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts index e5afbd1b8..7a4460df0 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts @@ -7,4 +7,4 @@ export const SOURCEPOINT_INITIAL_SLICE = defineInitialSlice( installSourcepointInitial ); -registerInitialSlice(SOURCEPOINT_INITIAL_SLICE, 11); +registerInitialSlice(SOURCEPOINT_INITIAL_SLICE, 12); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts b/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts index eaea6bc7e..738689aba 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts @@ -7,4 +7,4 @@ export const TESTLIGHT_INITIAL_SLICE = defineInitialSlice( installTestlightInitial ); -registerInitialSlice(TESTLIGHT_INITIAL_SLICE, 13); +registerInitialSlice(TESTLIGHT_INITIAL_SLICE, 14); diff --git a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts index 75c31417e..fe58751d7 100644 --- a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts @@ -318,7 +318,8 @@ export function adoptInitialRenderArtifactsFromHandoff( if ( typeof slotId !== 'string' || (kind !== 'aps' && kind !== 'gpt_adm') || - owner !== 'trusted_server' || + (owner !== 'trusted_server' && owner !== 'publisher') || + (owner === 'publisher' && kind !== 'gpt_adm') || typeof token !== 'string' || !/^r1_[A-Za-z0-9_-]{22}$/.test(token) || (hostPosition === null && hostPositionPriority !== null) || @@ -336,6 +337,7 @@ export function adoptInitialRenderArtifactsFromHandoff( const previousHostPosition = hostPosition as string | null; const previousHostPositionPriority = hostPositionPriority as '' | 'important' | null; const frame = identity; + const expectedParent = frame.parentNode; const expectedContentWindow = frame.contentWindow; const expectedSourceAttribute = frame.getAttribute('src'); const expectedSource = frame.src; @@ -346,7 +348,8 @@ export function adoptInitialRenderArtifactsFromHandoff( if ( !host.isConnected || host.ownerDocument !== document || - frame.parentNode !== host || + !expectedParent || + (owner === 'trusted_server' && expectedParent !== host) || !frame.isConnected || frame.ownerDocument !== document || !expectedContentWindow || @@ -368,7 +371,7 @@ export function adoptInitialRenderArtifactsFromHandoff( dispose: (): void => { if (disposed) return; disposed = true; - if (!armed) return; + if (!armed || owner === 'publisher') return; try { frame.remove(); } catch { @@ -398,7 +401,7 @@ export function adoptInitialRenderArtifactsFromHandoff( document.getElementById(domId as string) === host && host.isConnected && host.ownerDocument === document && - frame.parentNode === host && + frame.parentNode === expectedParent && frame.isConnected && frame.ownerDocument === document && frame.contentWindow === expectedContentWindow && diff --git a/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts b/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts index 5f7797054..0c7a4a039 100644 --- a/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts +++ b/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts @@ -12,54 +12,9 @@ function installPucDynamicOwner(): void { 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 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', - 'adm_document_no_load', - 'abi_mismatch', - 'bundle_partial', - ]); - const cancellationReasons = new Set(['caller_aborted', 'superseded', 'navigation_disposed']); + const renderFailureReason = + /^(?: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|adm_document_no_load|abi_mismatch|bundle_partial)$/; + const cancellationReason = /^(?: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') @@ -195,109 +150,6 @@ function installPucDynamicOwner(): void { } } }; - 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 { - key = JSON.parse(source.slice(index, keyEnd)) as unknown; - } catch { - 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 { - return undefined; - } - }; - const parseRegistration = (value: unknown): Record | undefined => { - try { - if (typeof value !== 'string' || new TextEncoder().encode(value).byteLength > 4096) { - return undefined; - } - return exactRecord(parseJsonWithoutDuplicateKeys(value), [ - '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 => { @@ -586,7 +438,7 @@ function installPucDynamicOwner(): void { if ( message['outcome'] === 'failed' && typeof message['reason'] === 'string' && - renderFailureReasons.has(message['reason']) + renderFailureReason.test(message['reason']) ) { finish(false, message['reason']); return; @@ -594,7 +446,7 @@ function installPucDynamicOwner(): void { if ( message['outcome'] === 'cancelled' && typeof message['reason'] === 'string' && - cancellationReasons.has(message['reason']) + cancellationReason.test(message['reason']) ) { finish(false, String(message['reason'])); return; @@ -613,14 +465,20 @@ function installPucDynamicOwner(): void { clearTimer(registrationTimer); const ports = eventPorts(event, 1); const dataValue = eventDataValue(event); - const response = parseRegistration(dataValue); + const response = + typeof dataValue === 'string' && + dataValue === + JSON.stringify({ + message: 'TS Render Owner Registered', + adId, + version: 1, + lifecycleTicket, + }); if ( !ports || !response || - response['message'] !== 'TS Render Owner Registered' || - response['adId'] !== adId || - response['version'] !== 1 || - response['lifecycleTicket'] !== lifecycleTicket + typeof adId !== 'string' || + typeof lifecycleTicket !== 'string' ) { closeEventPorts(event); finish(false, 'TS render owner registration refused'); diff --git a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts index de77b224b..a8241084b 100644 --- a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts +++ b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts @@ -448,7 +448,7 @@ export function validateRuntimeManifestV1( firstDisplay = null; } else { const fields = readExactDataFields(manifestFields.firstDisplay, ['src', 'slices']); - const slices = snapshotExactArray(fields?.slices, 13); + const slices = snapshotExactArray(fields?.slices, 14); if ( !fields || typeof fields.src !== 'string' || diff --git a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts index 8be1b1054..89deec0e5 100644 --- a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts +++ b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts @@ -61,6 +61,7 @@ export interface ReleaseCatalogSelection { export type FirstDisplaySliceId = | 'first_display' + | 'render_owner_initial' | 'aps_initial' | 'creative_initial' | 'datadome_initial' @@ -76,6 +77,7 @@ export type FirstDisplaySliceId = export type FirstDisplayIncludePredicate = | 'eligible_batch' + | 'render_owner_participates' | 'aps_participates' | 'creative_guard' | 'gpt_initial' @@ -104,6 +106,7 @@ export interface FirstDisplayCatalogSelection { readonly eligibleBatch: boolean; readonly integrations: readonly string[]; readonly apsParticipates?: boolean; + readonly renderOwnerParticipates?: boolean; readonly prebidParticipates?: boolean; readonly creative?: Readonly<{ enabled: boolean; @@ -132,7 +135,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object 'shared/first_display_registration', 'shared/first_display_transaction', 'shared/integration_config_validators', - 'first_display/adm_render_bridge', 'first_display/driver', 'first_display/leaf/projection', 'first_display/registration_client', @@ -145,6 +147,22 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object }), firstDisplayRow({ order: 2, + id: 'render_owner_initial', + include: 'render_owner_participates', + allowedImports: [ + 'shared/first_display_contracts', + 'first_display/registration_client', + 'first_display/slices/definition', + 'first_display/render_journal', + 'kernel/contracts/puc_dynamic_owner', + ], + inputs: ['first_display.control.v1'], + outputs: ['render_owner.initial.v1'], + obligation: + 'Own source-neutral reservation, ticket, v4 PUC, render journal, handoff, and retirement state', + }), + firstDisplayRow({ + order: 3, id: 'aps_initial', include: 'aps_participates', allowedImports: [ @@ -153,14 +171,13 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object 'first_display/slices/definition', 'first_display/leaf/aps_protocol', 'first_display/render_bridge', - 'kernel/contracts/puc_dynamic_owner', ], - inputs: ['first_display.control.v1'], + inputs: ['first_display.control.v1', 'render_owner.initial.v1'], outputs: ['aps.initial.v1'], - obligation: 'Own initial reservation, PUC, and APS document protocols', + obligation: 'Own APS URL, nonce, renderer-document, and top-mount authority', }), firstDisplayRow({ - order: 3, + order: 4, id: 'creative_initial', include: 'creative_guard', allowedImports: [ @@ -174,7 +191,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install current parser-time creative guards and record initial observations only', }), firstDisplayRow({ - order: 4, + order: 5, id: 'datadome_initial', include: 'integration:datadome', allowedImports: [ @@ -188,7 +205,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install the initial DataDome script and preload route guard', }), firstDisplayRow({ - order: 5, + order: 6, id: 'didomi_initial', include: 'integration:didomi', allowedImports: [ @@ -202,7 +219,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install the configured Didomi SDK path before SDK evaluation', }), firstDisplayRow({ - order: 6, + order: 7, id: 'google_tag_manager_initial', include: 'integration:google_tag_manager', allowedImports: [ @@ -216,7 +233,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install initial GTM script, preload, beacon, and fetch guards', }), firstDisplayRow({ - order: 7, + order: 8, id: 'gpt_initial', include: 'gpt_initial', allowedImports: [ @@ -231,7 +248,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Sole provisional GPT adapter, listeners, targeting, request, and handoff capture', }), firstDisplayRow({ - order: 8, + order: 9, id: 'lockr_initial', include: 'integration:lockr', allowedImports: [ @@ -245,7 +262,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install the initial Lockr script guard and bounded readiness observation', }), firstDisplayRow({ - order: 9, + order: 10, id: 'osano_initial', include: 'integration:osano', allowedImports: [ @@ -259,7 +276,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Capture the initial consent mirrors required by the protected batch', }), firstDisplayRow({ - order: 10, + order: 11, id: 'permutive_initial', include: 'integration:permutive', allowedImports: [ @@ -273,7 +290,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install initial guard/readiness and capture normalized segments', }), firstDisplayRow({ - order: 11, + order: 12, id: 'sourcepoint_initial', include: 'integration:sourcepoint', allowedImports: [ @@ -287,7 +304,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install the initial SDK guard and capture the GPP mirror', }), firstDisplayRow({ - order: 12, + order: 13, id: 'prebid_initial', include: 'prebid_participates', allowedImports: [ @@ -302,7 +319,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object 'Own initial artifact admission, queue, bidder, identity, EID, TS bid, and PUC setup', }), firstDisplayRow({ - order: 13, + order: 14, id: 'testlight_initial', include: 'integration:testlight', allowedImports: [ @@ -347,6 +364,12 @@ export function selectFirstDisplayCatalog( const include = (predicate: FirstDisplayIncludePredicate): boolean => { if (predicate === 'eligible_batch') return true; + if (predicate === 'render_owner_participates') { + return ( + selection.renderOwnerParticipates === true || + (selection.apsParticipates === true && integrations.has('aps')) + ); + } if (predicate === 'aps_participates') { return selection.apsParticipates === true && integrations.has('aps'); } diff --git a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts index c4c1260f4..6e0060678 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts @@ -3,6 +3,7 @@ import type { FirstDisplayGptDiagnosticsV1, FirstDisplayGptFactV1 } from '../sha export const FIRST_DISPLAY_CONTRACT_IDS: readonly FirstDisplaySliceId[] = Object.freeze([ 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -199,6 +200,13 @@ function snapshotSlices(value: unknown): readonly FirstDisplaySliceId[] | undefi if (!order || order <= previous) return undefined; previous = order; } + const renderOwner = slices.includes('render_owner_initial'); + if ( + (slices.includes('aps_initial') && !renderOwner) || + (renderOwner && !slices.includes('gpt_initial')) + ) { + return undefined; + } return slices as readonly FirstDisplaySliceId[]; } @@ -467,6 +475,7 @@ function validParserValues( const integer = (value: string | number | boolean | null): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 0; switch (sliceId) { + case 'render_owner_initial': case 'aps_initial': case 'prebid_initial': return single('protocol_version', (value) => value === 1); @@ -519,7 +528,9 @@ function snapshotParserState(value: unknown): Readonly> const entries = exactArray(fields?.values, 256); if ( !fields || - !snapshotSlices(['first_display', fields.sliceId]) || + typeof fields.sliceId !== 'string' || + fields.sliceId === 'first_display' || + !FIRST_DISPLAY_ORDER.has(fields.sliceId as FirstDisplaySliceId) || !observations || !entries || observations.length !== entries.length diff --git a/crates/trusted-server-js/lib/src/shared/first_display_registration.ts b/crates/trusted-server-js/lib/src/shared/first_display_registration.ts index a1997c2f6..04b4d44c2 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_registration.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_registration.ts @@ -6,6 +6,7 @@ const COMPONENT_ID = /^[a-z][a-z0-9_]{0,63}$/; const FIRST_DISPLAY_SRC = /^\/static\/tsjs=tsjs-first-display\.min\.js\?m=[0-9a-f]{4}&v=[0-9a-f]{64}$/; const PARSER_SLICE_ORDER = Object.freeze([ + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -187,7 +188,7 @@ export function snapshotFirstDisplayComponentRegistration( !HASH.test(registration.releaseId) || !Number.isInteger(registration.order) || registration.order < 1 || - registration.order > 13 || + registration.order > 14 || typeof registration.prepare !== 'function' ) { return undefined; diff --git a/crates/trusted-server-js/lib/src/shared/first_display_transaction.ts b/crates/trusted-server-js/lib/src/shared/first_display_transaction.ts index eea9d59a2..fac8e0a28 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_transaction.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_transaction.ts @@ -8,6 +8,7 @@ const FIRST_DISPLAY_SRC = const FIRST_DISPLAY_ORDER = new Map( [ 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -167,6 +168,7 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { ) { return this.reject(); } + if (this.stateValue !== 'collecting') return false; const prepared: PreparedFirstDisplaySliceV1[] = []; this.stateValue = 'preparing'; try { @@ -178,6 +180,7 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { sliceId: registration.id, }) ); + if (this.stateValue !== 'preparing') throw new TypeError('stale slice preparation'); const fields = exactRecord(candidate, ['activate']); if (!fields || typeof fields.activate !== 'function') throw new TypeError('invalid prepared slice'); @@ -185,42 +188,56 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { Object.freeze({ activate: fields.activate as PreparedFirstDisplaySliceV1['activate'] }) ); } - if (!this.authenticated()) throw new TypeError('stale first-display owner'); + if (!this.authenticated() || this.stateValue !== 'preparing') { + throw new TypeError('stale first-display owner'); + } this.stateValue = 'activating'; let ownershipOpen = true; let afterActivation: (() => void) | undefined; - for (let index = 0; index < prepared.length; index += 1) { - const slice = prepared[index]; - if (!slice) throw new TypeError('missing prepared first-display slice'); - slice.activate( - Object.freeze({ - own: (dispose: () => void): void => { - if (!ownershipOpen || typeof dispose !== 'function') { - throw new TypeError('first-display disposer registration is closed'); - } - this.disposers.push(dispose); - }, - afterActivate: (callback: () => void): void => { - if ( - !ownershipOpen || - index !== 0 || - afterActivation !== undefined || - typeof callback !== 'function' - ) { - throw new TypeError('invalid first-display post-activation callback'); - } - afterActivation = callback; - }, - }) - ); + try { + for (let index = 0; index < prepared.length; index += 1) { + const slice = prepared[index]; + if (!slice) throw new TypeError('missing prepared first-display slice'); + slice.activate( + Object.freeze({ + own: (dispose: () => void): void => { + if (!ownershipOpen || typeof dispose !== 'function') { + throw new TypeError('first-display disposer registration is closed'); + } + this.disposers.push(dispose); + }, + afterActivate: (callback: () => void): void => { + if ( + !ownershipOpen || + index !== 0 || + afterActivation !== undefined || + typeof callback !== 'function' + ) { + throw new TypeError('invalid first-display post-activation callback'); + } + afterActivation = callback; + }, + }) + ); + if (this.stateValue !== 'activating') { + throw new TypeError('stale first-display activation'); + } + } + } finally { + ownershipOpen = false; } - ownershipOpen = false; afterActivation?.(); - if (!this.authenticated()) throw new TypeError('stale first-display activation'); + if ( + !this.stateCurrent('activating') || + !this.authenticated() || + !this.stateCurrent('activating') + ) { + throw new TypeError('stale first-display activation'); + } this.stateValue = 'active'; return true; } catch { - this.stateValue = 'failed'; + if (!this.stateCurrent('disposed')) this.stateValue = 'failed'; this.unwind(); return false; } @@ -233,6 +250,10 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { this.registrations.length = 0; } + private stateCurrent(expected: FirstDisplayTransactionState): boolean { + return this.stateValue === expected; + } + private authenticated(): boolean { try { const { document, script, releaseId, generation, isCurrentGeneration } = this.options; @@ -262,9 +283,10 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { } private unwind(): void { - for (let index = this.disposers.length - 1; index >= 0; index -= 1) { + while (this.disposers.length > 0) { + const dispose = this.disposers.pop(); try { - this.disposers[index]?.(); + dispose?.(); } catch (error) { try { this.options.onDisposalError?.(error); @@ -273,7 +295,6 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { } } } - this.disposers.length = 0; } } diff --git a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs index 662db2f65..2205476ef 100644 --- a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs +++ b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs @@ -321,6 +321,56 @@ test('generated takeover completion leaves terminal fallback ownership with boot dom.window.close(); }); +test('generated bootstrap stops slice activation after a reentrant terminal callback', () => { + const { dom, selected } = createDocument(); + const target = { que: [] }; + const events = []; + const src = `/static/tsjs=tsjs-first-display.min.js?m=0081&v=${'c'.repeat(64)}`; + selected.src = src; + const bootValue = boot(); + bootValue.manifest.firstDisplay = { + src, + slices: ['first_display', 'gpt_initial'], + }; + const outlineValue = outline(); + outlineValue.slices = ['first_display', 'gpt_initial']; + dom.window.tsjs = target; + evaluateTransport(dom, transport(bootValue, outlineValue)); + + const base = { + abi: 1, + id: 'first_display', + releaseId: manifest.releaseId, + prepare: (host) => + Object.freeze({ + sliceHost: Object.freeze({}), + activate: ({ own }) => { + own(() => events.push('dispose-base')); + events.push('activate-base'); + host.options.onFailure('bundle_partial'); + }, + }), + }; + const gpt = { + abi: 1, + id: 'gpt_initial', + releaseId: manifest.releaseId, + prepare: () => + Object.freeze({ + activate: () => events.push('activate-gpt'), + }), + }; + + assert.equal(target._registerFirstDisplay.call(target, base, selected), true); + assert.equal(target._registerFirstDisplay.call(target, gpt, selected), false); + assert.deepEqual(events, ['activate-base', 'dispose-base']); + assert.equal( + Object.getOwnPropertyDescriptor(target, '_internal')?.value.reason, + 'bundle_partial' + ); + dom.window.close(); +}); + test('generated bootstrap commits one non-rendering terminal shell after registration failure', async () => { const { dom, selected } = createDocument(); const drained = []; 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 d0d029694..520ed7256 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 @@ -39,6 +39,7 @@ const bundle = (id, logical, role = 'integration', phase = 'takeover', trigger = const EXPECTED_RELEASE_BUNDLE_ORDER = [ 'bootstrap', 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -206,7 +207,7 @@ test('generated release inventory pins the server bundle order', () => { ); assert.equal(manifest.artifacts.filter(({ role }) => role === 'bootstrap').length, 1); assert.equal(manifest.artifacts.filter(({ role }) => role === 'first_display_base').length, 1); - assert.equal(manifest.artifacts.filter(({ role }) => role === 'first_display_slice').length, 12); + assert.equal(manifest.artifacts.filter(({ role }) => role === 'first_display_slice').length, 13); assert.equal(manifest.artifacts.filter(({ role }) => role === 'core').length, 1); assert.equal(manifest.artifacts.filter(({ role }) => role === 'integration').length, 20); for (const artifact of manifest.artifacts) { @@ -243,7 +244,7 @@ test('generated first-display components self-register through one authenticated ); const firstDisplay = release.artifacts.filter(({ phase }) => phase === 'first_display'); const dom = new JSDOM( - ``, + ``, { runScripts: 'outside-only', url: 'https://publisher.example/article', @@ -418,6 +419,18 @@ test('generated first-display components self-register through one authenticated onFailure: function() {} }), sliceBindings: function(id) { + if (id === 'render_owner_initial') { + return Object.freeze({ + bindings: Object.freeze({ + observe: function() {}, + register: function(protocol) { + window.__firstDisplayEvents.push('render_owner:' + protocol.id); + return function() {}; + } + }), + config: Object.freeze({}) + }); + } if (id === 'aps_initial') { return Object.freeze({ bindings: Object.freeze({ @@ -457,10 +470,14 @@ test('generated first-display components self-register through one authenticated }); `); const activatedBase = registrations[0].prepare(dom.window.__firstDisplayBaseHost); + const activatedRenderOwner = registrations + .find(({ id }) => id === 'render_owner_initial') + .prepare(activatedBase.sliceHost); const activatedGpt = registrations .find(({ id }) => id === 'gpt_initial') .prepare(activatedBase.sliceHost); activatedBase.activate(dom.window.__firstDisplayActivation); + activatedRenderOwner.activate(dom.window.__firstDisplaySliceActivation); activatedGpt.activate(dom.window.__firstDisplaySliceActivation); dom.window.__firstDisplayAfterActivate(); const directFrame = dom.window.document.querySelector('#slot-1 iframe'); @@ -468,7 +485,13 @@ test('generated first-display components self-register through one authenticated directFrame.dispatchEvent(new dom.window.Event('load')); assert.deepEqual( [...dom.window.__firstDisplayEvents], - ['gpt:gpt', 'bootstrap:register', 'bootstrap:action', 'gpt:display'] + [ + 'render_owner:render_owner', + 'gpt:gpt', + 'bootstrap:register', + 'bootstrap:action', + 'gpt:display', + ] ); } finally { dom.window.close(); @@ -967,16 +990,23 @@ test('bundle metrics enumerate and hash every reachable first-display mask', () const masks = metrics.firstDisplay.masks; assert.ok(Array.isArray(masks)); - assert.equal(masks.length, 2_560); + assert.equal(masks.length, 3_584); assert.equal(new Set(masks.map(({ mask }) => mask)).size, masks.length); assert.equal(new Set(masks.map(({ sha256 }) => sha256)).size, masks.length); for (const measurement of masks) { assert.match(measurement.mask, /^[0-9a-f]{4}$/u); assert.equal(measurement.ids[0], 'first_display'); if (!measurement.ids.includes('gpt_initial')) { + assert.equal(measurement.ids.includes('render_owner_initial'), false); assert.equal(measurement.ids.includes('aps_initial'), false); assert.equal(measurement.ids.includes('prebid_initial'), false); } + if (measurement.ids.includes('aps_initial')) { + assert.equal(measurement.ids.includes('render_owner_initial'), true); + } + if (measurement.ids.includes('render_owner_initial')) { + assert.equal(measurement.ids.includes('gpt_initial'), true); + } assert.deepEqual( measurement.files, measurement.ids.map((id) => `tsjs-${id}.js`) @@ -1005,7 +1035,7 @@ test('candidate architecture obeys every independent absolute transfer ceiling', }); assert.doesNotThrow(() => bundleBudgets.enforceCandidateArchitectureSizeCeilings(report)); - assert.equal(report.firstDisplay.masks.length, 2_560); + assert.equal(report.firstDisplay.masks.length, 3_584); assert.ok(report.firstDisplay.permittedMasks.length > 0); assert.ok(report.firstDisplay.permittedMasks.length < report.firstDisplay.masks.length); for (const name of ['minimal', 'reference', 'aps']) { @@ -1020,6 +1050,7 @@ test('candidate architecture obeys every independent absolute transfer ceiling', ]); assert.deepEqual(report.firstDisplay.named.aps.ids, [ 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'gpt_initial', @@ -1038,6 +1069,15 @@ test('candidate architecture obeys every independent absolute transfer ceiling', 'referencePersistent', 'maximalTotal', ]); + const maximalNonBootstrap = bundleMetrics.measureBundleSet( + release.artifacts.filter(({ role }) => role !== 'bootstrap').map(({ file }) => file), + currentArtifactContents + ); + assert.deepEqual(report.maximalTotal, { + rawBytes: maximalNonBootstrap.rawBytes, + gzipBytes: maximalNonBootstrap.gzipBytes, + brotliBytes: maximalNonBootstrap.brotliBytes, + }); }); test('absolute transfer ceilings reject independent one-byte regressions', () => { @@ -1833,6 +1873,54 @@ test('production bundle graphs reject every current forbidden edge', () => { ); }); +test('first-display render ownership is physically split at the source-neutral boundary', () => { + const { metrics, release } = readBuildEvidence(); + const sources = (id) => + new Set( + metrics.modules.find(({ file }) => file === `tsjs-${id}.js`).sources.map(({ file }) => file) + ); + const ownerSources = sources('render_owner_initial'); + const apsSources = sources('aps_initial'); + const baseSources = sources('first_display'); + + assert.equal(ownerSources.has('src/first_display/render_journal.ts'), true); + assert.equal(ownerSources.has('src/kernel/contracts/puc_dynamic_owner.ts'), true); + assert.equal(ownerSources.has('src/first_display/render_bridge.ts'), false); + assert.equal(ownerSources.has('src/first_display/leaf/aps_protocol.ts'), false); + assert.equal(apsSources.has('src/first_display/render_bridge.ts'), true); + assert.equal(apsSources.has('src/first_display/leaf/aps_protocol.ts'), true); + assert.equal(apsSources.has('src/first_display/render_journal.ts'), false); + assert.equal(apsSources.has('src/kernel/contracts/puc_dynamic_owner.ts'), false); + assert.equal(baseSources.has('src/first_display/render_journal.ts'), false); + assert.equal(baseSources.has('src/first_display/adm_render_bridge.ts'), false); + + const ownerBody = fs.readFileSync( + path.resolve(libDirectory, '../dist/tsjs-render_owner_initial.js'), + 'utf8' + ); + const apsLiterals = [...ownerBody.matchAll(/TS APS [A-Za-z ]+/gu)].map(([value]) => value); + assert.ok(apsLiterals.length > 0); + assert.deepEqual(new Set(apsLiterals), new Set(['TS APS Top Mount Started'])); + + const movedJournal = structuredClone(metrics); + movedJournal.modules + .find(({ file }) => file === 'tsjs-aps_initial.js') + .sources.push({ file: 'src/first_display/render_journal.ts', renderedBytes: 1 }); + assert.match( + bundleBudgets.findProductionGraphViolations(movedJournal, release).join('\n'), + /render_owner_initial-owned source src\/first_display\/render_journal\.ts/ + ); + + const movedAps = structuredClone(metrics); + movedAps.modules + .find(({ file }) => file === 'tsjs-render_owner_initial.js') + .sources.push({ file: 'src/first_display/render_bridge.ts', renderedBytes: 1 }); + assert.match( + bundleBudgets.findProductionGraphViolations(movedAps, release).join('\n'), + /aps_initial-owned source src\/first_display\/render_bridge\.ts/ + ); +}); + test('production bundle graphs scan bootstrap sources for test and fake seams', () => { const { metrics, release } = readBuildEvidence(); metrics.bootstrap.sources.push({ file: 'src/test/fake_adapter.ts', renderedBytes: 1 }); @@ -1976,7 +2064,7 @@ test('bundle check authenticates frozen captures and reports both without enforc } const commandReport = bundleBudgets.summarizeBundleBudgetCommandReport(result); - assert.equal(commandReport.candidateArchitecture.firstDisplay.reachableMaskCount, 2_560); + assert.equal(commandReport.candidateArchitecture.firstDisplay.reachableMaskCount, 3_584); assert.equal( commandReport.candidateArchitecture.firstDisplay.permittedMaskCount, result.candidateArchitecture.firstDisplay.permittedMasks.length diff --git a/crates/trusted-server-js/lib/test/core/bootstrap.test.ts b/crates/trusted-server-js/lib/test/core/bootstrap.test.ts index be3e643ca..4738e3fa4 100644 --- a/crates/trusted-server-js/lib/test/core/bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/core/bootstrap.test.ts @@ -113,6 +113,21 @@ function transportInput(firstDisplay = false): Record { }; } +function selectFirstDisplay( + input: Record, + mask: string, + slices: readonly string[] +): void { + const boot = input.boot as Record; + const manifest = boot.manifest as Record; + manifest.firstDisplay = { + src: `/static/tsjs=tsjs-first-display.min.js?m=${mask}&v=${'c'.repeat(64)}`, + slices: [...slices], + }; + const outline = input.outline as Record; + outline.slices = [...slices]; +} + describe('sealed production boot transport', () => { it.each([false, true])('parses and recursively freezes a fresh %s transport', (firstDisplay) => { const original = transportInput(firstDisplay); @@ -188,6 +203,26 @@ describe('sealed production boot transport', () => { deferred.src = `/static/tsjs=tsjs-evil.min.js?v=${'e'.repeat(64)}`; expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); }); + + it.each([ + ['APS without the render owner', '0085', ['first_display', 'aps_initial', 'gpt_initial']], + ['the render owner without GPT', '0003', ['first_display', 'render_owner_initial']], + ])('rejects %s before exposing the compact boot', (_name, mask, slices) => { + const value = transportInput(true); + selectFirstDisplay(value, mask, slices); + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); + }); + + it.each([ + ['0081', ['first_display', 'gpt_initial']], + ['0083', ['first_display', 'render_owner_initial', 'gpt_initial']], + ])('admits the closed non-APS slice relationship %s', (mask, slices) => { + const value = transportInput(true); + selectFirstDisplay(value, mask, slices); + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeDefined(); + }); }); describe('bootstrap integration configuration admission', () => { @@ -257,6 +292,16 @@ describe('bootstrap integration configuration admission', () => { expect(snapshotBootstrapInputV1(input, RELEASE)).toBeUndefined(); }); + it.each([ + ['APS without the render owner', '0085', ['first_display', 'aps_initial', 'gpt_initial']], + ['the render owner without GPT', '0003', ['first_display', 'render_owner_initial']], + ])('rejects %s before retaining the full boot', (_name, mask, slices) => { + const input = firstDisplayInput(); + selectFirstDisplay(input, mask, slices); + + expect(snapshotBootstrapInputV1(input, RELEASE)).toBeUndefined(); + }); + it('preserves poison-named config data properties and rejects them outside config values', () => { const config = Object.defineProperty({}, '__proto__', { configurable: true, diff --git a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts index 7f16bd08c..30c4b7d84 100644 --- a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; import type { FirstDisplayGptBoundCycleV1 } from '../../src/first_display/adapters/googletag'; -import { createFirstDisplayAdmRenderBridge } from '../../src/first_display/adm_render_bridge'; + +import { createTestFirstDisplayRenderBridge } from './helpers/render_owner_composition'; const RESERVATION_ID = `r1_${'a'.repeat(22)}`; @@ -48,9 +49,15 @@ function harness() { let now = 0; const terminal = vi.fn(); const mutation = vi.fn(() => true); - const bridge = createFirstDisplayAdmRenderBridge({ + const bridge = createTestFirstDisplayRenderBridge({ + browser: dom.window as unknown as Window, clearTimer: (handle) => timers.delete(handle as object), + createChannel: () => { + throw new Error('ADM fallback must not create an owner channel'); + }, document: dom.window.document, + fillRandom: () => undefined, + getAps: () => undefined, now: () => now, onNativeMutation: mutation, setTimer: (callback) => { @@ -74,33 +81,7 @@ function harness() { }; } -describe('ADM-only first-display render bridge', () => { - it('accepts a nonempty GAM result without installing PUC/message authority', () => { - const h = harness(); - - expect(h.bridge.recordGam(h.cycle, 'nonempty_gam')).toBe(true); - expect(h.terminal).toHaveBeenCalledWith('accepted', null); - expect(h.element.querySelector('iframe')).toBeNull(); - expect(h.timers).toHaveLength(0); - - expect(() => h.bridge.sealTsAdmission()).not.toThrow(); - expect(h.bridge.closeIngress()).toBe(true); - expect(h.bridge.captureHandoff()).toEqual({ - artifacts: [], - clockEpochMs: 0, - nextReservationOrdinal: 2, - nextTicketOrdinal: 1, - tombstones: [ - { - expiresAtMs: 900_000, - kind: 'reservation', - ordinal: 1, - value: RESERVATION_ID, - }, - ], - }); - }); - +describe('ADM-only shared first-display render journal', () => { it('owns only the attributable empty-GAM ADM frame and transfers it once', () => { const h = harness(); diff --git a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts index 818f3d92a..64ea576fb 100644 --- a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts @@ -191,6 +191,19 @@ describe('first-display immutable contracts', () => { expect( snapshotTakeoverOutlineV1(outline({ slices: ['gpt_initial', 'first_display'] })) ).toBeUndefined(); + expect( + snapshotTakeoverOutlineV1( + outline({ slices: ['first_display', 'aps_initial', 'gpt_initial'] }) + ) + ).toBeUndefined(); + expect( + snapshotTakeoverOutlineV1(outline({ slices: ['first_display', 'render_owner_initial'] })) + ).toBeUndefined(); + expect( + snapshotTakeoverOutlineV1( + outline({ slices: ['first_display', 'render_owner_initial', 'gpt_initial'] }) + )?.slices + ).toEqual(['first_display', 'render_owner_initial', 'gpt_initial']); expect( snapshotTakeoverOutlineV1(outline({ capabilities: ['gpt_slot', 'gpt_slot'] })) ).toBeUndefined(); @@ -345,6 +358,25 @@ describe('first-display immutable contracts', () => { ).toBeUndefined(); }); + it('validates the render-owner parser row inside a complete owner-to-GPT handoff', () => { + const gptParserState = (handoff().parserState as object[])[0]!; + const accepted = snapshotFirstDisplayHandoffV1( + handoff({ + slices: ['first_display', 'render_owner_initial', 'gpt_initial'], + parserState: [ + { + sliceId: 'render_owner_initial', + observations: ['protocol_version'], + values: [['protocol_version', 1]], + }, + gptParserState, + ], + }) + ); + + expect(accepted?.slices).toEqual(['first_display', 'render_owner_initial', 'gpt_initial']); + }); + it('enforces 255/256/257 slot and outcome boundaries and strict high-water counters', () => { const base = handoff(); const slot = (base.slots as object[])[0]!; diff --git a/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts b/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts new file mode 100644 index 000000000..e22141a78 --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts @@ -0,0 +1,17 @@ +import type { FirstDisplayApsProtocolV1 } from '../../../src/first_display/leaf/aps_protocol'; +import { createFirstDisplayApsRenderStrategy } from '../../../src/first_display/render_bridge'; +import { + createFirstDisplayRenderJournal, + type FirstDisplayRenderOwnerOptionsV1, +} from '../../../src/first_display/render_journal'; + +type TestRenderBridgeOptions = FirstDisplayRenderOwnerOptionsV1 & + Readonly<{ getAps: () => FirstDisplayApsProtocolV1 | undefined }>; + +/** Compose the production owner and APS capabilities without creating a production graph seam. */ +export function createTestFirstDisplayRenderBridge(options: TestRenderBridgeOptions) { + const { getAps, ...ownerOptions } = options; + const aps = getAps(); + const strategy = aps ? createFirstDisplayApsRenderStrategy(ownerOptions, aps) : undefined; + return createFirstDisplayRenderJournal(ownerOptions, strategy); +} diff --git a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts index 408ff3298..af9513d2b 100644 --- a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; import type { FirstDisplayGptBoundCycleV1 } from '../../src/first_display/adapters/googletag'; -import { createFirstDisplayRenderBridge } from '../../src/first_display/render_bridge'; import { PUC_DYNAMIC_OWNER } from '../../src/kernel/contracts/puc_dynamic_owner'; +import { createTestFirstDisplayRenderBridge } from './helpers/render_owner_composition'; + const RESERVATION_ID = `r1_${'a'.repeat(22)}`; class FakePort { @@ -95,22 +96,25 @@ function fixture(kind: 'adm' | 'aps' = 'adm') { function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) { const value = fixture(kind); - let listener: ((event: Record) => void) | undefined; + const listeners: Array<(event: Record) => void> = []; const target = { addEventListener: vi.fn( (name: string, next: (event: Record) => void, capture: boolean) => { expect(name).toBe('message'); expect(capture).toBe(true); - listener = next; + listeners.push(next); } ), - removeEventListener: vi.fn(), + removeEventListener: vi.fn((_name: string, next: (event: Record) => void) => { + const index = listeners.indexOf(next); + if (index >= 0) listeners.splice(index, 1); + }), }; const channels: Array<{ port1: FakePort; port2: FakePort }> = []; const timers = new Map void; delayMs: number }>>(); let randomByte = 1; let now = 0; - const bridge = createFirstDisplayRenderBridge({ + const bridge = createTestFirstDisplayRenderBridge({ getAps: () => kind === 'aps' ? Object.freeze({ @@ -123,15 +127,9 @@ function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) permanentSandbox: 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation', deadlines: Object.freeze({ - insertionMs: 1_000 as const, documentAcceptanceMs: 3_000 as const, completionMs: 10_000 as const, - ownerSettlementMs: 20_000 as const, }), - isReservationId: (candidate: unknown): candidate is string => - typeof candidate === 'string' && /^r1_[A-Za-z0-9_-]{22}$/.test(candidate), - isLifecycleTicket: (candidate: unknown): candidate is string => - typeof candidate === 'string' && /^t1_[A-Za-z0-9_-]{22}$/.test(candidate), isBootstrapNonce: (candidate: unknown): candidate is string => typeof candidate === 'string' && /^b1_[A-Za-z0-9_-]{22}$/.test(candidate), isRendererNonce: (candidate: unknown): candidate is string => @@ -141,8 +139,26 @@ function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) creativeOrigin: 'https://creative.example', tagType: 'iframe' as const, }), - createRenderBridge: () => { - throw new Error('the full bridge fixture already owns construction'); + createRenderStrategy: () => { + throw new Error('the legacy bridge fixture already owns construction'); + }, + parseWindowMessage: (candidate: unknown) => { + if (typeof candidate !== 'string') return undefined; + const message = JSON.parse(candidate) as Record; + if (message.message === 'TS APS Bootstrap Ready') { + return Object.freeze({ + kind: 'bootstrap_ready' as const, + bootstrap: message.bootstrapNonce as string, + }); + } + if (message.message === 'TS APS Container Ready') { + return Object.freeze({ + kind: 'container_ready' as const, + bootstrap: message.bootstrapNonce as string, + renderer: message.rendererNonce as string, + }); + } + return undefined; }, parseDocumentMessage: (candidate: unknown, nonce: string) => { const message = candidate as Record; @@ -200,8 +216,8 @@ function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) }) ).toBe(true); const dispatch = (event: Record): void => { - if (!listener) throw new Error('expected capture listener'); - listener(event); + if (listeners.length === 0) throw new Error('expected capture listener'); + for (const listener of [...listeners]) listener(event); }; const fire = (delayMs: number): void => { const entry = [...timers.entries()].find(([, timer]) => timer.delayMs === delayMs); @@ -278,8 +294,8 @@ function collapsedShell(h: ReturnType) { return { frame, wrapper }; } -function registerOwner(h: ReturnType) { - const source = {}; +function registerOwner(h: ReturnType, ownerSource?: object) { + const source = ownerSource ?? {}; const responsePort = new FakePort(); h.dispatch(requestEvent(responsePort, { source })); expect(h.bridge.recordGam(h.cycle, 'nonempty_gam')).toBe(true); @@ -307,6 +323,7 @@ function startApsDocument(h: ReturnType) { ports: [], source: frame.contentWindow, }); + expect(h.terminalFacts).toEqual([]); const navigation = JSON.parse(postMessage.mock.calls[0]?.[0] as string) as Record< string, unknown @@ -345,12 +362,89 @@ function acceptPucAps(h: ReturnType): HTMLIFrameElement { return frame; } +function bindMixedAdm(h: ReturnType) { + const element = h.dom.window.document.createElement('div'); + element.id = 'slot-2'; + h.dom.window.document.body.appendChild(element); + const reservationId = `r1_${'b'.repeat(22)}`; + const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze({ + bid: Object.freeze({ + candidateId: 'candidate002', + slot: 'slot-2', + provider: 'example', + upstreamBidId: 'upstream-2', + cpm: 1.5, + currency: 'USD' as const, + targeting: Object.freeze({}), + rendererReservationId: reservationId, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
mixed ADM creative
', + width: 300, + height: 250, + }), + }), + element, + ownership: 'trusted_server', + physicalSlot: {}, + isCurrent: () => true, + placement: Object.freeze({ + slot: 'slot-2', + gamUnitPath: '/123/example-2', + divId: 'slot-2', + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }), + slotId: 'slot-2', + traceToken: 'gt1_2', + }); + const terminal = vi.fn(); + expect(h.bridge.bind(cycle, terminal)).toBe(true); + return { cycle, element, reservationId, terminal }; +} + describe('bounded first-display render bridge', () => { + it('isolates interleaved APS and ADM ownership in one shared journal', () => { + const h = harness('aps'); + const adm = bindMixedAdm(h); + registerOwner(h); + + expect(h.bridge.recordGam(adm.cycle, 'gam_empty')).toBe(true); + const admFrame = adm.element.querySelector('iframe'); + expect(admFrame?.srcdoc).toContain('mixed ADM creative'); + admFrame?.dispatchEvent(new h.dom.window.Event('load')); + expect(adm.terminal).toHaveBeenCalledWith('accepted', null); + expect(h.terminals).toEqual([]); + + const { documentPort, frame: apsFrame, nonce } = startApsDocument(h); + documentPort.dispatch({ message: 'TS APS Document Accepted', version: 1, nonce }); + documentPort.dispatch({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(h.terminals).toEqual(['accepted']); + + h.bridge.sealTsAdmission(); + expect(h.bridge.closeIngress()).toBe(true); + const handoff = h.bridge.captureHandoff(); + expect(handoff?.artifacts.map(({ kind, slotId, token }) => ({ kind, slotId, token }))).toEqual([ + { kind: 'gpt_adm', slotId: 'slot-2', token: adm.reservationId }, + { kind: 'aps', slotId: 'slot-1', token: RESERVATION_ID }, + ]); + expect( + new Set( + handoff?.tombstones.filter(({ kind }) => kind === 'reservation').map(({ value }) => value) + ) + ).toEqual(new Set([adm.reservationId, RESERVATION_ID])); + expect(h.bridge.detachCommittedArtifacts()).toBe(true); + h.bridge.dispose(); + expect(admFrame?.isConnected).toBe(true); + expect(apsFrame.isConnected).toBe(true); + }); + it('observes admitted bridge activity and terminal tombstone expiry', () => { const mutations = vi.fn(() => true); const h = harness('adm', mutations); const shell = collapsedShell(h); - const owner = registerOwner(h); + const owner = registerOwner(h, shell.frame.contentWindow!); h.channels[0]?.port1.dispatch({ message: 'TS Owner Inserted', version: 1, diff --git a/crates/trusted-server-js/lib/test/first_display/slices.test.ts b/crates/trusted-server-js/lib/test/first_display/slices.test.ts index 9ee5571a1..30362b5bd 100644 --- a/crates/trusted-server-js/lib/test/first_display/slices.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/slices.test.ts @@ -53,6 +53,10 @@ import { createDidomiRuntime } from '../../src/integrations/didomi/module'; import { shouldProxyExternalUrl } from '../../src/integrations/creative/proxy_sign'; import { getPermutiveSegments } from '../../src/integrations/permutive/segments'; import { mirrorSourcepointConsent } from '../../src/integrations/sourcepoint/consent_mirror'; +import { + installRenderOwnerInitial, + type FirstDisplayRenderOwnerProtocolV1, +} from '../../src/first_display/render_journal'; const RELEASE_ID = 'a'.repeat(64); @@ -187,6 +191,61 @@ describe('first-display initial slice definitions', () => { } }); + it('installs one source-neutral render journal and gives its slice disposer final ownership', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const removeEventListener = vi.spyOn(dom.window, 'removeEventListener'); + const release = vi.fn(); + const owned: Array<() => void> = []; + let protocol: FirstDisplayRenderOwnerProtocolV1 | undefined; + + expect( + installRenderOwnerInitial( + Object.freeze({ + observe: vi.fn(), + register: (candidate: FirstDisplayRenderOwnerProtocolV1) => { + protocol = candidate; + return release; + }, + }), + (dispose) => owned.push(dispose) + ) + ).toEqual({ version: 1, id: 'render_owner' }); + const bridge = protocol?.createRenderBridge({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + createChannel: () => { + throw new Error('unused'); + }, + document: dom.window.document, + fillRandom: () => undefined, + now: () => 0, + setTimer: () => ({}), + }); + + expect(bridge).toBeDefined(); + expect(() => + protocol?.createRenderBridge({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + createChannel: () => { + throw new Error('unused'); + }, + document: dom.window.document, + fillRandom: () => undefined, + now: () => 0, + setTimer: () => ({}), + }) + ).toThrow(TypeError); + + expect(owned).toHaveLength(1); + owned[0]?.(); + expect(release).toHaveBeenCalledOnce(); + expect(removeEventListener).toHaveBeenCalledWith('message', expect.any(Function), true); + dom.window.close(); + }); + it.each([false, true])( 'projects the typed GAM attribution flag into the first-display GPT owner (%s)', (gamAttributionEnabled) => { @@ -233,8 +292,9 @@ describe('first-display initial slice definitions', () => { } ); - it('pins the exact twelve optional slices in build order', () => { + it('pins the exact thirteen optional slices in build order', () => { expect(INITIAL_SLICE_DEFINITIONS.map(({ id }) => id)).toEqual([ + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -283,6 +343,12 @@ describe('first-display initial slice definitions', () => { expect( selectInitialSliceDefinitions(['first_display', 'prebid_initial', 'gpt_initial']) ).toBeUndefined(); + expect( + selectInitialSliceDefinitions(['first_display', 'aps_initial', 'gpt_initial']) + ).toBeUndefined(); + expect( + selectInitialSliceDefinitions(['first_display', 'render_owner_initial']) + ).toBeUndefined(); expect( selectInitialSliceDefinitions(['first_display', 'unknown_initial' as 'gpt_initial']) ).toBeUndefined(); @@ -1119,7 +1185,7 @@ describe('first-display initial slice definitions', () => { } }); - it('registers the closed APS reservation and document-channel protocol', () => { + it('registers the closed APS nonce, policy, and document-channel protocol', () => { const release = vi.fn(); const disposers: Array<() => void> = []; let protocol: FirstDisplayApsProtocolV1 | undefined; @@ -1157,17 +1223,12 @@ describe('first-display initial slice definitions', () => { 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation' ); expect(protocol?.deadlines).toEqual({ - insertionMs: 1_000, documentAcceptanceMs: 3_000, completionMs: 10_000, - ownerSettlementMs: 20_000, }); - expect(protocol?.isReservationId(`r1_${'a'.repeat(22)}`)).toBe(true); - expect(protocol?.isLifecycleTicket(`t1_${'a'.repeat(22)}`)).toBe(true); expect(protocol?.isBootstrapNonce(`b1_${'a'.repeat(22)}`)).toBe(true); expect(protocol?.isRendererNonce(`n1_${'a'.repeat(22)}`)).toBe(true); expect(protocol?.isBootstrapNonce(`n1_${'a'.repeat(22)}`)).toBe(false); - expect(protocol?.isReservationId(`r1_${'a'.repeat(21)}`)).toBe(false); const nonce = `n1_${'b'.repeat(22)}`; expect( protocol?.parseDocumentMessage( diff --git a/crates/trusted-server-js/lib/test/first_display/transaction.test.ts b/crates/trusted-server-js/lib/test/first_display/transaction.test.ts index 3f50f14dc..f9b2cb62b 100644 --- a/crates/trusted-server-js/lib/test/first_display/transaction.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/transaction.test.ts @@ -58,7 +58,7 @@ describe('release-private first-display transaction', () => { ).toBe(true); expect( transaction.register( - registration('gpt_initial', 7, (own) => { + registration('gpt_initial', 8, (own) => { own(() => events.push('dispose-gpt')); events.push('activate-gpt'); }) @@ -91,7 +91,7 @@ describe('release-private first-display transaction', () => { }), }); transaction.register( - registration('gpt_initial', 7, () => { + registration('gpt_initial', 8, () => { events.push('activate-gpt'); }) ); @@ -120,7 +120,7 @@ describe('release-private first-display transaction', () => { const omitted = make(); expect(omitted.register(registration('first_display', 1))).toBe(true); expect(omitted.activate()).toBe(false); - expect(make().register(registration('gpt_initial', 7))).toBe(false); + expect(make().register(registration('gpt_initial', 8))).toBe(false); expect( make().register({ ...registration('first_display', 1), releaseId: 'b'.repeat(64) }) ).toBe(false); @@ -129,9 +129,9 @@ describe('release-private first-display transaction', () => { expect(make().register(accessor)).toBe(false); const late = make(); expect(late.register(registration('first_display', 1))).toBe(true); - expect(late.register(registration('gpt_initial', 7))).toBe(true); + expect(late.register(registration('gpt_initial', 8))).toBe(true); expect(late.activate()).toBe(true); - expect(late.register(registration('gpt_initial', 7))).toBe(false); + expect(late.register(registration('gpt_initial', 8))).toBe(false); }); it('authenticates the exact parser-inserted current script and current generation', () => { @@ -177,7 +177,7 @@ describe('release-private first-display transaction', () => { }) ); transaction.register( - registration('gpt_initial', 7, (own) => { + registration('gpt_initial', 8, (own) => { own(() => events.push('dispose-c')); throw new Error('boom'); }) @@ -187,4 +187,64 @@ describe('release-private first-display transaction', () => { expect(events).toEqual(['dispose-c', 'dispose-b', 'dispose-a']); expect(transaction.state).toBe('failed'); }); + + it('cannot continue activation or resurrect after a slice disposes reentrantly', () => { + const { document, script } = documentWithScript(); + const events: string[] = []; + const transaction = createFirstDisplayTransaction({ + document, + script, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display', 'gpt_initial'], + isCurrentGeneration: () => true, + }); + transaction.register( + registration('first_display', 1, (own) => { + own(() => events.push('dispose-base')); + events.push('activate-base'); + transaction.dispose(); + }) + ); + transaction.register( + registration('gpt_initial', 8, () => { + events.push('activate-gpt'); + }) + ); + + expect(transaction.activate()).toBe(false); + expect(transaction.state).toBe('disposed'); + expect(events).toEqual(['activate-base', 'dispose-base']); + }); + + it('removes each disposer before invoking it during recursive rollback', () => { + const { document, script } = documentWithScript(); + const events: string[] = []; + const transaction = createFirstDisplayTransaction({ + document, + script, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display', 'gpt_initial'], + isCurrentGeneration: () => true, + }); + transaction.register( + registration('first_display', 1, (own) => { + own(() => events.push('dispose-a')); + own(() => { + events.push('dispose-b'); + transaction.dispose(); + }); + }) + ); + transaction.register( + registration('gpt_initial', 8, () => { + throw new Error('boom'); + }) + ); + + expect(transaction.activate()).toBe(false); + expect(transaction.state).toBe('disposed'); + expect(events).toEqual(['dispose-b', 'dispose-a']); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts index 36c7404dc..8991f11a4 100644 --- a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts @@ -204,6 +204,65 @@ describe('render_runtime provider', () => { expect(batch.dispose).toHaveBeenCalledTimes(2); }); + it('adopts a publisher-owned PUC shell without claiming its DOM lifetime', () => { + const host = document.createElement('div'); + host.id = 'div-1'; + const publisherWrapper = document.createElement('div'); + const frame = document.createElement('iframe'); + frame.src = 'https://publisher.example/universal-creative'; + publisherWrapper.append(frame); + document.body.append(host, publisherWrapper); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: vi.fn(() => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }) + ), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: vi.fn(() => batch), + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + kind: 'gpt_adm' as const, + owner: 'publisher' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([Object.freeze({}), frame]), + }); + const store = createCommittedArtifactStore(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + createArtifactHostPositionLeaseRegistry(), + document + ); + + expect(committed).toBeDefined(); + committed?.arm(); + frame.style.setProperty('visibility', 'hidden'); + expect(store.sweep()).toBe(1); + expect(frame.isConnected).toBe(true); + expect(batch.dispose).toHaveBeenCalledOnce(); + }); + it.each(['reparented', 'frame_style'] as const)( 'retires an adopted APS mount on %s loss and compare-restores only owned style', (mutation) => { diff --git a/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts b/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts index 2718f4874..e86d35516 100644 --- a/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts @@ -75,32 +75,61 @@ const EXPECTED = [ ] as const; describe('canonical release catalog', () => { - it('pins the exact thirteen first-display rows and closed server-owned selection', () => { + it('pins the exact fourteen first-display rows and closed server-owned selection', () => { expect(FIRST_DISPLAY_CONTRACT_IDS).toEqual(FIRST_DISPLAY_CATALOG.map(({ id }) => id)); expect(FIRST_DISPLAY_CATALOG.map(({ order, id }) => [order, id])).toEqual([ [1, 'first_display'], - [2, 'aps_initial'], - [3, 'creative_initial'], - [4, 'datadome_initial'], - [5, 'didomi_initial'], - [6, 'google_tag_manager_initial'], - [7, 'gpt_initial'], - [8, 'lockr_initial'], - [9, 'osano_initial'], - [10, 'permutive_initial'], - [11, 'sourcepoint_initial'], - [12, 'prebid_initial'], - [13, 'testlight_initial'], + [2, 'render_owner_initial'], + [3, 'aps_initial'], + [4, 'creative_initial'], + [5, 'datadome_initial'], + [6, 'didomi_initial'], + [7, 'google_tag_manager_initial'], + [8, 'gpt_initial'], + [9, 'lockr_initial'], + [10, 'osano_initial'], + [11, 'permutive_initial'], + [12, 'sourcepoint_initial'], + [13, 'prebid_initial'], + [14, 'testlight_initial'], ]); - expect(MAX_FIRST_DISPLAY_SLICES).toBe(13); + expect(MAX_FIRST_DISPLAY_SLICES).toBe(14); expect( selectFirstDisplayCatalog({ eligibleBatch: true, integrations: ['aps', 'gpt', 'prebid'], apsParticipates: true, + renderOwnerParticipates: true, prebidParticipates: true, }).map(({ id }) => id) - ).toEqual(['first_display', 'aps_initial', 'gpt_initial', 'prebid_initial']); + ).toEqual([ + 'first_display', + 'render_owner_initial', + 'aps_initial', + 'gpt_initial', + 'prebid_initial', + ]); + expect( + selectFirstDisplayCatalog({ + eligibleBatch: true, + integrations: ['gpt'], + renderOwnerParticipates: true, + }).map(({ id }) => id) + ).toEqual(['first_display', 'render_owner_initial', 'gpt_initial']); + expect( + selectFirstDisplayCatalog({ + eligibleBatch: true, + integrations: ['gpt'], + renderOwnerParticipates: false, + }).map(({ id }) => id) + ).toEqual(['first_display', 'gpt_initial']); + expect( + selectFirstDisplayCatalog({ + eligibleBatch: true, + integrations: ['gpt'], + apsParticipates: true, + }).map(({ id }) => id) + ).toEqual(['first_display', 'gpt_initial']); expect(selectFirstDisplayCatalog({ eligibleBatch: false, integrations: [] })).toEqual([]); expect(() => selectFirstDisplayCatalog({ eligibleBatch: true, integrations: ['unknown'] }) 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 78fefad64..d13c544ac 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 @@ -374,6 +374,12 @@ describe('Universal Creative bridge dispatcher', () => { expect(PUC_DYNAMIC_OWNER).not.toContain('rendererUrl'); expect(PUC_DYNAMIC_OWNER).not.toContain('aaxResponse'); expect(PUC_DYNAMIC_OWNER).toContain('TS APS Top Mount Started'); + expect(PUC_DYNAMIC_OWNER).not.toMatch( + /\b(?:fetch|XMLHttpRequest|WebSocket|EventSource|Worker|SharedWorker|Blob)\b/ + ); + expect(PUC_DYNAMIC_OWNER).not.toContain('import('); + expect(PUC_DYNAMIC_OWNER).not.toContain('createObjectURL'); + expect(PUC_DYNAMIC_OWNER).not.toMatch(/createElement\(["'](?:script|link)["']\)/); }); it('binds ADM load and final acceptance to the exact inserted navigation', () => { @@ -745,6 +751,92 @@ describe('Universal Creative bridge dispatcher', () => { } }); + it('contains synchronous helper registration and control-port settlement reentrancy', 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 controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + start: vi.fn(() => { + controlListener?.({ + data: { + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }, + ports: [], + }); + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }); + }), + }; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + callback({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + return stopListening; + } + ); + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + + await expect(rendered).resolves.toBeUndefined(); + expect(controlPort.start).toHaveBeenCalledOnce(); + expect(controlPort.close).toHaveBeenCalledOnce(); + expect(stopListening).toHaveBeenCalledOnce(); + } finally { + delete dynamicWindow.render; + } + }); + it('rejects accepted ADM settlement after the owner iframe navigation changes', async () => { const dynamicWindow = window as unknown as { render?: ( @@ -1576,6 +1668,14 @@ describe('Universal Creative bridge dispatcher', () => { expect(order).toEqual([`lookup:${RESERVATION_ID}`, 'stop']); expect(port.postMessage).toHaveBeenCalledOnce(); + expect(String(port.postMessage.mock.calls[0]?.[0])).toBe( + JSON.stringify({ + message: 'Prebid Response', + adId: RESERVATION_ID, + rendererVersion: '4', + tsOwner: { version: 1, status: 'refused' }, + }) + ); expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0]))).toEqual({ message: 'Prebid Response', adId: RESERVATION_ID, @@ -1858,7 +1958,8 @@ describe('Universal Creative bridge dispatcher', () => { 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])); + const rawResponse = String(port.postMessage.mock.calls[0]?.[0]); + const response = JSON.parse(rawResponse); expect( new TextEncoder().encode(String(port.postMessage.mock.calls[0]?.[0])).byteLength ).toBeLessThanOrEqual(72 * 1_024); @@ -1874,6 +1975,20 @@ describe('Universal Creative bridge dispatcher', () => { lifecycleTicket: LIFECYCLE_TICKET, }, }); + expect(rawResponse).toBe( + JSON.stringify({ + message: 'Prebid Response', + adId: RESERVATION_ID, + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + 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'); diff --git a/crates/trusted-server-js/src/bundle.rs b/crates/trusted-server-js/src/bundle.rs index 0ec802645..bcd3d240f 100644 --- a/crates/trusted-server-js/src/bundle.rs +++ b/crates/trusted-server-js/src/bundle.rs @@ -135,7 +135,7 @@ pub fn all_integration_metadata() -> Vec { .collect() } -/// Return generated metadata for the base and twelve first-display components. +/// Return generated metadata for the base and thirteen first-display slices. #[must_use] pub fn all_first_display_metadata() -> Vec { TSJS_ARTIFACTS @@ -388,25 +388,25 @@ mod tests { fn generated_artifact_inventory_includes_bootstrap_core_and_catalog_once() { let artifacts = all_artifact_metadata(); - assert_eq!(artifacts.len(), 35); + assert_eq!(artifacts.len(), 36); assert_eq!(artifacts[0].id, "bootstrap"); assert_eq!(artifacts[0].role, TsjsArtifactRole::Bootstrap); assert_eq!(artifacts[1].id, "first_display"); assert_eq!(artifacts[1].role, TsjsArtifactRole::FirstDisplayBase); assert!( - artifacts[2..14] + artifacts[2..15] .iter() .all(|artifact| artifact.role == TsjsArtifactRole::FirstDisplaySlice) ); - assert_eq!(artifacts[14].id, "core"); - assert_eq!(artifacts[14].role, TsjsArtifactRole::Core); + assert_eq!(artifacts[15].id, "core"); + assert_eq!(artifacts[15].role, TsjsArtifactRole::Core); assert!( - artifacts[15..] + artifacts[16..] .iter() .all(|artifact| artifact.role == TsjsArtifactRole::Integration) ); assert_eq!(all_module_ids().len(), 21); - assert_eq!(all_first_display_ids().len(), 13); + assert_eq!(all_first_display_ids().len(), 14); } #[test] 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 adbabbb19..00a32f78c 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 @@ -1304,27 +1304,45 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - Create: `crates/trusted-server-js/lib/src/first_display/render_journal.ts` - Create: `crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts` +- Create: `crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts` +- Delete: `crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts` +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/integrations/registry.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` - Modify: `crates/trusted-server-core/src/tsjs.rs` +- Modify: `crates/trusted-server-js/build.rs` +- Modify: `crates/trusted-server-js/src/bundle.rs` - Modify: `crates/trusted-server-js/lib/src/first_display/agent.ts` -- Modify: `crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts` +- Modify: `crates/trusted-server-js/lib/src/first_display/composition.ts` - Modify: `crates/trusted-server-js/lib/src/first_display/render_bridge.ts` - Modify: `crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts` -- Modify: `crates/trusted-server-js/lib/src/first_display/slices/aps.ts` +- Modify: `crates/trusted-server-js/lib/src/first_display/slices/*.ts` +- Modify: `crates/trusted-server-js/lib/src/core/bootstrap.ts` +- Modify: `crates/trusted-server-js/lib/src/core/contracts/boot.ts` - Modify: `crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts` - Modify: `crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/integration_registry.ts` - Modify: `crates/trusted-server-js/lib/src/kernel/release_catalog.ts` - Modify: `crates/trusted-server-js/lib/src/shared/first_display_transaction.ts` - Modify: `crates/trusted-server-js/lib/src/shared/first_display_registration.ts` - Modify: `crates/trusted-server-js/lib/src/shared/first_display_contracts.ts` - Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/scripts/bundle-metrics.mjs` - Modify: `crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs` +- Modify: `crates/trusted-server-js/lib/scripts/print-release-id.mjs` +- Modify: `scripts/validate-tsjs-performance-evidence.mjs` +- Modify: `crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts` - Test: `crates/trusted-server-js/lib/test/first_display/{agent,adm_render_bridge,render_bridge,slices}.test.ts` - Test: `crates/trusted-server-js/lib/test/first_display/transaction.test.ts` - Test: `crates/trusted-server-js/lib/test/first_display/contracts.test.ts` +- Test: `crates/trusted-server-js/lib/test/core/bootstrap.test.ts` +- Test: `crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts` - Test: `crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts` - Test: `crates/trusted-server-js/lib/test/services/puc_bridge.test.ts` +- Test: `crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs` - Test: `crates/trusted-server-js/lib/test/build/release-v1.test.mjs` -- Test: `crates/trusted-server-integration-tests/browser/tests/shared/{aps-renderer,aps-puc-lifecycle,tsjs-runtime}.spec.ts` +- Test: `crates/trusted-server-integration-tests/browser/tests/shared/{aps-renderer,aps-puc-lifecycle,tsjs-performance,tsjs-runtime}.spec.ts` - [ ] **Step 1: Add RED catalog, selection, and source-ownership tests.** Add `render_owner_initial` as catalog order 2 and shift the remaining optional @@ -1341,14 +1359,24 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled renderer-document parsing, mount code, or top-mount policy. ADM-only masks still receive the self-contained v4 owner. Assert exact catalog order 1–14 and the implications APS ⇒ render owner ⇒ GPT, ADM ⇒ render owner without APS, and - no-bid/nonrendering ⇒ no render owner. + no-bid/nonrendering ⇒ no render owner. Pin the server masks `0001` no-bid, + `0081` attribution-only, `0083` ADM, `008b` creative ADM, `0087` APS, `008f` + creative APS, and `1083` Prebid ADM. Reject an APS participation fact when APS + is not enabled. Update the Rust build consumer, generated Rust inventory, + release-id printer, persistent manifest capacity, and all slice registration + orders from 13/35 to 14/36 rather than leaving a dead build scalar or a stale + hard-coded index. - [ ] **Step 2: Add RED parity tests before extracting code.** Run the same journal corpus against ADM-only, APS-only, and mixed APS/ADM batches. Cover accepted, failed, cancelled, empty-GAM fallback, replacement success/failure, moved or mutated nodes, navigation, handoff, post-detach reentrancy, overlay-lease - transfer, targeting restoration, and final retirement. The new tests must fail - only because a shared base journal is not yet exposed. + transfer, targeting restoration, and final retirement. Include the first-display + APS adoption → persistent APS replacement → final-retirement sequence and the + publisher-owned PUC handoff. Keep the old harness shape only in a test helper + that composes the neutral journal with the APS strategy; production has no + compatibility wrapper. The new tests must fail only because a shared base + journal is not yet exposed. - [ ] **Step 3: Add RED compact-owner tests.** Keep the exact successful/refused v4 outer shapes and the complete current adversarial corpus, while requiring the @@ -1360,7 +1388,9 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled route test must also prove `render_owner_initial` is concatenated into the one served mask body, has no independently routable/requested production asset, and can create no script, preload, fetch, dynamic-import, worker, or blob request - before paint. + before paint. Measure the browser-observed call-time boundary, not a potentially + backdated `PerformanceResourceTiming.startTime`, and assert the exact request + sequence document → selected first-display artifact → persistent runtime. - [ ] **Step 4: Run RED.** @@ -1374,7 +1404,11 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled detach state, timers, and retirement bookkeeping. Expose only the exact frozen operations the APS protocol needs through the release-private slice host. Keep APS descriptor parsing, nonce registries, document parser, mount policy, and - overlay behavior in `aps_initial`. + overlay behavior in `aps_initial`. Instantiate exactly one journal during + render-owner activation, pass its one-use private capability through the base + host to APS, and let the render-owner slice disposer own it. The APS protocol + may import the neutral interface as a type only; the owner must have no runtime + dependency on APS. - [ ] **Step 6: Compact the dynamic owner in place.** Deduplicate its exact record, event/port, timer, and terminal helpers without removing checks or changing a @@ -1399,7 +1433,11 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled Expected: every focused behavior is unchanged, the production graph has one render journal, and the exact mandatory APS mask remains admitted. If the combined inline-plus-mask semantic bytes still exceed the rc × 1.10 allowance, stop and - revisit the approved split rather than weakening a gate. + revisit the approved split rather than weakening a gate. The release proof must + show 36 artifacts, 14 first-display rows, 3,584 reachable masks, the exact + generated admitted subset, a largest admitted first-display body no greater than + 90,000/30,000/26,000 raw/gzip/Brotli bytes, and a maximal total that excludes the + bootstrap role. - [ ] **Step 8: Run the three-browser focused lifecycle proof.** In `tsjs-runtime.spec.ts`, assert exactly one parser-blocking first-display TSJS @@ -1413,7 +1451,7 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 9: Commit.** ```bash - git add crates/trusted-server-core/src/tsjs.rs crates/trusted-server-js/lib/src/first_display crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts crates/trusted-server-js/lib/src/kernel/release_catalog.ts crates/trusted-server-js/lib/src/shared/first_display_transaction.ts crates/trusted-server-js/lib/src/shared/first_display_registration.ts crates/trusted-server-js/lib/src/shared/first_display_contracts.ts crates/trusted-server-js/lib/build-all.mjs crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs crates/trusted-server-js/lib/test/first_display crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts crates/trusted-server-js/lib/test/services/puc_bridge.test.ts crates/trusted-server-js/lib/test/build/release-v1.test.mjs crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts + git add -A git commit -m "Thin the APS first-display owner" ``` 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 d936ffe0d..27cee9784 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 @@ -2571,6 +2571,15 @@ that slice, and neither slice imports or instantiates a second complete render b | `prebid_initial` | Prebid participates in the initial batch | artifact admission, publisher queue, bidder/user-ID/EID setup, initial TS bid/PUC path | | `testlight_initial` | Testlight is enabled | capture preexisting callbacks before publisher replacement/drain | +The canonical low-order examples are part of the server/build contract: no-bid is +`0001`, GPT attribution-only is `0081`, ADM is `0083`, creative-guarded ADM is +`008b`, APS is `0087`, creative-guarded APS is `008f`, and Prebid-plus-ADM is +`1083`. These examples do not create aliases: the route accepts only the exact +generated mask/hash pair admitted for the current configuration. APS participation +without enabled APS configuration is invalid rather than a reason to select the +render owner, and `render_owner_initial` has no independently routable production +asset; its bytes exist only inside the selected first-display composition. + Every slice registers into the bootstrap's release-private first-display sink from the expected parser-inserted artifact and `document.currentScript`. The build fixes their order, interfaces, and allowed imports; there is no public service locator or diff --git a/scripts/validate-tsjs-performance-evidence.mjs b/scripts/validate-tsjs-performance-evidence.mjs index 12e8df4f8..a8db699d4 100644 --- a/scripts/validate-tsjs-performance-evidence.mjs +++ b/scripts/validate-tsjs-performance-evidence.mjs @@ -100,7 +100,7 @@ function validateReferenceTransfer( expectedSha, expectedModel, path, - firstDisplayMask = "0045", + firstDisplayMask = "008b", ) { exactKeys( value, @@ -707,14 +707,14 @@ export function validateEvidence(evidence, expected) { expected.baseSha, apsFirstAction.baseline.artifactModel, "aps.transfer.baselineReferenceTransfer", - "0047", + "008f", ); const apsCandidateTransfer = validateReferenceTransfer( evidence.aps.transfer.candidateReferenceTransfer, expected.headSha, apsFirstAction.candidate.artifactModel, "aps.transfer.candidateReferenceTransfer", - "0047", + "008f", ); if ( apsFirstAction.baseline.selectedTransferBytes !== @@ -964,7 +964,7 @@ function validFixture() { sha256: "4".repeat(64), }, { - semanticEndpoint: `external:/static/tsjs=tsjs-first-display.min.js?m=0045&v=${"5".repeat(64)}`, + semanticEndpoint: `external:/static/tsjs=tsjs-first-display.min.js?m=008b&v=${"5".repeat(64)}`, delivery: "external", rawBytes: 79_000, gzipBytes: 19_000, @@ -1097,7 +1097,7 @@ function validFixture() { sha256: "9".repeat(64), }, { - semanticEndpoint: `external:/static/tsjs=tsjs-first-display.min.js?m=0047&v=${"a".repeat(64)}`, + semanticEndpoint: `external:/static/tsjs=tsjs-first-display.min.js?m=008f&v=${"a".repeat(64)}`, delivery: "external", rawBytes: 79_500, gzipBytes: 19_500, @@ -1162,7 +1162,7 @@ function runSelfTest() { )[0]; releaseBaselineFixture.transfer.baselineReferenceTransfer.sources[0].semanticEndpoint = "inline:boot-controller"; - releaseBaselineFixture.transfer.baselineReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=0045&v=${"3".repeat(64)}`; + releaseBaselineFixture.transfer.baselineReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=008b&v=${"3".repeat(64)}`; for (const metric of ["rawBytes", "gzipBytes", "brotliBytes"]) { releaseBaselineFixture.transfer.baselineReferenceTransfer[metric] -= removedLegacyInit[metric]; @@ -1247,7 +1247,7 @@ function runSelfTest() { [ "mismatched first-display mask", (value) => { - value.transfer.candidateReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=0047&v=${"5".repeat(64)}`; + value.transfer.candidateReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=008f&v=${"5".repeat(64)}`; }, ], [ @@ -1535,7 +1535,7 @@ function runSelfTest() { ); assert.match( performanceTest, - /performanceCase === "aps" \? "0047" : "0045"/u, + /performanceCase === "aps" \? "008f" : "008b"/u, "the release-v1 semantic transfer must measure the exact admitted GPT and APS agents", ); assert.match( From 6ee83c400ce598adf3e0cceca7c42cdf8a71c338 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:18:53 -0700 Subject: [PATCH 541/844] Add APS render fix and TSJS resilience design spec Synthesizes three audits of the full rc/july merged state (APS end-to-end trace, TSJS architecture audit, GPT integration map) into a phased design: client-to-server disposition telemetry first, APS admission/identity/render fixes second, then the kernel/adapters/ services restructuring of TSJS with a single window.tsjs namespace, performance budgets, and restoration of the render attribution lost in the #922 merge. --- ...s-render-fix-and-tsjs-resilience-design.md | 591 ++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md 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 new file mode 100644 index 000000000..d8550b05a --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md @@ -0,0 +1,591 @@ +# APS Render Fix and TSJS Resilience Architecture — Design + +- **Status:** draft for review +- **Date:** 2026-08-04 +- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything merged + from `main` plus every rc-only merge), not just the delta pending against + `main`. +- **Inputs:** three code audits performed against this baseline (APS end-to-end + trace, TSJS architecture audit, GPT integration map); open issues #926, #941, + #944, #962, #964, #977, #983, #989, #993; open PR #997. + +--- + +## 1. Problem statement + +APS (Amazon Publisher Services) demand is fully integrated server-side — the +edge server runs the APS OpenRTB auction, wins bids, and ships a typed renderer +descriptor to the page — yet APS creatives still do not appear for real users. +Every previous fix (the `bid.meta` carrier so Prebid does not strip the +descriptor, the decoupled prebid shim, the `hb_adid` fallback to the OpenRTB bid +id) addressed a real defect, and APS still does not render. That pattern — serial +single-cause fixes that each survive review and still do not produce ads — is +itself the finding: the APS pipeline has **multiple independent failure points, +most of which fail silently**, and the client library has **no way to tell the +server (or the operator) which one fired**. + +At the same time, the TSJS client library has grown organically to 56 files / +~11,900 lines with two ~1,700-line monoliths, duplicated logic maintained by +hand in two languages, inverted layering, and roughly one hundred `catch` blocks +that discard failures. The APS outage and the library's shape are the same +problem seen from two sides: a delivery pipeline whose failure modes are +invisible and whose components cannot be reasoned about independently. + +This design covers both: (a) the specific fixes that make APS render, and (b) +the target architecture that makes TSJS a clean, resilient library so the next +integration does not reproduce this failure class. + +### Non-goals + +- No change to the APS OpenRTB endpoint contract or Amazon-side configuration. +- No rewrite of Prebid.js integration strategy (the decoupled shim stays). +- No visual/behavioral change for publishers whose pages work today. + +--- + +## 2. Why APS still does not render — the evidence + +The audit traced all four delivery flows: (a) SSAT server-side ad template via +`window.tsjs.bids`, (b) GAM + client-side `trustedServer` Prebid adapter, (c) +SPA `/_ts/page-bids` re-auction, (d) direct `/auction` via `tsjs.requestAds`. +Only flow (d) — the demo path nobody runs in production — can render an APS +descriptor without GAM's cooperation. + +The failure points, ranked by likelihood and blast radius: + +### 2.1 Admission: APS bids are eliminated before they can win + +| # | Failure | Where | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | +| A1 | **A configured `[auction].mediator` discards every direct-provider bid.** Winners come exclusively from the mediator response; APS bids (with their renderers) are used only as mediator input and reporting. APS shows `status: success, bid_count: N` yet never wins a slot. | `orchestrator.rs:412-431` | +| A2 | **`allow_script_creatives` defaults to `false`**, dropping every `tagtype: "script"` APS bid — a large share of TAM demand. The drop is counted but invisible (see A4). | `aps.rs:141-143`, `:773-778` | +| A3 | **Strict per-bid gates**: exact `w`×`h` match against configured formats (a 300×600 answer on a `[[300,250],[728,90]]` slot dies), required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` | +| A4 | **Drop reasons never reach an operator on the production paths.** `drop_reasons` counters surface only in `/auction` `ext.orchestrator`; the SSAT and page-bids paths discard them, server logs do not carry them, and the `ts-debug` comment allowlist excludes them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | + +### 2.2 Identity: the `hb_adid` contract with GAM is unproven + +| # | Failure | Where | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| B1 | **GAM key-value values are capped at 40 characters.** The new fallback emits the raw APS OpenRTB bid `id` (a long opaque string) as `hb_adid`. If GAM truncates or rejects it, `%%PATTERN:hb_adid%%` comes back different, the bridge's equality check fails, and it bails **with no log**. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` | +| B2 | **Two id universes for the same bid.** SSAT keys the bridge on the APS bid id; the client-side Prebid adapter keys on Prebid's generated `adId`. A page running both paths registers the same slot under different ids. | `publisher.rs:3366`, `prebid/index.ts:982` | + +### 2.3 Render: the client has one narrow happy path and no fallback + +| # | Failure | Where | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| C1 | **If GAM never serves the Prebid Universal Creative, nothing renders and nothing is recorded.** The renderer descriptor sits unused in `window.tsjs.bids`; `renderApsCreative` is reachable only from the unused flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` | +| C2 | **The `/integrations/aps/renderer` route registers only when `[integrations.aps]` is enabled on that origin.** A 404/401 there means the sandboxed iframe waits 10 s and dies silently. | `aps.rs:1188-1245`, `aps/render.ts:404` | +| C3 | **SafeFrame breaks slot attribution.** The bridge resolves a message source by walking top-document iframes under the slot div; a nested SafeFrame creative window is invisible to that walk, so the bridge bails silently. | `gpt/index.ts:157-183`, `:1599-1600` | +| C4 | **Three hand-maintained copies of the descriptor schema** (Rust struct, TS validator, inline renderer-document validator) with exact-key rejection: any server-side field addition instantly blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` | +| C5 | **A dead duplicate renderer branch in the bridge** contains the debug log and dedup logic people would look for while debugging; it can never execute. | `gpt/index.ts:1643-1674` | +| C6 | **The renderer CSP may kill creatives after success is reported** (`object-src`, workers, `blob:`/`data:` frames are blocked; `renderer-ready` fires before the creative actually paints). | `aps.rs:49` | +| C7 | **The renderer branches record nothing**: no `recordRender`, no win/billing beacons, no `stampCreativeTrace` — an APS win looks "never rendered" in every trace whether or not it painted. | `gpt/index.ts:1558-1632` | + +### 2.4 Observability: the common factor + +There is **zero client→server reporting**. Server telemetry marks `is_win=1` at +auction time and goes quiet; a bid that never painted is byte-identical to one +that painted perfectly. Client-side evidence (`window.tsjs.renders`, console +warnings, `data-ts-*` attributes) dies with the tab. This is why "APS still does +not work" has taken weeks instead of a dashboard row saying +`render_fail{renderer_endpoint_404}`. + +--- + +## 3. The GPT reality this design must respect + +The GPT integration is a **bootstrap-first hybrid**: the server injects a +495-line ES5 `gpt_bootstrap.js` inline in `` before the TSJS bundle, and +the two coordinate through shared monkeypatch sentinels, with document order +deciding the winner. The audit's key facts: + +1. **The bundle's handoff and initial-load code is dead in production.** The + bootstrap installs its wrappers first and sets the same sentinels the bundle + checks; ~200 lines of the TypeScript the test suite exercises most heavily + never run on a real page. A fix landed only in `gpt/index.ts` has no + production effect. +2. **The slot handoff can alias a publisher's new div to a GPT slot bound to a + dead element** — and the orphan-recovery watcher built to repair exactly that + was **lost in the #922 merge** (`0dc9b19a9` resolved `gpt/index.ts` to the rc + side). `updateRender` (one impression, one row) now has no production + caller, `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every + bridge-served impression double-counts in the trace. Open PR #997 appears to + be the reworked replacement; restoring this is a correctness prerequisite, + not a refactor. +3. **TS refreshes never pass `changeCorrelator: false`**, so every TS-driven + refresh starts a new GAM page-view correlator — silently changing roadblock, + competitive-exclusion, and frequency-capping behavior. +4. **`enableSingleRequest()` is called blind** after the publisher's own + `enableServices()` has almost always run (post-#945 deferral), so SRA intent + is asserted but not real — and on pages where TS wins the race, it forces + SRA onto publishers who chose otherwise. +5. Responsive resolution is a DOM-element-selection ladder (not GPT size + mapping); ambiguity silently skips the slot for the whole pass. +6. Three independent wrappers on `pubads().refresh` (bootstrap, bundle, prebid) + coordinate via window-global booleans that async wrappers observe already + reset. + +Any APS fix that adds more targeting keys, more refresh calls, or more +postMessage traffic has to land inside this reality, which is why the design +couples the APS fix to the library restructuring instead of adding a sixth +patch to the pile. + +--- + +## 4. Design overview + +Three workstreams, ordered by dependency: + +1. **See the failures** — client→server disposition telemetry plus surfacing + the server's existing drop counters. Without this, every subsequent fix is + another blind patch. +2. **Fix APS delivery** — admission, identity, render chain, schema, and + bridge fixes, each verifiable by the new telemetry and by contract tests. +3. **Restructure TSJS** — the kernel/adapters/services architecture that makes + the fixes durable and the next integration cheap. + +--- + +## 5. Workstream 1 — Observability first + +### 5.1 Client disposition beacon + +A new kernel module batches disposition events and posts them to a new +`POST /_ts/client-events` ingest route: + +``` +{ v: 1, page: {auctionId, navGen}, events: [ + { t: "bid_received", slot, bidder, source } + { t: "targeting_set", slot, hbAdid } + { t: "bridge_request", slot, adId, matched: bool } + { t: "render_attempt", slot, source: "renderer"|"adm"|"pbs-cache" } + { t: "render_ok", slot, source } + { t: "render_fail", slot, source, reason } // reason is a closed enum +] } +``` + +- Transport: `navigator.sendBeacon` with `fetch` keepalive fallback; batched + (flush on `visibilitychange`/`pagehide` and every 5 s); capped payload. +- Server side: a bounded, sampled log/telemetry row per event class, joining on + the auction id the server already logs. No KV writes, no PII, no cookies. +- The existing `recordRender` funnel becomes a producer for this beacon, so the + in-page trace overlay and the server see the same stream. + +`reason` enums are the contract: `renderer_endpoint_404`, +`renderer_ready_timeout`, `descriptor_invalid`, `bridge_id_mismatch`, +`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, and so on. +Every silent `return` found by the audit gets a reason code. + +### 5.2 Surface the server's own drop counters + +- Log `drop_reasons` at `warn` when an APS response yields zero admitted bids. +- Add `drop_reasons` to auction telemetry rows and to the `ts-debug` comment + allowlist (SSAT and page-bids paths). +- Startup validation warning when `[integrations.aps]` is enabled while + `allow_script_creatives = false`: "script-type APS demand will be dropped." +- Startup validation warning when APS (or any direct provider) is configured + alongside a mediator, until Workstream 2 makes that combination meaningful. + +**Exit criterion:** an operator can answer "which of the failure points in +section 2 is firing on this page" from server logs alone, with one page load. + +--- + +## 6. Workstream 2 — APS delivery fixes + +### 6.1 Admission + +- **Mediated auctions must not discard direct-provider winners (A1).** New + winner-merge policy: after the mediator responds, direct-provider bids + compete per slot by decoded CPM against mediator bids under a configurable + strategy: `mediator_only` (today's behavior, explicit), `merge_highest_cpm` + (new default). The delivery report gains + `dropped_winner_reasons["mediator_superseded"]` so the loser is visible. +- **Dimension tolerance (A3).** Replace exact `w`×`h` equality with a + containment rule: an APS bid is admitted when its size fits within any + configured format for the slot (never larger on either axis); the served size + is reported in targeting. Exact match stays preferred when available. +- **Script creatives (A2).** Keep the secure default (`false`) but make the + consequence loud (5.2) and document the enablement path for TAM-heavy + publishers. The renderer sandbox already isolates script tag types; this is a + policy toggle, not new machinery. + +### 6.2 Render identity: one short token + +Introduce a server-generated **render token** — 12 chars, `[a-z0-9]`, unique per +(auction, slot) — emitted as `hb_adid` for every SSAT bid and used as the key in +every registry and bridge branch: + +- Well inside GAM's 40-char value limit and charset rules (B1). +- The bid map carries `{ hb_adid: token, bid_id, renderer, … }`; the bridge + matches on the token; billing/win URLs keep using the real bid id. +- The client-side Prebid adapter path keeps Prebid's generated `adId` (that + contract is Prebid's own), but registration for both paths lands in **one** + registry keyed by whichever token the path will observe (B2). +- Property test: every emitted `hb_adid` matches `^[a-z0-9-]{1,40}$`. + +### 6.3 Render source chain with a GAM-claim timeout + +A winning bid becomes an ordered list of render sources: +`renderer → inline adm → pbs-cache`. The render engine walks the chain, emitting +`render_attempt` / `render_ok` / `render_fail{reason}` per step. + +For flow (a)/(c), add the missing fallback (C1): when targeting was set for a +slot and **no bridge request arrives within N seconds of `slotRenderEnded` +(empty) or within M seconds of refresh**, and the config opts in +(`[auction].client_render_fallback = "renderer"`), render the descriptor +directly into the slot container via the existing `renderApsCreative` path. The +fallback is opt-in because it changes GAM reporting semantics; the beacon makes +the "GAM never asked" case visible either way. + +### 6.4 One descriptor schema + +The Rust `ApsRendererV1` struct becomes the single source of truth: + +- `build.rs` (or a checked-in generation step) exports JSON Schema from the + serde model; the TS types and validators in `aps/render.ts` and the inline + renderer-document validator are **generated** from it. +- Validation becomes versioned-envelope tolerant: known fields validated + strictly, unknown fields ignored, `version` gates behavior (C4). +- A conformance test round-trips a Rust-serialized descriptor through the TS + validator and the renderer-document validator in CI. + +### 6.5 Renderer endpoint availability + +- Register the `/integrations/aps/renderer` route whenever the server can emit + renderer bids (auction-level concern), not only when the APS integration is + enabled on the serving origin (C2). +- The renderer iframe failure path (10 s timeout, load error) emits + `render_fail{renderer_endpoint_404 | renderer_ready_timeout}` instead of + dying silently. +- CSP audit (C6): extend `APS_RENDERER_CSP` with the minimum additional sources + observed in real Amazon creative traffic (candidates: `frame-src data: blob:`, + `worker-src blob:`), each addition justified in a comment and covered by the + browser spec. + +### 6.6 Bridge hardening + +- Delete the dead duplicate renderer branch (C5) and move its dedup + + debug-log into the live branch. +- Renderer branches call the same `fireWinBillingBeacons` + + `recordGptBridgeRender` as the adm and cache branches (C7). +- Blanket source validation at the top of the bridge listener: parse and + ownership-check before any branch logic; new branches inherit protection. +- SafeFrame-aware attribution (C3): resolve the slot by the MessageChannel port + and the `hb_adid` token first (the token is already unique per slot), using + the DOM walk only as a fallback. + +### 6.7 Tests that pin the contract + +1. Browser spec for flow (a): real GPT + PUC handshake driven from + `window.tsjs.bids` with a renderer-only bid (the region the dead code hid). +2. Mediator + APS orchestration test asserting `merge_highest_cpm` admits the + APS winner and `mediator_only` reports the drop. +3. `build_bid_map` tests: renderer emission, token-form `hb_adid`, adm and + cache-coordinate suppression for renderer bids. +4. Cross-schema conformance (6.4). +5. Page-bids JSON carries `renderer`; SPA hook delivers it to the bridge. + +--- + +## 7. Workstream 3 — TSJS target architecture + +### 7.1 Layering + +``` +kernel/ boot, config, command queue, event bus, log, telemetry beacon +adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access +services/ slots (registry+handoff), auction client, render engine, consent +integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services) +``` + +Rules, enforced by an eslint boundary rule in CI (`import/no-restricted-paths`): + +- `kernel` imports nothing above it; `adapters` import kernel only; `services` + import kernel + adapters; `integrations` import kernel + services, **never + each other**. +- This dissolves today's inversions: `core/auction.ts` and `core/request.ts` + importing `integrations/aps/render`, `gpt` and `prebid` importing `aps`, and + `prebid` owning the GPT refresh wrapper. +- `aps/` gains a real module boundary (an `index.ts`), ending the triple + inlining that gives three bundles three private copies of the frame-tracking + WeakMaps (today two paths can each mount a live APS iframe on one container + without seeing each other's cancel bookkeeping). + +### 7.2 Adapters: explicit absence + +Every external global is wrapped once with a tri-state +(`present | pending | absent`), a queue for `pending`, and a resolution +timeout that emits telemetry on `absent`. No other file touches +`window.googletag` / `window.pbjs`. This converts today's silent hangs (GPT +stub whose `cmd` never drains, `adInit` bare-returning without googletag) into +recorded, reasoned outcomes. + +### 7.3 Slot registry service + +One registry owns all slot knowledge: publisher-defined vs TS-defined, +adoption, handoff claims, responsive element resolution, refresh generation, +targeting-key history — keyed by `WeakMap` plus a +div-id index. Expando properties on live GPT objects are eliminated. The GPT +integration feeds events in and executes registry decisions; the prebid refresh +handler consumes the same registry instead of re-deriving slot resolution. + +### 7.4 Global namespace policy: everything under `window.tsjs` + +Today the library sprawls across the window: ten-plus `window.__tsjs_*` / +`window.__ts*` flags, `globalThis.tscreative` / `tsCreativeConfig`, a +symbol-keyed dispatcher, and expando properties stamped onto foreign objects +(`__tsPushed` on GPT's command queue, `__tsSlotHandoffPatched` on wrapped +functions, `__tsRenderGeneration` / `__tsRenderBid` on live GPT slot objects, +sentinels on `pbjs`). The policy going forward: + +- **One owned global: `window.tsjs`**, split internally into `tsjs` (public, + versioned API) and `tsjs._internal` (coordination state, explicitly not a + contract). Server-injected boot flags (`__tsjs_gpt_enabled`, + `__tsjs_slim_prebid_url`, bundle manifests) become fields the boot script + sets via the same command-queue pattern (`window.tsjs = window.tsjs || +{cmd: []}`), so early inline scripts and the bundle share one namespace. +- **No expandos on objects we do not own.** Per-slot state + (`__tsRenderGeneration`, `__tsRenderBid`) moves into the slot registry's + `WeakMap`; wrap-idempotence sentinels on foreign + functions are replaced by a kernel-held `WeakSet` of wrapped targets. +- **Immediate cleanup, independent of the refactor:** `__tsRenderGeneration` + and `__tsRenderBid` are dead writes on this baseline — written at + `gpt/index.ts:1086-1091`, read by nothing (their consumer was lost in the + #922 merge). Delete the writes now; when Phase 2 restores attribution, the + captured bid/generation lives in `SlotRecord`. +- Third-party globals (`googletag`, `pbjs`, CMP APIs) are read only through + adapters (7.2); integration-owned config globals (`didomiConfig`, + `permutive.config`) are written only inside that integration's adapter + boundary. + +### 7.5 Messaging module + +All `postMessage` traffic goes through one module: versioned envelopes, message +name constants (today `'Prebid Request'` appears as a bare literal at six +sites, and the APS handshake exists in three hand-synced copies), source and — +where origins are non-opaque — origin validation, and one audit point. + +### 7.6 Lifecycle discipline + +- **`install()` entry points instead of import-time side effects.** The server + boot script calls `tsjs.install(['gpt', 'prebid', …])`; modules stop + self-executing at module bottom. This kills the double-injection class + (today: `beacon_guard` double-wraps `window.fetch` unrecoverably, a second + `creative` copy silently disarms `setConfig`, `didomi` can throw during + module evaluation and halt the concatenated bundle). +- One shared window-level install sentinel helper (the pattern + `gpt_diagnostics` already got right), applied to every integration. +- A `PageSession` object owns all per-page mutable state; SPA navigation + disposes and recreates it (fixing the leaked observers, listeners, and + unbounded maps the audit enumerated). +- Error policy: no empty `catch` — every catch either handles, logs with + context, or emits a disposition reason. The auction fetch gets a timeout + + `AbortController`, and `requestAds` surfaces failure to its caller. +- **Console logging is retained, not replaced.** The disposition beacon is + additive: every condition that surfaces an issue keeps (or gains) a + `log.warn` with enough context to debug from an open DevTools console, + because the console is the tool available on a publisher's page when no + server access exists. Concretely: existing warnings survive the refactor + verbatim or strengthened; failure paths currently logged at `debug` — which + is invisible at the default `warn` level (for example the creative + `dynamic_src_guard` and click-guard rejection paths) — are promoted to + `warn` when they indicate a delivery or security-relevant failure; and every + new `render_fail` / `absent`-dependency disposition emits a paired `warn` + carrying the same reason code, so console and beacon tell one story. + +### 7.7 The bootstrap problem + +`gpt_bootstrap.js` duplicates ~400 lines of the hardest logic (handoff, +initial-load detection, hydration deferral) in hand-written ES5, always wins +the sentinel race, and has one live divergence (its simpler `adInit` can run +first and permanently suppress the bundle's `slotRenderEnded` listener). + +Target: shrink the inline bootstrap to a **queue-and-flags stub only** (create +`googletag.cmd` interception points, record early publisher calls, expose +`__tsjs_gpt_enabled`), and move all behavior into the bundle, which replays the +recorded early calls on install. If a no-bundle fallback must keep rendering +ads (today's pinned behavior), that fallback is **generated from the same +TypeScript source** at build time, never hand-maintained. + +### 7.8 GPT correctness fixes carried with the restructure + +- Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify + against open PR #997; land whichever is canonical) — fixes the dead-element + handoff alias (section 3.2) and trace double-counting. +- Pass `changeCorrelator: false` on TS-initiated refreshes; make correlator + behavior a documented, configurable decision. +- `enableSingleRequest()` only when GPT services are not already enabled; + otherwise adopt the publisher's mode and record it. +- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` instead + of only a console warning. + +### 7.9 Decomposition targets + +| Today | Target | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | +| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (moves onto slot registry), eids, diagnostics | +| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory the other six integrations already use | +| `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | +| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state | + +--- + +### 7.10 Performance + +Resilience must not cost speed; several parts of this design make the library +faster, and the rest are held to explicit budgets: + +**Where the design is a speedup:** + +- **Smaller synchronous bundle.** Consolidating `gpt/script_guard.ts` (634 + lines) onto the shared factory, un-inlining `aps/render.ts` from three + bundles into one, deleting the dead bridge branch and dead expando writes, + and splitting the trace overlay UI out of `core` all shrink the head-blocking + `tsjs-unified.js`. Target: measurably smaller than today's bundle, tracked in + CI (size report per PR). +- **Fewer repeated DOM walks.** Today slot resolution + (`findSlotElementByDivId`'s five-step ladder, iframe walks in the bridge, + prebid's independent re-derivation) runs per feature per pass. The slot + registry resolves once per slot per navigation and everyone reads the record. +- **Bounded waits instead of blind ones.** The 10 s silent renderer timeout and + the "queued forever on a GPT that never loads" cases become short, telemetered + timeouts with fallbacks — failures surface in hundreds of milliseconds, and + the render-source chain moves to the next source instead of waiting. + +**Where the design must not regress, and how that is enforced:** + +- **Ad request timing is untouched.** The critical path (bids script → + targeting → display/refresh) gains no network calls and no awaits; adapter + indirection is one property read and a queue check. +- **Telemetry is off the critical path by construction.** `sendBeacon` / + keepalive fetch, batched, flushed on `visibilitychange` — never awaited by + render code, capped in size and event count. +- **No new long tasks.** The kernel boots synchronously in microseconds + (queue + registry creation); integration `install()` bodies do what their + import-time footers do today, just at a controlled moment. +- **Budgets in CI:** bundle byte size per module, and a browser-spec assertion + that time-from-bids-script-to-first-`display()` on the reference page does + not regress against the recorded baseline. + +### 7.11 Toolchain and dependency currency + +The refactor starts from a current toolchain rather than dragging old versions +through it: + +- **TypeScript to latest stable.** The library pins `typescript ^5.5.4` while + the rest of the stack (vite 7, vitest 4, typescript-eslint 8) is current; + upgrade TS first and adopt the newer strictness the refactor wants anyway + (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, + `verbatimModuleSyntax`) — these directly serve the typing goals in 7.9 + (killing `as unknown as` escapes and the wrong `global.d.ts` declaration). +- **Dev toolchain to latest stable:** eslint (+ plugins), prettier, jsdom, + `@playwright/test` in the browser-test package, and `@types/node` aligned to + the pinned Node in `.tool-versions`. Each bump lands as its own mechanical PR + gated by the full CI matrix, with changelog review — this library + monkeypatches globals (`fetch`, `sendBeacon`, DOM prototypes), so jsdom and + Playwright behavior changes are real risks, not formalities. +- **`prebid.js` is deliberately excluded from casual bumps.** The runtime + Prebid is the external R2 bundle, version-locked by manifest hash and SRI; + the npm `prebid.js` dependency exists for tests and type + references. Upgrading Prebid is its own coordinated deploy (bundle + config + sha + server), per the decoupled-shim process — the spec only requires that + the npm pin and the deployed bundle version stay documented together so + tests exercise the version production runs. +- **Standing policy:** dependencies are reviewed on a monthly cadence and + before each phase of this migration begins; a phase never starts on a + toolchain more than one minor behind latest stable. Version floors live in + `package.json` (exact or caret pins as today) and CI runs on the pinned + Node/npm from `.tool-versions`. + +## 8. Migration plan (phased, each phase independently shippable) + +- **Phase 0 — Observability and toolchain.** Beacon + ingest route + server + drop-reason surfacing + reason codes on today's silent returns. Toolchain + currency (7.11): TypeScript and dev-dependency upgrades land here, before + any structural change, so every later phase type-checks against the compiler + it will ship with. No runtime behavior change. +- **Phase 1 — APS correctness.** Sections 6.1, 6.2, 6.5, 6.6 (admission, + token, endpoint, bridge). Verified by Phase 0 data and the new tests. This + phase alone should make APS render wherever configuration permits. +- **Phase 2 — GPT correctness.** Restore #922 attribution/orphan recovery + (with #997), correlator, SRA guard. Render-source chain + opt-in direct + fallback (6.3). +- **Phase 3 — Structure.** Layering + boundary lint, `install()` lifecycle, + adapters, slot registry, messaging module, schema generation (6.4). +- **Phase 4 — Decomposition.** File splits, script-guard consolidation, + bootstrap shrink. Pure moves under the existing vitest + browser specs, + landed one module per PR. + +Each phase gates on: all existing CI (Rust + JS + browser specs) green, plus +its own new tests; no phase depends on a later one. + +--- + +## 9. Alternatives considered + +1. **Keep patching APS point-failures without telemetry.** Rejected: three + consecutive correct fixes have not produced ads; without disposition data + the next fix is another guess. +2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but + changes GAM reporting/pacing semantics unilaterally; kept as the opt-in + fallback (6.3) instead. +3. **Full library rewrite in one branch.** Rejected: the browser-spec safety + net is thin in exactly the areas being changed; phased extraction under + tests is slower but survivable. +4. **Drop the ES5 bootstrap entirely (bundle-only).** Cleanest, but loses the + pinned "ads still render if the bundle fails" guarantee; the + generated-fallback approach (7.6) keeps that guarantee without the dual + maintenance. + +--- + +## 10. Risks + +- **Mediator merge policy (6.1)** changes auction economics where a mediator is + configured; mitigated by the explicit `mediator_only` strategy and the + delivery-report visibility. +- **Beacon volume**: bounded by batching, sampling, and closed enums; the + ingest route is fire-and-forget and cannot block rendering. +- **Schema generation** adds a build step; mitigated by checking generated + artifacts into the tree and diffing them in CI. +- **Bootstrap shrink** touches the most load-order-sensitive code in the + product; it is deliberately last (Phase 4) and behind the browser specs. + +## 11. Success criteria + +1. APS creatives render on a reference page in each configured flow (SSAT, + Prebid adapter, page-bids), proven by browser specs and by disposition + telemetry from a staged deployment. +2. Every failure point in section 2 maps to a distinct, observable signal + (server log, telemetry row, or beacon reason). +3. `eslint` boundary rules pass with zero exceptions; no integration imports + another integration; `core`/`kernel` imports no integration. +4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or + generated. +5. Trace counts are per-impression (no double counting), and orphaned-slot + recovery is covered by a non-vacuous test. +6. The only TSJS-owned global is `window.tsjs`; no expando properties on GPT + slots, GPT functions, or `pbjs`; the dead `__tsRenderGeneration` / + `__tsRenderBid` writes are gone. +7. The synchronous bundle is no larger than today's (target: smaller), and the + reference-page time-from-bids-script-to-first-`display()` does not regress. +8. No existing warning is lost: every issue-surfacing condition logs at `warn` + or above in the console, with the same reason code the beacon carries. +9. TypeScript and the dev toolchain are on latest stable (with the new + strictness flags enabled), `prebid.js`'s npm pin matches the documented + deployed bundle version, and the monthly review policy is in CI docs. + +## 12. Open questions + +1. Is a mediator configured in the affected production deployment? (Decides + whether A1 is the primary cause or a latent one.) +2. What share of live APS demand is `tagtype: "script"`? (Decides how urgent + the `allow_script_creatives` enablement guidance is.) +3. Should the direct-render fallback (6.3) ever become default-on for + publishers without GAM line items for `hb_bidder=aps`? +4. Is PR #997 the intended restoration of the lost #922 attribution core, or + should the original be re-merged? +5. Beacon endpoint naming and retention: `/_ts/client-events` vs folding into + the existing telemetry namespace. From b41e999b1e26d06f15ab374d84055f81a8d4e668 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:01:40 -0700 Subject: [PATCH 542/844] Rework design spec after review: settle the five architectural contracts Revision 2 addresses every blocking finding from the design review: adds a client-born random trace_id envelope and drops the EC-derived auction id from ingestion, scopes the render token to renderer-only bids so the PBS Cache uuid contract is untouched, replaces the aps index.ts assumption with a versioned window.tsjs._internal runtime ABI for the IIFE build, specifies an exactly-once render state machine with honest accepted-vs-confirmed semantics and nonempty-GAM protection, keeps mediator_only as the default with merge as defined opt-in, withdraws the containment dimension rule for operator-declared size maps, renames the unobservable 404 reason to renderer_no_ready, moves schema generation to a separate wire-schema crate with semantic validators retained, keeps ownership-first bridge validation over port-plus-token trust, and adds the ingest/deployment contracts (credentials-omit transport, four-adapter routing, content-addressed asset fix), a test acceptance matrix, and reordered phases with flags and rollback criteria. --- ...s-render-fix-and-tsjs-resilience-design.md | 936 +++++++++++------- 1 file changed, 566 insertions(+), 370 deletions(-) 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 d8550b05a..6415d36d8 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 @@ -1,13 +1,16 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** draft for review +- **Status:** revision 2 — reworked after design review (review verdict: + request changes). The five architectural contracts the review required are + settled in section 4. - **Date:** 2026-08-04 -- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything merged - from `main` plus every rc-only merge), not just the delta pending against - `main`. +- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything + merged from `main` plus every rc-only merge), not just the delta pending + against `main`. - **Inputs:** three code audits performed against this baseline (APS end-to-end - trace, TSJS architecture audit, GPT integration map); open issues #926, #941, - #944, #962, #964, #977, #983, #989, #993; open PR #997. + trace, TSJS architecture audit, GPT integration map); design review of + revision 1; open issues #926, #941, #944, #962, #964, #977, #983, #989, + #993; open PR #997. --- @@ -39,7 +42,9 @@ integration does not reproduce this failure class. - No change to the APS OpenRTB endpoint contract or Amazon-side configuration. - No rewrite of Prebid.js integration strategy (the decoupled shim stays). -- No visual/behavioral change for publishers whose pages work today. +- No behavior change for publishers whose pages work today. Where this design + must migrate a public surface (section 7.4), it does so behind a bounded + compatibility window, never by immediate removal. --- @@ -59,7 +64,7 @@ The failure points, ranked by likelihood and blast radius: | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | | A1 | **A configured `[auction].mediator` discards every direct-provider bid.** Winners come exclusively from the mediator response; APS bids (with their renderers) are used only as mediator input and reporting. APS shows `status: success, bid_count: N` yet never wins a slot. | `orchestrator.rs:412-431` | | A2 | **`allow_script_creatives` defaults to `false`**, dropping every `tagtype: "script"` APS bid — a large share of TAM demand. The drop is counted but invisible (see A4). | `aps.rs:141-143`, `:773-778` | -| A3 | **Strict per-bid gates**: exact `w`×`h` match against configured formats (a 300×600 answer on a `[[300,250],[728,90]]` slot dies), required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` | +| A3 | **Strict per-bid gates**: exact `w`×`h` membership in the slot's configured formats, required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` | | A4 | **Drop reasons never reach an operator on the production paths.** `drop_reasons` counters surface only in `/auction` `ext.orchestrator`; the SSAT and page-bids paths discard them, server logs do not carry them, and the `ts-debug` comment allowlist excludes them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | ### 2.2 Identity: the `hb_adid` contract with GAM is unproven @@ -74,7 +79,7 @@ The failure points, ranked by likelihood and blast radius: | # | Failure | Where | | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | C1 | **If GAM never serves the Prebid Universal Creative, nothing renders and nothing is recorded.** The renderer descriptor sits unused in `window.tsjs.bids`; `renderApsCreative` is reachable only from the unused flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` | -| C2 | **The `/integrations/aps/renderer` route registers only when `[integrations.aps]` is enabled on that origin.** A 404/401 there means the sandboxed iframe waits 10 s and dies silently. | `aps.rs:1188-1245`, `aps/render.ts:404` | +| C2 | **A renderer endpoint that never answers is a silent 10-second death.** The sandboxed iframe cannot read an HTTP status from its opaque origin; a 404/401/misrouted document simply never posts `renderer-ready`. | `aps.rs:1188-1245`, `aps/render.ts:384-404` | | C3 | **SafeFrame breaks slot attribution.** The bridge resolves a message source by walking top-document iframes under the slot div; a nested SafeFrame creative window is invisible to that walk, so the bridge bails silently. | `gpt/index.ts:157-183`, `:1599-1600` | | C4 | **Three hand-maintained copies of the descriptor schema** (Rust struct, TS validator, inline renderer-document validator) with exact-key rejection: any server-side field addition instantly blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` | | C5 | **A dead duplicate renderer branch in the bridge** contains the debug log and dedup logic people would look for while debugging; it can never execute. | `gpt/index.ts:1643-1674` | @@ -86,9 +91,7 @@ The failure points, ranked by likelihood and blast radius: There is **zero client→server reporting**. Server telemetry marks `is_win=1` at auction time and goes quiet; a bid that never painted is byte-identical to one that painted perfectly. Client-side evidence (`window.tsjs.renders`, console -warnings, `data-ts-*` attributes) dies with the tab. This is why "APS still does -not work" has taken weeks instead of a dashboard row saying -`render_fail{renderer_endpoint_404}`. +warnings, `data-ts-*` attributes) dies with the tab. --- @@ -125,166 +128,355 @@ deciding the winner. The audit's key facts: coordinate via window-global booleans that async wrappers observe already reset. -Any APS fix that adds more targeting keys, more refresh calls, or more -postMessage traffic has to land inside this reality, which is why the design -couples the APS fix to the library restructuring instead of adding a sixth -patch to the pile. - --- -## 4. Design overview - -Three workstreams, ordered by dependency: - -1. **See the failures** — client→server disposition telemetry plus surfacing - the server's existing drop counters. Without this, every subsequent fix is - another blind patch. -2. **Fix APS delivery** — admission, identity, render chain, schema, and - bridge fixes, each verifiable by the new telemetry and by contract tests. -3. **Restructure TSJS** — the kernel/adapters/services architecture that makes - the fixes durable and the next integration cheap. +## 4. Design gates — the five contracts settled before implementation + +The design review identified five architectural contracts every later section +depends on. This revision settles them; changing any of these later reopens the +review. + +### G1 — Trace identity and event envelope + +**The client-visible auction id must never be ingested.** It is EC-derived by +construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists), and server +telemetry already uses a deliberately unrelated fresh UUID +(`telemetry.rs:94-97`), so a join on it is both a privacy violation and +impossible. + +Contract: + +- A **`trace_id`** is minted client-side per navigation: 128-bit CSPRNG + (`crypto.getRandomValues`), hex-encoded, held in `PageSession`, never + persisted, never derived from any identity. +- Every event carries the envelope `{trace_id, nav_gen, render_gen, seq}`. + `seq` is a per-trace monotonic counter (ordering + deduplication); + `nav_gen`/`render_gen` scope events to a navigation and a refresh cycle, so a + batch that spans SPA navigations remains attributable per event, not per + batch. +- The **server** stamps the same `trace_id` into its own telemetry rows by + reading it from the beacon, never the reverse: server auction rows keep their + independent telemetry UUID, and correlation happens only where the client + reported a `trace_id` alongside the events it observed. Failures with no + winning bid therefore still have a trace: the trace is client-born, not + auction-born. +- **Sampling is sticky per trace** (decided once at trace mint, recorded in the + envelope), never per event — a sampled trace is complete or absent. + +### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID + +**The PBS Cache contract stays untouched.** Today `hb_adid` deliberately +prefers the Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache +coordinates and the bridge's cache fetch assume `?uuid=` +(`publisher.rs:3450`, `gpt/index.ts:1700`) — that is the Prebid Universal +Creative's documented contract. Revision 1's "token for every SSAT bid" would +have broken every cache-backed render and is withdrawn. + +Contract: + +- Bids with a cache id: `hb_adid` = cache UUID, exactly as today. +- Bids with markup and no cache id: `hb_adid` = existing fallback chain + (`ad_id`, then bid id), exactly as today. +- **Renderer-only bids (APS): `hb_adid` = a server-minted render token**, + format `^[a-z0-9]{12}$` (12 chars exactly), CSPRNG-generated with collision + retry within the auction, unique across slots and auctions, one-time + consumption in the bridge registry, TTL-bounded. Twelve characters sit well + inside GAM's documented 40-character targeting-value limit. +- The client-side Prebid adapter path keeps Prebid's generated `adId` (that is + Prebid's own contract); both paths register into one bridge registry keyed by + whichever id that path will observe. +- Regression tests: non-APS cache-backed bids keep byte-identical `hb_adid` + and cache coordinates; the token property test asserts `^[a-z0-9]{12}$`, + uniqueness, one-time consumption, and TTL expiry. + +### G3 — Runtime ABI: how code shares state under the IIFE build + +Every entry point is built as a self-contained IIFE with dynamic imports +inlined (`build-all.mjs:46`), and the server concatenates already-closed IIFEs +(`bundle.rs:23`). **A module `import` therefore never shares state across +bundles** — an `aps/index.ts` alone would just mint another private copy. This +is already a live defect beyond APS: `core/context.ts:11` holds a private +context-provider `Map` while `permutive/index.ts:102` registers into its own +independently bundled copy. + +Contract — **a versioned registration ABI on `window.tsjs._internal`**: + +- The kernel ships **only** in `tsjs-core` (always first in the concatenated + unified bundle) and publishes `tsjs._internal = { abi: 1, registry }` exactly + once, guarded by a window-level sentinel. +- All **stateful** services (slot registry, render state machine, APS renderer + registry, context providers, event bus, beacon queue) exist once, owned by + the kernel, and are reached **only** through + `tsjs._internal.registry.get(name, minVersion)` at call time — never through + imported module state. Pure stateless helpers may still be imported and + inlined freely. +- Deferred bundles (prebid) and later scripts interact through the same ABI; + `abi` majors are checked at lookup and a mismatch is a logged, telemetered + refusal, not a silent no-op. +- The alternative (single module graph / shared chunks emitted by the build and + loaded as real modules) is recorded as the long-term option; the ABI is + chosen now because it works under today's concatenation and deferred + loading without changing the delivery pipeline. +- This gate precedes and unblocks: the event bus, the slot registry, the + install registry, `PageSession`, the messaging service, and fixes the + context-provider split as its first proof. + +### G4 — Exactly-once render state machine, and honest success semantics + +**Absence of a bridge request is not proof GAM failed** — GAM may legitimately +serve a different, higher-priority creative without ever invoking the TS +bridge. And **`renderer-ready` means Amazon's runner script loaded, not that an +ad painted** (`aps.rs:105`); the current direct renderer returns `true` +immediately while the real outcome settles asynchronously for up to ten +seconds (`render.ts:318`). + +Contract: + +- One render state machine per placement instance, keyed by + `(trace_id, nav_gen, slot, refresh_gen, render_token)`, with exactly-once + terminal transition. States: + `targeting_set → bridge_claimed | gam_filled_other | fallback_running → +render_accepted → render_confirmed | render_failed(reason)`. +- **The fallback never replaces a nonempty GAM render.** Any bridge claim or + nonempty `slotRenderEnded` cancels a pending fallback; an **empty** + `slotRenderEnded` may trigger it; navigation, refresh, handoff, and slot + destruction invalidate stale attempts. +- The renderer API becomes awaitable with cancellation and an explicit + terminal reason; no fire-and-forget `true`. +- Event taxonomy replaces the single `render_ok`: + - `bridge_response_sent` — the bridge handed a payload to the PUC; + - `render_accepted` — the renderer document loaded Amazon's runner + (today's "ready"); + - `render_confirmed` — observed evidence of a paint (nonempty + `slotRenderEnded` for GAM fills; for the sandboxed renderer, iframe + load + non-collapsed geometry where observable). +- **Billing/win callbacks fire no earlier than `render_accepted`, and + `render_confirmed` is the only state reported as success.** Where APS + provides no paint acknowledgement, the design says so plainly: telemetry can + distinguish "runner loaded" from "nothing loaded," but **cannot distinguish + a painted ad from a blank one** inside the opaque frame — rows terminate at + `render_accepted` and are labeled as such, not upgraded. + +### G5 — Deployment contracts + +- **Config rollback:** every new auction/config field is default-valued and + **omitted from serialization at its default** (`AuctionConfig` denies + unknown fields, `auction_config_types.rs:7`), so blobs written by a new + binary remain readable by the previous one unless an operator opts in. +- **Ingest routing:** the beacon route exists in **all four adapters** + (Fastly, Axum, Cloudflare, Spin) as an early, EC-free, filter-free route. + Only Fastly has a real telemetry sink today; the no-sink behavior elsewhere + is "accept, count, drop" — defined, not accidental. +- **Storage:** a new dedicated datasource for client events (the existing + auction datasource cannot hold the shape without migration); schema, + retention, and sampling documented with the datasource definition. +- **Asset content-addressing is fixed, not assumed.** Script URLs embed a + content hash but the handler ignores the query and serves current registry + bytes from the path alone (`tsjs.rs:3`, `publisher.rs:294`) — during a + rolling deploy an old `?v=A` request can receive bytes `B` and cache them + under `A`. The handler must validate the requested hash and serve the + matching immutable artifact (or answer with the current hash's redirect); + rolling-deploy cache tests pin this. +- **Phase ordering** follows section 9; every phase ships behind a feature + flag with canary thresholds and rollback criteria. --- -## 5. Workstream 1 — Observability first +## 5. Workstream 1 — Observability ### 5.1 Client disposition beacon -A new kernel module batches disposition events and posts them to a new -`POST /_ts/client-events` ingest route: +A kernel module batches disposition events to `POST /_ts/client-events`: ``` -{ v: 1, page: {auctionId, navGen}, events: [ - { t: "bid_received", slot, bidder, source } - { t: "targeting_set", slot, hbAdid } - { t: "bridge_request", slot, adId, matched: bool } - { t: "render_attempt", slot, source: "renderer"|"adm"|"pbs-cache" } - { t: "render_ok", slot, source } - { t: "render_fail", slot, source, reason } // reason is a closed enum +{ v: 1, trace: {trace_id, sampled}, events: [ + { seq, nav_gen, render_gen, t: "bid_received", slot, bidder, source } + { seq, nav_gen, render_gen, t: "targeting_set", slot, hbAdid } + { seq, nav_gen, render_gen, t: "bridge_request", slot, adId, matched } + { seq, nav_gen, render_gen, t: "bridge_response_sent", slot, source } + { seq, nav_gen, render_gen, t: "render_attempt", slot, source } + { seq, nav_gen, render_gen, t: "render_accepted", slot, source } + { seq, nav_gen, render_gen, t: "render_confirmed", slot, source } + { seq, nav_gen, render_gen, t: "render_fail", slot, source, reason } ] } ``` -- Transport: `navigator.sendBeacon` with `fetch` keepalive fallback; batched - (flush on `visibilitychange`/`pagehide` and every 5 s); capped payload. -- Server side: a bounded, sampled log/telemetry row per event class, joining on - the auction id the server already logs. No KV writes, no PII, no cookies. -- The existing `recordRender` funnel becomes a producer for this beacon, so the - in-page trace overlay and the server see the same stream. - -`reason` enums are the contract: `renderer_endpoint_404`, -`renderer_ready_timeout`, `descriptor_invalid`, `bridge_id_mismatch`, -`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, and so on. -Every silent `return` found by the audit gets a reason code. - -### 5.2 Surface the server's own drop counters - -- Log `drop_reasons` at `warn` when an APS response yields zero admitted bids. -- Add `drop_reasons` to auction telemetry rows and to the `ts-debug` comment - allowlist (SSAT and page-bids paths). -- Startup validation warning when `[integrations.aps]` is enabled while - `allow_script_creatives = false`: "script-type APS demand will be dropped." -- Startup validation warning when APS (or any direct provider) is configured - alongside a mediator, until Workstream 2 makes that combination meaningful. - -**Exit criterion:** an operator can answer "which of the failure points in -section 2 is firing on this page" from server logs alone, with one page load. +- **Transport:** `fetch(..., {keepalive: true, credentials: "omit"})` is the + primary transport, because `sendBeacon` always sends credentials and its + `true` return only means "queued," not "received." `sendBeacon` remains the + documented last-resort fallback on `pagehide` where keepalive is + unavailable, and the ingest handler ignores credentials in all cases. +- **Batching:** flush on `visibilitychange`/`pagehide` and every 5 s; each + event self-describes its navigation via the envelope, so batches spanning + SPA navigations stay attributable. +- **Reasons are a closed enum**, structurally serialized (never interpolated + into log lines): `renderer_no_ready`, `descriptor_invalid`, + `bridge_id_mismatch`, `gam_empty`, `gam_filled_other`, `no_render_source`, + `slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`, + `fallback_cancelled`, `timeout`. `renderer_no_ready` (not + `renderer_endpoint_404`) is deliberate: the opaque iframe cannot read an + HTTP status, so the observable fact is "no ready message before timeout." + +### 5.2 Ingest contract + +- Registered in all four adapters before auth/EC/filters (G5); same-origin + enforced via `Origin`/`Sec-Fetch-Site` checks; hard caps on body bytes, + event count, and string lengths **enforced before parsing or logging**; + malformed rows dropped and counted; per-IP rate limiting; responds + `204 Cache-Control: no-store`; touches no KV and mints no identity. +- Server logs a bounded, structured summary per batch; the Fastly sink writes + to the new datasource (G5); other adapters count-and-drop until a sink + exists. + +### 5.3 Two modes, honestly separated + +A sampled, best-effort beacon **cannot** guarantee a complete diagnosis from +one page load — so the design stops claiming it: + +- **Production telemetry:** sticky-sampled traces, SLO "a delivery failure + mode occurring on ≥ N% of impressions is visible in the datasource within + one hour." +- **Diagnostic mode:** explicitly enabled (tester cookie / query flag), + unsampled, full event stream plus console mirroring — this is the "one page + load tells you which failure fired" tool. + +### 5.4 Server-side drop-reason surfacing + +- Emit a bounded structured summary **whenever any bid is dropped** (not only + when zero survive): per-slot reason counts, capped. +- Add `drop_reasons` to auction telemetry rows. +- The initial-HTML `ts-debug` comment gains the drop summary; `/_ts/page-bids` + returns JSON and **cannot carry an HTML comment**, so it gains a gated + structured `debug` field instead, enabled by the same tester gate. +- Startup validation warnings: APS enabled while `allow_script_creatives = +false` ("script-type APS demand will be dropped"); any direct provider + configured alongside a mediator without the merge strategy of 6.1 ("provider + bids cannot win as configured"). --- ## 6. Workstream 2 — APS delivery fixes -### 6.1 Admission - -- **Mediated auctions must not discard direct-provider winners (A1).** New - winner-merge policy: after the mediator responds, direct-provider bids - compete per slot by decoded CPM against mediator bids under a configurable - strategy: `mediator_only` (today's behavior, explicit), `merge_highest_cpm` - (new default). The delivery report gains - `dropped_winner_reasons["mediator_superseded"]` so the loser is visible. -- **Dimension tolerance (A3).** Replace exact `w`×`h` equality with a - containment rule: an APS bid is admitted when its size fits within any - configured format for the slot (never larger on either axis); the served size - is reported in targeting. Exact match stays preferred when available. -- **Script creatives (A2).** Keep the secure default (`false`) but make the - consequence loud (5.2) and document the enablement path for TAM-heavy - publishers. The renderer sandbox already isolates script tag types; this is a - policy toggle, not new machinery. - -### 6.2 Render identity: one short token - -Introduce a server-generated **render token** — 12 chars, `[a-z0-9]`, unique per -(auction, slot) — emitted as `hb_adid` for every SSAT bid and used as the key in -every registry and bridge branch: - -- Well inside GAM's 40-char value limit and charset rules (B1). -- The bid map carries `{ hb_adid: token, bid_id, renderer, … }`; the bridge - matches on the token; billing/win URLs keep using the real bid id. -- The client-side Prebid adapter path keeps Prebid's generated `adId` (that - contract is Prebid's own), but registration for both paths lands in **one** - registry keyed by whichever token the path will observe (B2). -- Property test: every emitted `hb_adid` matches `^[a-z0-9-]{1,40}$`. - -### 6.3 Render source chain with a GAM-claim timeout - -A winning bid becomes an ordered list of render sources: -`renderer → inline adm → pbs-cache`. The render engine walks the chain, emitting -`render_attempt` / `render_ok` / `render_fail{reason}` per step. - -For flow (a)/(c), add the missing fallback (C1): when targeting was set for a -slot and **no bridge request arrives within N seconds of `slotRenderEnded` -(empty) or within M seconds of refresh**, and the config opts in -(`[auction].client_render_fallback = "renderer"`), render the descriptor -directly into the slot container via the existing `renderApsCreative` path. The -fallback is opt-in because it changes GAM reporting semantics; the beacon makes -the "GAM never asked" case visible either way. - -### 6.4 One descriptor schema - -The Rust `ApsRendererV1` struct becomes the single source of truth: - -- `build.rs` (or a checked-in generation step) exports JSON Schema from the - serde model; the TS types and validators in `aps/render.ts` and the inline - renderer-document validator are **generated** from it. -- Validation becomes versioned-envelope tolerant: known fields validated - strictly, unknown fields ignored, `version` gates behavior (C4). -- A conformance test round-trips a Rust-serialized descriptor through the TS - validator and the renderer-document validator in CI. - -### 6.5 Renderer endpoint availability - -- Register the `/integrations/aps/renderer` route whenever the server can emit - renderer bids (auction-level concern), not only when the APS integration is - enabled on the serving origin (C2). -- The renderer iframe failure path (10 s timeout, load error) emits - `render_fail{renderer_endpoint_404 | renderer_ready_timeout}` instead of - dying silently. -- CSP audit (C6): extend `APS_RENDERER_CSP` with the minimum additional sources - observed in real Amazon creative traffic (candidates: `frame-src data: blob:`, - `worker-src blob:`), each addition justified in a comment and covered by the - browser spec. - -### 6.6 Bridge hardening - -- Delete the dead duplicate renderer branch (C5) and move its dedup + - debug-log into the live branch. -- Renderer branches call the same `fireWinBillingBeacons` + - `recordGptBridgeRender` as the adm and cache branches (C7). -- Blanket source validation at the top of the bridge listener: parse and - ownership-check before any branch logic; new branches inherit protection. -- SafeFrame-aware attribution (C3): resolve the slot by the MessageChannel port - and the `hb_adid` token first (the token is already unique per slot), using - the DOM walk only as a fallback. - -### 6.7 Tests that pin the contract - -1. Browser spec for flow (a): real GPT + PUC handshake driven from - `window.tsjs.bids` with a renderer-only bid (the region the dead code hid). -2. Mediator + APS orchestration test asserting `merge_highest_cpm` admits the - APS winner and `mediator_only` reports the drop. -3. `build_bid_map` tests: renderer emission, token-form `hb_adid`, adm and - cache-coordinate suppression for renderer bids. -4. Cross-schema conformance (6.4). -5. Page-bids JSON carries `renderer`; SPA hook delivers it to the bridge. +### 6.1 Mediation: opt-in merge, `mediator_only` stays the default + +The configured contract today is explicit — the mediator is the final +decision-maker (`auction_config_types.rs:48`) — and changing that default +silently would be an economic breaking change with a rollback hazard. So: + +- `[auction].winner_selection = "mediator_only"` (default, today's behavior, + now explicit) or `"merge_highest_cpm"` (opt-in). The field is omitted from + serialized blobs at its default (G5). +- `merge_highest_cpm` semantics, defined up front: comparison in the auction's + decoded CPM currency; slot floors apply to both populations; a bid present + in both (a provider bid the mediator also returned) counts once, by + provenance `mediator`; deals outrank open bids regardless of CPM; ties break + to the mediator; a mediator timeout degrades to direct-provider selection + (today's short-circuit) and is reported as such. +- **One candidate-selection helper serves both mediation lifecycles** — the + ordinary/page-bids path (`orchestrator.rs:412-431`) and the initial-SSAT + split dispatch/collect path (`orchestrator.rs:1320`) — with tests for each. +- A **selection report** (`winner_source`, `mediator_superseded` counts) is + emitted separately from the delivery-conversion drop reasons, so "lost the + merge" is never conflated with "failed to serialize." + +### 6.2 Dimensions: exact membership stays; flexibility is operator-declared + +Revision 1's containment rule ("never larger on either axis") would still +reject its own motivating example (a 300×600 answer on a 300×250/728×90 slot) +while admitting pathological 1×1 sizes — withdrawn. Instead: + +- Exact size membership (`aps.rs:657-668`) remains the default; it is + consistent with discrete GAM slot formats. +- An operator may declare flexibility per slot: + `accept_sizes = [[300, 600], …]` (an explicit allow-list of additional + creative sizes, with documentation that GAM line items must accept them) — + no inference, no aspect heuristics in v1. Admitted alternate sizes set + `hb_size` targeting (set and cleared with the other `hb_*` keys) and the + served size is reported in the selection report. + +### 6.3 Script creatives + +Keep the secure default (`allow_script_creatives = false`) but make the +consequence loud (5.4) and document the enablement path for TAM-heavy +publishers. The renderer sandbox already isolates script tag types; this is a +policy toggle, not new machinery. + +### 6.4 Render identity + +Implemented exactly as G2: cache UUID untouched, render token only for +renderer-only bids, one registry, token property tests +(`^[a-z0-9]{12}$`, CSPRNG, collision retry, TTL, one-time consumption, +cross-slot/auction uniqueness), and a non-APS cache-path regression test. + +### 6.5 Fallback rendering + +Implemented exactly as G4: opt-in +(`[auction].client_render_fallback = "renderer"`), driven by the render state +machine, triggered only by an **empty** GAM render or a bridge-claim timeout +with no fill evidence, cancelled by any bridge claim or nonempty render, +invalidated by navigation/refresh/destruction, and reported with the G4 event +taxonomy. The direct renderer is converted to an awaitable API first; the +fallback lands only after that conversion. + +### 6.6 Renderer endpoint + +- Document the topology: within one deployment the renderer route and the APS + provider share the same config gate (`aps.rs:1224`, `:1244`), so "server + emits descriptors but lacks the route" is a **cross-deployment or stale-CDN + problem**, and a 401 most plausibly comes from broad `[[handlers]]` auth + patterns matching `/integrations/*` before route dispatch. +- Therefore: validate at startup that no configured auth handler pattern + covers `/integrations/aps/renderer`; make the static renderer document + config-independent **iff** the deployment serves multiple origins from one + config (recorded as an open question with the operator); version the + renderer document and define its cache headers. +- The client reports `renderer_no_ready` (5.1) — no status probe is added, + because a probe would violate the no-new-critical-path-request budget. +- CSP audit (C6) unchanged from revision 1: extend `APS_RENDERER_CSP` only + with sources observed in real Amazon traffic, each justified in a comment + and covered by the browser spec. + +### 6.7 One descriptor schema — structural generation, semantic validators kept + +- The wire truth is the **tagged enum** `BidRenderer` (the `type: "aps"` + discriminator lives there, not on `ApsRendererV1` — `types.rs:188-211`), so + the generated schema is the full tagged envelope. +- Generation lives in a **separate wire-schema crate** (or a host-side + xtask) — it cannot live in `trusted-server-js`'s build because core already + depends on that crate (`Cargo.toml:45`) and the reverse edge would be a + cycle. Generated TS artifacts are checked in; CI fails on staleness. +- Generation covers **structure only** (fields, types, discriminator, + version). The semantic security checks stay hand-written on both sides: + URL/origin policy, canonical base64, length bounds, the exact one-bid + envelope projection, cross-field equality. **Unknown-field tolerance applies + only to the outer versioned descriptor; the decoded AAX envelope remains an + exact projection** so `adm`, notification URLs, or sibling fields can never + slip through unexamined. +- A shared corpus — positive cases plus an adversarial set (extra fields, + wrong versions, oversized payloads, URL smuggling, non-canonical base64) — + runs through the Rust validator, the TS validator, and the inline renderer + document in CI. + +### 6.8 Bridge hardening + +- **Ownership proof stays source-first.** The bridge continues to resolve the + message source to a slot and only then compares that slot's expected id + (`gpt/index.ts:1599` order) — a MessageChannel port plus a token is not + ownership proof, because an inbound port has no pre-established slot + identity and the token is visible in `window.tsjs.bids`. For SafeFrame, + source resolution is extended to walk nested browsing contexts via + `window.frames` containment checks rather than DOM `querySelectorAll` only; + where the source is unresolvable the bridge refuses (with + `bridge_id_mismatch`) instead of trusting the token. +- Adversarial tests are part of the contract: wrong-slot tokens, nested + foreign frames, replayed and duplicated messages, stolen tokens, and + previous-navigation tokens — not only the positive SafeFrame case. +- Blanket top-of-listener hygiene: parse and ownership-check before any + branch logic; new branches inherit protection. +- Delete the dead duplicate renderer branch (C5); move its dedup and debug log + into the live branch. +- Renderer branches emit the same trace records as the adm and cache branches, + under G4's event taxonomy and billing rules (C7). --- @@ -304,95 +496,87 @@ Rules, enforced by an eslint boundary rule in CI (`import/no-restricted-paths`): - `kernel` imports nothing above it; `adapters` import kernel only; `services` import kernel + adapters; `integrations` import kernel + services, **never each other**. +- Stateful services are reached through the G3 registration ABI, never through + imported module state; stateless helpers may be imported and inlined. - This dissolves today's inversions: `core/auction.ts` and `core/request.ts` importing `integrations/aps/render`, `gpt` and `prebid` importing `aps`, and `prebid` owning the GPT refresh wrapper. -- `aps/` gains a real module boundary (an `index.ts`), ending the triple - inlining that gives three bundles three private copies of the frame-tracking - WeakMaps (today two paths can each mount a live APS iframe on one container - without seeing each other's cancel bookkeeping). -### 7.2 Adapters: explicit absence +### 7.2 Adapters: explicit absence, without giving up on late loaders -Every external global is wrapped once with a tri-state -(`present | pending | absent`), a queue for `pending`, and a resolution -timeout that emits telemetry on `absent`. No other file touches -`window.googletag` / `window.pbjs`. This converts today's silent hangs (GPT -stub whose `cmd` never drains, `adInit` bare-returning without googletag) into -recorded, reasoned outcomes. +Every external global is wrapped once with a state machine +`present | pending | timed_out`, a queue for `pending`, and per-operation +bounds. `timed_out` is **not terminal**: publishers legitimately lazy-load +GPT, Prebid, and CMPs, so a later arrival transitions the adapter to `present` +and drains what is still valid; individual queued operations carry their own +timeouts and expire with a disposition reason rather than the adapter +permanently disabling itself. ### 7.3 Slot registry service One registry owns all slot knowledge: publisher-defined vs TS-defined, adoption, handoff claims, responsive element resolution, refresh generation, targeting-key history — keyed by `WeakMap` plus a -div-id index. Expando properties on live GPT objects are eliminated. The GPT -integration feeds events in and executes registry decisions; the prebid refresh -handler consumes the same registry instead of re-deriving slot resolution. - -### 7.4 Global namespace policy: everything under `window.tsjs` - -Today the library sprawls across the window: ten-plus `window.__tsjs_*` / -`window.__ts*` flags, `globalThis.tscreative` / `tsCreativeConfig`, a -symbol-keyed dispatcher, and expando properties stamped onto foreign objects -(`__tsPushed` on GPT's command queue, `__tsSlotHandoffPatched` on wrapped -functions, `__tsRenderGeneration` / `__tsRenderBid` on live GPT slot objects, -sentinels on `pbjs`). The policy going forward: - -- **One owned global: `window.tsjs`**, split internally into `tsjs` (public, - versioned API) and `tsjs._internal` (coordination state, explicitly not a - contract). Server-injected boot flags (`__tsjs_gpt_enabled`, - `__tsjs_slim_prebid_url`, bundle manifests) become fields the boot script - sets via the same command-queue pattern (`window.tsjs = window.tsjs || -{cmd: []}`), so early inline scripts and the bundle share one namespace. -- **No expandos on objects we do not own.** Per-slot state - (`__tsRenderGeneration`, `__tsRenderBid`) moves into the slot registry's - `WeakMap`; wrap-idempotence sentinels on foreign - functions are replaced by a kernel-held `WeakSet` of wrapped targets. -- **Immediate cleanup, independent of the refactor:** `__tsRenderGeneration` - and `__tsRenderBid` are dead writes on this baseline — written at - `gpt/index.ts:1086-1091`, read by nothing (their consumer was lost in the - #922 merge). Delete the writes now; when Phase 2 restores attribution, the - captured bid/generation lives in `SlotRecord`. -- Third-party globals (`googletag`, `pbjs`, CMP APIs) are read only through - adapters (7.2); integration-owned config globals (`didomiConfig`, - `permutive.config`) are written only inside that integration's adapter - boundary. +div-id index, owned by the kernel via the G3 ABI. Expando properties on live +GPT objects are eliminated. The GPT integration feeds events in and executes +registry decisions; the prebid refresh handler consumes the same registry. + +### 7.4 Global namespace policy — with a compatibility window + +One owned global, `window.tsjs`, public API versioned, coordination state +under `tsjs._internal` (G3). But the migration must not break published +contracts: + +- **Inventory first:** every current global is classified public + (`globalThis.tscreative`, `tsCreativeConfig` — documented, settable + pre-load) or private (`__tsjs_*` flags, expandos, sentinels). +- **Public globals get a bounded dual-read/write window:** old and new names + both work for a stated deprecation period, with pre-init compatibility tests + (config set before the bundle loads must keep working); removal is its own + later, announced change. +- Private globals migrate immediately: per-slot expandos + (`__tsRenderGeneration`, `__tsRenderBid` — dead writes today, delete now) + into `SlotRecord`; function-object sentinels into a kernel-held `WeakSet`; + boot flags into the `window.tsjs = window.tsjs || {cmd: []}` pattern. ### 7.5 Messaging module All `postMessage` traffic goes through one module: versioned envelopes, message name constants (today `'Prebid Request'` appears as a bare literal at six -sites, and the APS handshake exists in three hand-synced copies), source and — -where origins are non-opaque — origin validation, and one audit point. - -### 7.6 Lifecycle discipline - -- **`install()` entry points instead of import-time side effects.** The server - boot script calls `tsjs.install(['gpt', 'prebid', …])`; modules stop - self-executing at module bottom. This kills the double-injection class - (today: `beacon_guard` double-wraps `window.fetch` unrecoverably, a second - `creative` copy silently disarms `setConfig`, `didomi` can throw during - module evaluation and halt the concatenated bundle). -- One shared window-level install sentinel helper (the pattern - `gpt_diagnostics` already got right), applied to every integration. -- A `PageSession` object owns all per-page mutable state; SPA navigation - disposes and recreates it (fixing the leaked observers, listeners, and - unbounded maps the audit enumerated). -- Error policy: no empty `catch` — every catch either handles, logs with - context, or emits a disposition reason. The auction fetch gets a timeout + +sites, and the APS handshake exists in three hand-synced copies), source +validation per 6.8, and one audit point. + +### 7.6 Plugin lifecycle + +`install()` replaces import-time side effects, with the semantics the review +demanded: + +- `tsjs.definePlugin(id, version, install, dispose)` registers; the kernel + resolves install requests against registrations, so **a deferred bundle that + registers after `tsjs.install([...])` was requested is installed on + arrival** (pending-install queue), bounded by a missing-module timeout that + emits `bundle_partial`. +- Per-plugin exception isolation: a plugin that throws during install is + quarantined and reported; it cannot halt the bundle (today `didomi` can + throw during module evaluation and stop everything after it). +- Duplicate registration of the same `(id, version)` is a no-op; a different + version for a registered id follows a declared policy (first-wins + loud + telemetry). +- A `PageSession` object owns an **enumerable** set of listeners, timers, + observers, and slot records — registered at creation, disposed on + navigation. A `WeakMap` alone cannot dispose anything; the owned-set is the + disposal inventory. +- Error policy: no empty `catch` — every catch handles, logs with context, or + emits a disposition reason. The auction fetch gets a timeout + `AbortController`, and `requestAds` surfaces failure to its caller. -- **Console logging is retained, not replaced.** The disposition beacon is - additive: every condition that surfaces an issue keeps (or gains) a - `log.warn` with enough context to debug from an open DevTools console, - because the console is the tool available on a publisher's page when no - server access exists. Concretely: existing warnings survive the refactor - verbatim or strengthened; failure paths currently logged at `debug` — which - is invisible at the default `warn` level (for example the creative - `dynamic_src_guard` and click-guard rejection paths) — are promoted to - `warn` when they indicate a delivery or security-relevant failure; and every - new `render_fail` / `absent`-dependency disposition emits a paired `warn` - carrying the same reason code, so console and beacon tell one story. +- **Console logging is retained, not replaced.** The beacon is additive: every + issue-surfacing condition keeps (or gains) a `log.warn` debuggable from an + open DevTools console. Existing warnings survive verbatim or strengthened; + failure paths currently at `debug` (invisible at the default `warn` level — + the creative `dynamic_src_guard` and click-guard rejection paths) are + promoted to `warn` when they indicate a delivery or security-relevant + failure; every `render_fail` / dependency-timeout disposition emits a paired + `warn` carrying the same reason code, so console and beacon tell one story. ### 7.7 The bootstrap problem @@ -401,24 +585,25 @@ initial-load detection, hydration deferral) in hand-written ES5, always wins the sentinel race, and has one live divergence (its simpler `adInit` can run first and permanently suppress the bundle's `slotRenderEnded` listener). -Target: shrink the inline bootstrap to a **queue-and-flags stub only** (create -`googletag.cmd` interception points, record early publisher calls, expose -`__tsjs_gpt_enabled`), and move all behavior into the bundle, which replays the -recorded early calls on install. If a no-bundle fallback must keep rendering -ads (today's pinned behavior), that fallback is **generated from the same -TypeScript source** at build time, never hand-maintained. +Target: shrink the inline bootstrap to a queue-and-flags stub (create +`googletag.cmd` interception points, record early publisher calls, expose the +enable flag), with the bundle replaying recorded calls on install. This is +**not a pure move** — replay changes observable ordering — so it ships behind +its own flag with the browser specs extended to cover replay timing, and the +no-bundle fallback ("ads still render if the bundle fails," pinned by +`gpt.rs:1174-1179`) is **generated from the same TypeScript source** at build +time, never hand-maintained. ### 7.8 GPT correctness fixes carried with the restructure - Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify - against open PR #997; land whichever is canonical) — fixes the dead-element - handoff alias (section 3.2) and trace double-counting. -- Pass `changeCorrelator: false` on TS-initiated refreshes; make correlator - behavior a documented, configurable decision. + against open PR #997; land whichever is canonical). +- Pass `changeCorrelator: false` on TS-initiated refreshes; correlator + behavior becomes a documented, configurable decision. - `enableSingleRequest()` only when GPT services are not already enabled; otherwise adopt the publisher's mode and record it. -- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` instead - of only a console warning. +- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` in + addition to its console warning. ### 7.9 Decomposition targets @@ -430,162 +615,173 @@ TypeScript source** at build time, never hand-maintained. | `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | | `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state | ---- - ### 7.10 Performance -Resilience must not cost speed; several parts of this design make the library -faster, and the rest are held to explicit budgets: - -**Where the design is a speedup:** - -- **Smaller synchronous bundle.** Consolidating `gpt/script_guard.ts` (634 - lines) onto the shared factory, un-inlining `aps/render.ts` from three - bundles into one, deleting the dead bridge branch and dead expando writes, - and splitting the trace overlay UI out of `core` all shrink the head-blocking - `tsjs-unified.js`. Target: measurably smaller than today's bundle, tracked in - CI (size report per PR). -- **Fewer repeated DOM walks.** Today slot resolution - (`findSlotElementByDivId`'s five-step ladder, iframe walks in the bridge, - prebid's independent re-derivation) runs per feature per pass. The slot - registry resolves once per slot per navigation and everyone reads the record. -- **Bounded waits instead of blind ones.** The 10 s silent renderer timeout and - the "queued forever on a GPT that never loads" cases become short, telemetered - timeouts with fallbacks — failures surface in hundreds of milliseconds, and - the render-source chain moves to the next source instead of waiting. - -**Where the design must not regress, and how that is enforced:** - -- **Ad request timing is untouched.** The critical path (bids script → - targeting → display/refresh) gains no network calls and no awaits; adapter - indirection is one property read and a queue check. -- **Telemetry is off the critical path by construction.** `sendBeacon` / - keepalive fetch, batched, flushed on `visibilitychange` — never awaited by - render code, capped in size and event count. -- **No new long tasks.** The kernel boots synchronously in microseconds - (queue + registry creation); integration `install()` bodies do what their - import-time footers do today, just at a controlled moment. -- **Budgets in CI:** bundle byte size per module, and a browser-spec assertion - that time-from-bids-script-to-first-`display()` on the reference page does - not regress against the recorded baseline. +Client-side, the design is a net speedup with enforced budgets: + +- **Smaller synchronous bundle:** script-guard consolidation, single APS + module (via the ABI), dead-code deletion, trace-overlay extraction. +- **Fewer repeated DOM walks:** slot resolution once per navigation in the + registry. +- **Bounded waits instead of blind ones:** the 10 s silent renderer timeout + and forever-queued GPT cases become short, telemetered timeouts. +- **Budgets in CI, precisely specified:** per-bundle byte sizes measured raw, + gzip, and Brotli for an exact named module set, compared against a + checked-in baseline artifact with a stated tolerance; the browser-spec + timing assertion (bids-script-to-first-`display()`) runs N times and gates + on a percentile, not a single sample. + +Server-side (new in this revision): each injected page currently concatenates +and hashes the full immediate bundle, and the asset request concatenates it +again (`bundle.rs:51`). Precompute bundle bytes + hash per registry module set +(they change only at deploy/config time), and benchmark server CPU/heap before +and after. ### 7.11 Toolchain and dependency currency -The refactor starts from a current toolchain rather than dragging old versions -through it: - -- **TypeScript to latest stable.** The library pins `typescript ^5.5.4` while - the rest of the stack (vite 7, vitest 4, typescript-eslint 8) is current; - upgrade TS first and adopt the newer strictness the refactor wants anyway - (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, - `verbatimModuleSyntax`) — these directly serve the typing goals in 7.9 - (killing `as unknown as` escapes and the wrong `global.d.ts` declaration). -- **Dev toolchain to latest stable:** eslint (+ plugins), prettier, jsdom, - `@playwright/test` in the browser-test package, and `@types/node` aligned to - the pinned Node in `.tool-versions`. Each bump lands as its own mechanical PR - gated by the full CI matrix, with changelog review — this library - monkeypatches globals (`fetch`, `sendBeacon`, DOM prototypes), so jsdom and - Playwright behavior changes are real risks, not formalities. -- **`prebid.js` is deliberately excluded from casual bumps.** The runtime - Prebid is the external R2 bundle, version-locked by manifest hash and SRI; - the npm `prebid.js` dependency exists for tests and type - references. Upgrading Prebid is its own coordinated deploy (bundle + config - sha + server), per the decoupled-shim process — the spec only requires that - the npm pin and the deployed bundle version stay documented together so - tests exercise the version production runs. -- **Standing policy:** dependencies are reviewed on a monthly cadence and - before each phase of this migration begins; a phase never starts on a - toolchain more than one minor behind latest stable. Version floors live in - `package.json` (exact or caret pins as today) and CI runs on the pinned - Node/npm from `.tool-versions`. - -## 8. Migration plan (phased, each phase independently shippable) - -- **Phase 0 — Observability and toolchain.** Beacon + ingest route + server - drop-reason surfacing + reason codes on today's silent returns. Toolchain - currency (7.11): TypeScript and dev-dependency upgrades land here, before - any structural change, so every later phase type-checks against the compiler - it will ship with. No runtime behavior change. -- **Phase 1 — APS correctness.** Sections 6.1, 6.2, 6.5, 6.6 (admission, - token, endpoint, bridge). Verified by Phase 0 data and the new tests. This - phase alone should make APS render wherever configuration permits. -- **Phase 2 — GPT correctness.** Restore #922 attribution/orphan recovery - (with #997), correlator, SRA guard. Render-source chain + opt-in direct - fallback (6.3). -- **Phase 3 — Structure.** Layering + boundary lint, `install()` lifecycle, - adapters, slot registry, messaging module, schema generation (6.4). -- **Phase 4 — Decomposition.** File splits, script-guard consolidation, - bootstrap shrink. Pure moves under the existing vitest + browser specs, - landed one module per PR. - -Each phase gates on: all existing CI (Rust + JS + browser specs) green, plus -its own new tests; no phase depends on a later one. +- **TypeScript to latest stable** (library pins `^5.5.4` while vite 7 / + vitest 4 / typescript-eslint 8 are current), adopting + `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, + `verbatimModuleSyntax` — these directly serve killing the `as unknown as` + escapes and the wrong `global.d.ts` declaration. +- **Dev toolchain to latest stable** (eslint + plugins, prettier, jsdom, + `@playwright/test`, `@types/node` aligned to the pinned Node), each bump its + own mechanical CI-gated PR with changelog review — this library + monkeypatches `fetch`, `sendBeacon`, and DOM prototypes, so jsdom and + Playwright behavior changes are real risks. +- **`prebid.js` is excluded from casual bumps:** the runtime Prebid is the + external R2 bundle locked by manifest hash and SRI; upgrading it is its own + coordinated deploy. The npm pin and the deployed bundle version stay + documented together so tests exercise the version production runs. +- **Standing policy:** monthly dependency review; no migration phase starts + more than one minor behind latest stable. --- -## 9. Alternatives considered - -1. **Keep patching APS point-failures without telemetry.** Rejected: three - consecutive correct fixes have not produced ads; without disposition data - the next fix is another guess. -2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but - changes GAM reporting/pacing semantics unilaterally; kept as the opt-in - fallback (6.3) instead. -3. **Full library rewrite in one branch.** Rejected: the browser-spec safety - net is thin in exactly the areas being changed; phased extraction under - tests is slower but survivable. -4. **Drop the ES5 bootstrap entirely (bundle-only).** Cleanest, but loses the - pinned "ads still render if the bundle fails" guarantee; the - generated-fallback approach (7.6) keeps that guarantee without the dual - maintenance. +## 8. Migration plan + +Reordered so every phase's prerequisites precede it; each phase ships behind a +feature flag with canary thresholds and rollback criteria. + +- **Phase 0 — Contracts and toolchain.** Settle G1–G5 in code-adjacent docs; + toolchain upgrades (7.11); the trace envelope + beacon + four-adapter ingest + (accept-count-drop outside Fastly) behind a flag; server drop-reason + surfacing (5.4); reason codes on today's silent returns; delete the dead + expando writes. No runtime behavior change for pages with the flag off. +- **Phase 1 — Runtime ABI + APS admission/identity.** G3 kernel registry in + `tsjs-core` (context-provider fix is the proof); wire-schema crate + shared + corpus (6.7); mediation selection helper + opt-in merge (6.1); render token + for renderer-only bids (6.4); renderer-endpoint startup validation (6.6); + bridge hardening minus fallback (6.8). APS renders after this phase wherever + GAM line items and configuration permit — the no-GAM fallback is explicitly + Phase 2. +- **Phase 2 — Render state machine + GPT correctness.** Minimal `SlotRecord` + core (just enough for the state machine keys; full registry lands in + Phase 3); awaitable renderer conversion; the exactly-once fallback (6.5, + G4); restore #922/#997 attribution and orphan recovery; correlator and SRA + fixes (7.8). +- **Phase 3 — Structure.** Full layering + boundary lint, plugin lifecycle + + `PageSession` (7.6), adapters (7.2), full slot registry (7.3), messaging + module (7.5), namespace migration with its compatibility window (7.4), + asset content-addressing fix + server bundle precompute (G5, 7.10). +- **Phase 4 — Decomposition.** File splits (7.9), script-guard consolidation, + bootstrap shrink behind its own flag with replay-timing specs (7.7), and + the end of the public-global compatibility window. --- -## 10. Risks +## 9. Test acceptance matrix + +Blocking CI is hermetic (the deterministic PUC/message harness); a separate +staged smoke suite covers real GAM line items and is release-gating, not +PR-gating. + +| Area | Must cover | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Mediation | both lifecycles (ordinary + SSAT split dispatch/collect); ties, floors, deals, duplicate demand; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) | +| Cache identity | non-APS cache-backed bids: byte-identical `hb_adid` + cache coordinates (regression); PUC `?uuid=` fetch path | +| Render token | `^[a-z0-9]{12}$`; CSPRNG source; collision retry; TTL; one-time consumption; cross-slot/auction uniqueness | +| Fallback | races vs bridge claim; nonempty-GAM protection; repeated refresh; SPA navigation cancellation; slot destruction; exactly-once terminal transition | +| Render semantics | no billing after runner-load failure; `render_accepted` vs `render_confirmed` labeling; opaque-frame honesty (no painted/blank claim) | +| Bridge security | wrong-slot tokens; nested foreign frames; replayed + duplicate messages; stolen tokens; previous-navigation tokens; SafeFrame positive case | +| Beacon | trace joins; ordering/dedup via `(trace_id, nav_gen, seq)`; batching across navigations; loss tolerance; ingest abuse (oversize, malformed, cross-origin); all-adapter routing | +| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection | +| Runtime ABI | one kernel instance under concatenation; deferred registration after install request; per-plugin failure isolation; abi-version mismatch refusal | +| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); `PageSession` disposal inventory; pre-init `tsCreativeConfig` compatibility; dual-name global window | +| Delivery | immutable-cache behavior across a simulated rolling deploy; deterministic raw/gzip/Brotli bundle budgets vs baseline artifact | +| Observability | drop-reason summary on any partial drop; page-bids structured `debug` field gating; diagnostic mode unsampled completeness | + +--- -- **Mediator merge policy (6.1)** changes auction economics where a mediator is - configured; mitigated by the explicit `mediator_only` strategy and the - delivery-report visibility. -- **Beacon volume**: bounded by batching, sampling, and closed enums; the - ingest route is fire-and-forget and cannot block rendering. -- **Schema generation** adds a build step; mitigated by checking generated - artifacts into the tree and diffing them in CI. -- **Bootstrap shrink** touches the most load-order-sensitive code in the - product; it is deliberately last (Phase 4) and behind the browser specs. +## 10. Alternatives considered -## 11. Success criteria +1. **Keep patching APS point-failures without telemetry.** Rejected: three + consecutive correct fixes have not produced ads; without disposition data + the next fix is another guess. +2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but + changes GAM reporting/pacing semantics unilaterally; kept as the opt-in, + state-machine-guarded fallback instead. +3. **Single module graph / shared chunks instead of the registration ABI.** + Cleaner long-term, but changes the delivery pipeline (chunk loading) now; + recorded as the successor option behind the same ABI surface. +4. **Full library rewrite in one branch.** Rejected: the browser-spec safety + net is thin in exactly the areas being changed. +5. **Drop the ES5 bootstrap entirely.** Loses the pinned "ads render if the + bundle fails" guarantee; the generated-fallback approach keeps it without + dual maintenance. + +## 11. Risks + +- **Merge strategy misconfiguration** changes auction economics; mitigated by + keeping `mediator_only` the default, the selection report, and omitted + serialization at defaults. +- **Beacon abuse/volume:** bounded by pre-parse caps, origin checks, rate + limits, sticky sampling, and closed enums. +- **ABI freeze risk:** `tsjs._internal.registry` becomes load-bearing; + versioned from day one, majors checked at lookup. +- **Bootstrap replay** changes observable ordering; own flag, replay-timing + specs, staged rollout. +- **Schema generation** adds a build step; checked-in artifacts + staleness CI. + +## 12. Success criteria 1. APS creatives render on a reference page in each configured flow (SSAT, - Prebid adapter, page-bids), proven by browser specs and by disposition - telemetry from a staged deployment. -2. Every failure point in section 2 maps to a distinct, observable signal - (server log, telemetry row, or beacon reason). + Prebid adapter, page-bids), proven hermetically in CI and by the staged + smoke suite against real GAM line items. +2. Every failure point in section 2 maps to a distinct observable signal, and + **diagnostic mode** yields the failing reason from one page load; production + telemetry meets the stated SLO (5.3). 3. `eslint` boundary rules pass with zero exceptions; no integration imports - another integration; `core`/`kernel` imports no integration. + another integration; stateful sharing goes through the versioned ABI. 4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. 5. Trace counts are per-impression (no double counting), and orphaned-slot recovery is covered by a non-vacuous test. -6. The only TSJS-owned global is `window.tsjs`; no expando properties on GPT - slots, GPT functions, or `pbjs`; the dead `__tsRenderGeneration` / - `__tsRenderBid` writes are gone. -7. The synchronous bundle is no larger than today's (target: smaller), and the - reference-page time-from-bids-script-to-first-`display()` does not regress. +6. The only TSJS-owned global is `window.tsjs` (public globals only inside + their announced compatibility window); no expandos on GPT slots, GPT + functions, or `pbjs`. +7. Per-bundle raw/gzip/Brotli sizes are at or below the checked-in baseline + within stated tolerance, and the percentile-based + bids-script-to-first-`display()` assertion does not regress; server-side + per-request bundle concatenation/hashing is precomputed. 8. No existing warning is lost: every issue-surfacing condition logs at `warn` - or above in the console, with the same reason code the beacon carries. -9. TypeScript and the dev toolchain are on latest stable (with the new - strictness flags enabled), `prebid.js`'s npm pin matches the documented - deployed bundle version, and the monthly review policy is in CI docs. + or above with the same reason code the beacon carries. +9. TypeScript and the dev toolchain are on latest stable with the new + strictness flags; `prebid.js`'s npm pin matches the documented deployed + bundle version; the monthly review policy is in CI docs. +10. Rolling-deploy cache tests pass: a `?v=A` request never caches bytes other + than `A`. -## 12. Open questions +## 13. Open questions 1. Is a mediator configured in the affected production deployment? (Decides whether A1 is the primary cause or a latent one.) -2. What share of live APS demand is `tagtype: "script"`? (Decides how urgent - the `allow_script_creatives` enablement guidance is.) -3. Should the direct-render fallback (6.3) ever become default-on for - publishers without GAM line items for `hb_bidder=aps`? +2. What share of live APS demand is `tagtype: "script"`? +3. Should `client_render_fallback` ever become default-on for publishers + without GAM line items for `hb_bidder=aps`? 4. Is PR #997 the intended restoration of the lost #922 attribution core, or should the original be re-merged? -5. Beacon endpoint naming and retention: `/_ts/client-events` vs folding into - the existing telemetry namespace. +5. Does any deployment serve descriptors for an origin whose config disables + APS (decides whether the renderer route becomes config-independent, 6.6)? +6. Datasource naming/retention for client events, and whether Axum/Cloudflare/ + Spin get real sinks or keep accept-count-drop. From 29ce6ce0355072527c2c27c58a870b1e0408029c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:59:44 -0700 Subject: [PATCH 543/844] Revise design spec for the second review round Settles the correlation gap by minting the trace server-side for the initial navigation and echoing client-minted traces through the page-bids request, adds the per-attempt acknowledgement-nonce protocol so render_accepted is an authenticated kernel-observed event, renames observations to what the browser can actually see and terminates APS at render_accepted, splits nurl and burl into independent idempotent transitions with a declared billing policy, restricts fallback to an attributed terminal empty GPT event via a per-slot request-cycle protocol, moves the content hash into the asset pathname with retained artifacts and 410-on-unknown, reorders the migration so asset identity and the kernel ABI precede the beacon, drops the deal-priority rule the bid model cannot express, replaces accept_sizes with request-what- you-accept, minimizes the beacon payload with id kinds and a signed diagnostic capability, makes the renderer document route unconditional and versioned with a two-stage ack, bounds SafeFrame traversal to a parent-chain walk, fixes the tsjs.que and TypeScript-5.9 facts, and names the concrete numbers the review asked for. --- ...s-render-fix-and-tsjs-resilience-design.md | 1081 +++++++++-------- 1 file changed, 556 insertions(+), 525 deletions(-) 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 6415d36d8..144b7ca36 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 @@ -1,16 +1,17 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 2 — reworked after design review (review verdict: - request changes). The five architectural contracts the review required are - settled in section 4. +- **Status:** revision 3 — reworked after the second design review round. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `541298695` — the full merged state (everything merged from `main` plus every rc-only merge), not just the delta pending against `main`. - **Inputs:** three code audits performed against this baseline (APS end-to-end - trace, TSJS architecture audit, GPT integration map); design review of - revision 1; open issues #926, #941, #944, #962, #964, #977, #983, #989, - #993; open PR #997. + trace, TSJS architecture audit, GPT integration map); design reviews of + revisions 1 and 2; open issues #926, #941, #944, #962, #964, #977, #983, + #989, #993; open PR #997. +- **Terminology:** the per-refresh counter is `refresh_gen` everywhere in this + document (revision 2 used `render_gen` in some places; that name is + retired). --- @@ -56,8 +57,6 @@ SPA `/_ts/page-bids` re-auction, (d) direct `/auction` via `tsjs.requestAds`. Only flow (d) — the demo path nobody runs in production — can render an APS descriptor without GAM's cooperation. -The failure points, ranked by likelihood and blast radius: - ### 2.1 Admission: APS bids are eliminated before they can win | # | Failure | Where | @@ -105,253 +104,291 @@ deciding the winner. The audit's key facts: 1. **The bundle's handoff and initial-load code is dead in production.** The bootstrap installs its wrappers first and sets the same sentinels the bundle checks; ~200 lines of the TypeScript the test suite exercises most heavily - never run on a real page. A fix landed only in `gpt/index.ts` has no - production effect. + never run on a real page. 2. **The slot handoff can alias a publisher's new div to a GPT slot bound to a dead element** — and the orphan-recovery watcher built to repair exactly that was **lost in the #922 merge** (`0dc9b19a9` resolved `gpt/index.ts` to the rc - side). `updateRender` (one impression, one row) now has no production - caller, `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every + side). `updateRender` now has no production caller, + `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every bridge-served impression double-counts in the trace. Open PR #997 appears to - be the reworked replacement; restoring this is a correctness prerequisite, - not a refactor. -3. **TS refreshes never pass `changeCorrelator: false`**, so every TS-driven - refresh starts a new GAM page-view correlator — silently changing roadblock, - competitive-exclusion, and frequency-capping behavior. + be the reworked replacement. +3. **TS refreshes never pass `changeCorrelator: false`**, silently changing + roadblock, competitive-exclusion, and frequency-capping behavior. 4. **`enableSingleRequest()` is called blind** after the publisher's own - `enableServices()` has almost always run (post-#945 deferral), so SRA intent - is asserted but not real — and on pages where TS wins the race, it forces - SRA onto publishers who chose otherwise. -5. Responsive resolution is a DOM-element-selection ladder (not GPT size - mapping); ambiguity silently skips the slot for the whole pass. -6. Three independent wrappers on `pubads().refresh` (bootstrap, bundle, prebid) - coordinate via window-global booleans that async wrappers observe already - reset. + `enableServices()` has almost always run. +5. Responsive resolution is a DOM-element-selection ladder; ambiguity silently + skips the slot for the whole pass. +6. Three independent wrappers on `pubads().refresh` coordinate via + window-global booleans that async wrappers observe already reset. +7. **GPT refresh is asynchronous and offers no request-cancellation + primitive** (`gpt/index.ts:1080`): once a refresh is issued, a response can + arrive arbitrarily late. Any fallback design must treat an issued GPT + request as uncancelable. --- -## 4. Design gates — the five contracts settled before implementation - -The design review identified five architectural contracts every later section -depends on. This revision settles them; changing any of these later reopens the -review. +## 4. Design gates — the five contracts -### G1 — Trace identity and event envelope +### G1 — Trace identity and correlation **The client-visible auction id must never be ingested.** It is EC-derived by -construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists), and server -telemetry already uses a deliberately unrelated fresh UUID -(`telemetry.rs:94-97`), so a join on it is both a privacy violation and -impossible. - -Contract: - -- A **`trace_id`** is minted client-side per navigation: 128-bit CSPRNG - (`crypto.getRandomValues`), hex-encoded, held in `PageSession`, never - persisted, never derived from any identity. -- Every event carries the envelope `{trace_id, nav_gen, render_gen, seq}`. - `seq` is a per-trace monotonic counter (ordering + deduplication); - `nav_gen`/`render_gen` scope events to a navigation and a refresh cycle, so a - batch that spans SPA navigations remains attributable per event, not per - batch. -- The **server** stamps the same `trace_id` into its own telemetry rows by - reading it from the beacon, never the reverse: server auction rows keep their - independent telemetry UUID, and correlation happens only where the client - reported a `trace_id` alongside the events it observed. Failures with no - winning bid therefore still have a trace: the trace is client-born, not - auction-born. -- **Sampling is sticky per trace** (decided once at trace mint, recorded in the - envelope), never per event — a sampled trace is complete or absent. +construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists). Server +telemetry uses an independent fresh UUID (`telemetry.rs:94-97`) — and, decisive +for the design: **initial-HTML auction telemetry is emitted before page +JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so a +client-minted trace can never retroactively join it. + +Contract — correlation is minted by whoever acts first: + +- **Initial navigation (`nav_gen = 0`):** the **server** mints a + `trace_id` — 128-bit CSPRNG, hex (`^[0-9a-f]{32}$`), derived from nothing — + per HTML response, writes it into that response's auction telemetry rows at + emit time, and injects it into the page as a `tsjs` boot field alongside the + sampling decision. The client's `NavigationSession` adopts it. It is a new + value, never `AuctionRequest.id`. +- **SPA navigations (`nav_gen > 0`):** the **client** mints the `trace_id` for + the navigation and sends it in the `/_ts/page-bids` request body; the server + records it contemporaneously in that auction's telemetry rows. No + retroactive joining anywhere. +- **Envelope:** every event carries + `{trace_id, sampled, nav_gen, refresh_gen, seq}` — per event, not per batch, + so a transport batch may span navigations without ambiguity. `seq` is a + per-trace monotonic counter for ordering and deduplication. +- **Sampling** is decided once per trace (server-decided for `nav_gen 0`, + client-decided from the injected rate for later navigations) and recorded in + the envelope; a sampled trace is complete or absent. ### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID -**The PBS Cache contract stays untouched.** Today `hb_adid` deliberately -prefers the Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache -coordinates and the bridge's cache fetch assume `?uuid=` -(`publisher.rs:3450`, `gpt/index.ts:1700`) — that is the Prebid Universal -Creative's documented contract. Revision 1's "token for every SSAT bid" would -have broken every cache-backed render and is withdrawn. +**The PBS Cache contract stays untouched.** `hb_adid` deliberately prefers the +Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache coordinates +and the bridge's cache fetch assume `?uuid=` (`publisher.rs:3450`, +`gpt/index.ts:1700`). Contract: -- Bids with a cache id: `hb_adid` = cache UUID, exactly as today. -- Bids with markup and no cache id: `hb_adid` = existing fallback chain - (`ad_id`, then bid id), exactly as today. +- Bids with a cache id: `hb_adid` = cache UUID, exactly as today. Bids with + markup and no cache id: existing fallback chain, exactly as today. - **Renderer-only bids (APS): `hb_adid` = a server-minted render token**, - format `^[a-z0-9]{12}$` (12 chars exactly), CSPRNG-generated with collision - retry within the auction, unique across slots and auctions, one-time - consumption in the bridge registry, TTL-bounded. Twelve characters sit well - inside GAM's documented 40-character targeting-value limit. -- The client-side Prebid adapter path keeps Prebid's generated `adId` (that is - Prebid's own contract); both paths register into one bridge registry keyed by - whichever id that path will observe. + format `^[a-z0-9]{12}$` (12 chars exactly, 36¹² ≈ 4.7 × 10¹⁸ values), + CSPRNG-generated. Collision handling is honest about its scope: retry on + collision **within the minting auction** (the only scope the server can + check without storage); cross-auction uniqueness is **probabilistic**, with + the birthday bound documented (at 10⁶ live tokens the collision probability + is ~10⁻⁷) and made harmless by scoping: the client registry keys tokens per + `(trace_id, nav_gen)`, so a cross-page collision cannot cross wires. +- Token lifecycle: TTL **15 minutes** from mint, one-time consumption in the + bridge registry, invalidated by navigation. +- The client-side Prebid adapter path keeps Prebid's generated `adId`; both + paths register into one bridge registry keyed by whichever id that path + observes. - Regression tests: non-APS cache-backed bids keep byte-identical `hb_adid` - and cache coordinates; the token property test asserts `^[a-z0-9]{12}$`, - uniqueness, one-time consumption, and TTL expiry. + and cache coordinates. ### G3 — Runtime ABI: how code shares state under the IIFE build Every entry point is built as a self-contained IIFE with dynamic imports inlined (`build-all.mjs:46`), and the server concatenates already-closed IIFEs -(`bundle.rs:23`). **A module `import` therefore never shares state across -bundles** — an `aps/index.ts` alone would just mint another private copy. This -is already a live defect beyond APS: `core/context.ts:11` holds a private -context-provider `Map` while `permutive/index.ts:102` registers into its own -independently bundled copy. +(`bundle.rs:23`) — a module `import` never shares state across bundles. Live +proof: `core/context.ts:11` holds a private context-provider `Map` while +`permutive/index.ts:102` registers into its own copy. Contract — **a versioned registration ABI on `window.tsjs._internal`**: - The kernel ships **only** in `tsjs-core` (always first in the concatenated unified bundle) and publishes `tsjs._internal = { abi: 1, registry }` exactly once, guarded by a window-level sentinel. -- All **stateful** services (slot registry, render state machine, APS renderer - registry, context providers, event bus, beacon queue) exist once, owned by - the kernel, and are reached **only** through - `tsjs._internal.registry.get(name, minVersion)` at call time — never through - imported module state. Pure stateless helpers may still be imported and - inlined freely. -- Deferred bundles (prebid) and later scripts interact through the same ABI; - `abi` majors are checked at lookup and a mismatch is a logged, telemetered - refusal, not a silent no-op. -- The alternative (single module graph / shared chunks emitted by the build and - loaded as real modules) is recorded as the long-term option; the ABI is - chosen now because it works under today's concatenation and deferred - loading without changing the delivery pipeline. -- This gate precedes and unblocks: the event bus, the slot registry, the - install registry, `PageSession`, the messaging service, and fixes the - context-provider split as its first proof. - -### G4 — Exactly-once render state machine, and honest success semantics - -**Absence of a bridge request is not proof GAM failed** — GAM may legitimately -serve a different, higher-priority creative without ever invoking the TS -bridge. And **`renderer-ready` means Amazon's runner script loaded, not that an -ad painted** (`aps.rs:105`); the current direct renderer returns `true` -immediately while the real outcome settles asynchronously for up to ten -seconds (`render.ts:318`). - -Contract: - -- One render state machine per placement instance, keyed by - `(trace_id, nav_gen, slot, refresh_gen, render_token)`, with exactly-once - terminal transition. States: - `targeting_set → bridge_claimed | gam_filled_other | fallback_running → -render_accepted → render_confirmed | render_failed(reason)`. -- **The fallback never replaces a nonempty GAM render.** Any bridge claim or - nonempty `slotRenderEnded` cancels a pending fallback; an **empty** - `slotRenderEnded` may trigger it; navigation, refresh, handoff, and slot - destruction invalidate stale attempts. -- The renderer API becomes awaitable with cancellation and an explicit - terminal reason; no fire-and-forget `true`. -- Event taxonomy replaces the single `render_ok`: - - `bridge_response_sent` — the bridge handed a payload to the PUC; - - `render_accepted` — the renderer document loaded Amazon's runner - (today's "ready"); - - `render_confirmed` — observed evidence of a paint (nonempty - `slotRenderEnded` for GAM fills; for the sandboxed renderer, iframe - load + non-collapsed geometry where observable). -- **Billing/win callbacks fire no earlier than `render_accepted`, and - `render_confirmed` is the only state reported as success.** Where APS - provides no paint acknowledgement, the design says so plainly: telemetry can - distinguish "runner loaded" from "nothing loaded," but **cannot distinguish - a painted ad from a blank one** inside the opaque frame — rows terminate at - `render_accepted` and are labeled as such, not upgraded. +- **Construction ownership:** the kernel constructs and registers the core + service instances (event bus, beacon queue, session objects, slot registry, + render state machine) during its own boot; integrations construct only + integration-scoped services and register them during their `install()`. +- All **stateful** services are reached only through + `tsjs._internal.registry.get(name, minVersion)` at call time. Pure stateless + helpers may be imported and inlined freely. +- `abi` majors are checked at lookup; a mismatch is a logged, telemetered + refusal, not a silent no-op. Mixed-version delivery (old deferred bundle, + new core) is a tested scenario, not an accident. +- The single-module-graph build is recorded as the successor option behind the + same ABI surface. + +### G4 — Render lifecycle: cycles, acknowledgements, and honest states + +Four sub-contracts, each fixing a hole the reviews identified. + +**G4a — GPT request-cycle protocol.** `slotRenderEnded` identifies a slot, not +a request, and the bridge currently reads live `window.tsjs.bids` +(`gpt/index.ts:1606`) while the generation snapshots are dead writes +(`gpt/index.ts:1085`). Contract: every TS-issued `display()`/`refresh()` opens +a **cycle** `(slot, refresh_gen)` pushed onto a per-slot pending-cycle queue; +`slotRequested` confirms it; GPT fires slot events in order per slot, so +`slotRenderEnded` is attributed to the oldest confirmed pending cycle for the +slot. Each bridge token and each render attempt binds to exactly one cycle. If +attribution is ambiguous (overlapping cycles the queue cannot separate, or an +event with no pending cycle), the state machine for that slot **fails closed**: +no fallback, `render_fail{cycle_unattributable}`, console warning. + +**G4b — Acknowledgement path.** Today the renderer document posts ready only +to its immediate parent (`aps.rs:105`); in the PUC path that parent is the +nested renderer frame, which resolves a local promise (`render.ts:423`) the +top-level kernel cannot observe — and callbacks currently fire right after the +bridge posts its response (`gpt/index.ts:1572`, `:1620`). Contract: the bridge +response carries a **per-attempt CSPRNG acknowledgement nonce**; the dynamic +renderer posts versioned `render_accepted` / `render_failed{reason}` messages +**to the kernel** (top window), carrying the nonce; the kernel validates +source ownership, nonce, token, `nav_gen`, and `refresh_gen` before any state +transition or callback. This protocol is pinned by tests for all three flows: +SSAT, client-Prebid, and nested SafeFrame. + +**G4c — Honest observation names.** The browser cannot see inside an opaque +APS frame, and the iframe's geometry is assigned by our own renderer — it +proves nothing about content. A nonempty `slotRenderEnded` proves GAM +delivered a creative container, not that the nested runner painted. The state +machine therefore records observations under accurate names — +`gam_nonempty`, `gam_empty`, `renderer_document_loaded`, `runner_loaded`, +`runner_failed` — and **APS attempts terminate at `render_accepted`** +(= authenticated `runner_loaded` ack) unless Amazon provides a real completion +acknowledgement. `render_confirmed` exists only for paths with same-origin +observable content (inline adm frames TS itself writes); it is never derived +from geometry or from PUC container delivery. Tests: accepted-but-blank, and +nonempty-`slotRenderEnded`-before-bridge-claim. + +**G4d — `nurl`/`burl` are separate business events.** OpenRTB 2.6 +distinguishes them: `nurl` is the win notice (implies neither delivery nor +billability); `burl` is the billable-event notice under exchange policy. +Today both fire together (`gpt/index.ts:459`). Contract — independent, +idempotent transitions: + +1. winner selection → fire `nurl`; +2. `render_accepted` (authenticated) → fire `burl` — this is the **declared + commercial policy** for APS given no paint acknowledgement exists, recorded + here explicitly rather than implied; +3. terminal failure after acceptance → no un-firing; the row is labeled + `billed_then_failed` so the policy's cost is measurable. + +**G4e — Fallback trigger.** GPT offers no cancellation (section 3.7), so a +timeout can race a late fill that arrives after a fallback has rendered and +billed. Contract: the opt-in direct fallback +(`[auction].client_render_fallback = "renderer"`) renders **only after an +explicit terminal empty event for the bound cycle** (`gam_empty` from G4a +attribution). A timeout emits diagnostics (`render_fail{bridge_claim_timeout}`) +and **never renders**. For publisher-owned (adopted) slots, fallback is +disabled entirely. TS-owned-slot timeout rendering is admitted only as a +possible future extension that must first destroy the slot to retire the +request, and is out of scope here. ### G5 — Deployment contracts - **Config rollback:** every new auction/config field is default-valued and - **omitted from serialization at its default** (`AuctionConfig` denies - unknown fields, `auction_config_types.rs:7`), so blobs written by a new - binary remain readable by the previous one unless an operator opts in. -- **Ingest routing:** the beacon route exists in **all four adapters** - (Fastly, Axum, Cloudflare, Spin) as an early, EC-free, filter-free route. - Only Fastly has a real telemetry sink today; the no-sink behavior elsewhere - is "accept, count, drop" — defined, not accidental. -- **Storage:** a new dedicated datasource for client events (the existing - auction datasource cannot hold the shape without migration); schema, - retention, and sampling documented with the datasource definition. -- **Asset content-addressing is fixed, not assumed.** Script URLs embed a - content hash but the handler ignores the query and serves current registry - bytes from the path alone (`tsjs.rs:3`, `publisher.rs:294`) — during a - rolling deploy an old `?v=A` request can receive bytes `B` and cache them - under `A`. The handler must validate the requested hash and serve the - matching immutable artifact (or answer with the current hash's redirect); - rolling-deploy cache tests pin this. -- **Phase ordering** follows section 9; every phase ships behind a feature - flag with canary thresholds and rollback criteria. + omitted from serialization at its default (`auction_config_types.rs:7` + denies unknown fields), so blobs written by a new binary remain readable by + the previous one unless an operator opts in. +- **Asset identity is path-based and retained.** A query hash the handler + ignores (`tsjs.rs:3`, `publisher.rs:294`) is not content addressing, and + redirecting an old hash to current bytes just executes new code under old + HTML, bootstrap flags, and ABI expectations. Contract: the content hash + moves into the **pathname** (`/static/tsjs//.js`); the server + serves through a hash→bytes manifest that **retains prior artifacts** beyond + the maximum HTML cache lifetime plus the deferred-load window (retention + floor: 7 days); `Cache-Control: immutable` only on exact hash matches; + unknown hashes answer `410 Gone` with `no-store` — never a redirect to + different bytes. Precomputed concatenations are keyed by the **ordered + module-ID vector** (order affects side effects), not the set. +- **Ingest routing:** the beacon route exists in all four adapters (Fastly, + Axum, Cloudflare, Spin) as an early, EC-free, filter-free route. Only Fastly + has a real sink today; the others accept-count-drop by explicit contract. +- **Storage:** a new dedicated datasource named `ts_client_events`; retention + **30 days**; production sampling default **10%** (operator-tunable); + schema versioned with the event enum. +- **Phase ordering** is section 8's; each phase ships behind a feature flag + with the named canary thresholds and rollback criteria in section 8. --- ## 5. Workstream 1 — Observability -### 5.1 Client disposition beacon +### 5.1 Event payload — minimized by design -A kernel module batches disposition events to `POST /_ts/client-events`: +High-cardinality identifiers stay out of the beacon: no raw `hb_adid`, no raw +Prebid `adId`, no free-form slot strings. ``` -{ v: 1, trace: {trace_id, sampled}, events: [ - { seq, nav_gen, render_gen, t: "bid_received", slot, bidder, source } - { seq, nav_gen, render_gen, t: "targeting_set", slot, hbAdid } - { seq, nav_gen, render_gen, t: "bridge_request", slot, adId, matched } - { seq, nav_gen, render_gen, t: "bridge_response_sent", slot, source } - { seq, nav_gen, render_gen, t: "render_attempt", slot, source } - { seq, nav_gen, render_gen, t: "render_accepted", slot, source } - { seq, nav_gen, render_gen, t: "render_confirmed", slot, source } - { seq, nav_gen, render_gen, t: "render_fail", slot, source, reason } +{ v: 1, events: [ + { trace_id, sampled, nav_gen, refresh_gen, seq, + t: "bid_received" | "targeting_set" | "bridge_request" | + "bridge_response_sent" | "render_attempt" | "render_accepted" | + "render_confirmed" | "render_fail", + slot, // configured slot id if in the injected slot set, else "s" + id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" + matched, // bridge_request only: token/id equality result + source, // "renderer" | "adm" | "pbs-cache" | "gam" + reason } // render_fail only: closed enum below ] } ``` -- **Transport:** `fetch(..., {keepalive: true, credentials: "omit"})` is the - primary transport, because `sendBeacon` always sends credentials and its - `true` return only means "queued," not "received." `sendBeacon` remains the - documented last-resort fallback on `pagehide` where keepalive is - unavailable, and the ingest handler ignores credentials in all cases. -- **Batching:** flush on `visibilitychange`/`pagehide` and every 5 s; each - event self-describes its navigation via the envelope, so batches spanning - SPA navigations stay attributable. -- **Reasons are a closed enum**, structurally serialized (never interpolated - into log lines): `renderer_no_ready`, `descriptor_invalid`, - `bridge_id_mismatch`, `gam_empty`, `gam_filled_other`, `no_render_source`, - `slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`, - `fallback_cancelled`, `timeout`. `renderer_no_ready` (not - `renderer_endpoint_404`) is deliberate: the opaque iframe cannot read an - HTTP status, so the observable fact is "no ready message before timeout." - -### 5.2 Ingest contract - -- Registered in all four adapters before auth/EC/filters (G5); same-origin - enforced via `Origin`/`Sec-Fetch-Site` checks; hard caps on body bytes, - event count, and string lengths **enforced before parsing or logging**; - malformed rows dropped and counted; per-IP rate limiting; responds - `204 Cache-Control: no-store`; touches no KV and mints no identity. -- Server logs a bounded, structured summary per batch; the Fastly sink writes - to the new datasource (G5); other adapters count-and-drop until a sink - exists. - -### 5.3 Two modes, honestly separated - -A sampled, best-effort beacon **cannot** guarantee a complete diagnosis from -one page load — so the design stops claiming it: - -- **Production telemetry:** sticky-sampled traces, SLO "a delivery failure - mode occurring on ≥ N% of impressions is visible in the datasource within - one hour." -- **Diagnostic mode:** explicitly enabled (tester cookie / query flag), - unsampled, full event stream plus console mirroring — this is the "one page - load tells you which failure fired" tool. - -### 5.4 Server-side drop-reason surfacing - -- Emit a bounded structured summary **whenever any bid is dropped** (not only - when zero survive): per-slot reason counts, capped. -- Add `drop_reasons` to auction telemetry rows. -- The initial-HTML `ts-debug` comment gains the drop summary; `/_ts/page-bids` - returns JSON and **cannot carry an HTML comment**, so it gains a gated - structured `debug` field instead, enabled by the same tester gate. -- Startup validation warnings: APS enabled while `allow_script_creatives = -false` ("script-type APS demand will be dropped"); any direct provider - configured alongside a mediator without the merge strategy of 6.1 ("provider - bids cannot win as configured"). +- Every stored string is either a member of a server-known allowlist (slot ids + from the injected config, enum members) or a bounded ordinal — nothing free + .form is persisted. +- Reason enum: `renderer_document_no_load`, `runner_no_load`, `runner_failed`, + `descriptor_invalid`, `bridge_id_mismatch`, `cycle_unattributable`, + `bridge_claim_timeout`, `gam_empty`, `no_render_source`, `slot_unresolved`, + `gpt_absent`, `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, + `abi_mismatch`. (`renderer_no_ready` from revision 2 is split by the G4b/6.6 + protocol into document-load vs runner-load failures.) + +### 5.2 Transport + +`fetch(..., {keepalive: true, credentials: "omit"})` primary; `sendBeacon` as +the documented last-resort `pagehide` fallback (credentialed by platform +design, and its `true` means queued, not received — the handler ignores +credentials either way). Flush on `visibilitychange`/`pagehide` and every 5 s. + +### 5.3 Ingest wire contract (numeric, complete) + +- Route: `POST /_ts/client-events`, registered in all four adapters before + auth/EC/filters. Content type: `application/json` only (no + `Content-Encoding`; compressed bodies rejected). Responds + `204 Cache-Control: no-store`. +- Limits enforced **before parse or log**: body ≤ **16 KiB**; ≤ **64** events + per batch; any string field ≤ **64** chars; `trace_id` must match + `^[0-9a-f]{32}$`; `nav_gen`/`refresh_gen`/`seq` are integers in + `[0, 2³¹)`. Violation → `204` (accepted-and-dropped) + abuse counter; the + endpoint never echoes input. +- Same-origin enforcement: `Sec-Fetch-Site: same-origin` when present; + otherwise `Origin` must match the serving host; **absent both → reject** + (drop-and-count). All strings are structurally serialized (never + interpolated into log lines). +- Client IP for rate limiting is derived per adapter from its documented + trusted source (Fastly: the platform client IP; Axum: configured trusted + proxy header; Cloudflare/Spin: platform equivalents). Rate limiting uses the + platform limiter where one exists (Fastly); portable adapters ship a + best-effort in-memory limiter and the policy is **fail-open with an abuse + counter** (dropping telemetry must never block ad delivery). +- **Diagnostic mode is a server-injected capability, not a query flag.** The + tester gate (cookie) is evaluated server-side at HTML render; the page + receives a short-lived signed capability token (HMAC over + `trace_id + expiry`, ≤ 15 minutes) which the client echoes in the batch. + The ingest handler verifies the signature — this works with + `credentials: "omit"` because the capability travels in the payload, and a + public query flag alone can never switch a session to unsampled. + +### 5.4 Two modes, honestly separated + +- **Production telemetry:** sticky-sampled (default 10%), SLO: **a delivery + failure mode affecting ≥ 1% of impressions is visible in `ts_client_events` + within one hour**. +- **Diagnostic mode:** capability-gated, unsampled, full event stream plus + console mirroring — the "one page load names the failing reason" tool. + +### 5.5 Server-side drop-reason surfacing + +- Emit a bounded structured summary **whenever any bid is dropped** (per-slot + reason counts, capped), not only when zero survive. +- Add `drop_reasons` to auction telemetry rows; add the drop summary to the + initial-HTML `ts-debug` comment; `/_ts/page-bids` (JSON) gains a gated + structured `debug` field under the same tester gate. +- Startup validation warnings: APS enabled while + `allow_script_creatives = false`; any direct provider configured alongside a + mediator without the 6.1 merge strategy. --- @@ -359,124 +396,128 @@ false` ("script-type APS demand will be dropped"); any direct provider ### 6.1 Mediation: opt-in merge, `mediator_only` stays the default -The configured contract today is explicit — the mediator is the final -decision-maker (`auction_config_types.rs:48`) — and changing that default -silently would be an economic breaking change with a rollback hazard. So: - - `[auction].winner_selection = "mediator_only"` (default, today's behavior, - now explicit) or `"merge_highest_cpm"` (opt-in). The field is omitted from - serialized blobs at its default (G5). -- `merge_highest_cpm` semantics, defined up front: comparison in the auction's - decoded CPM currency; slot floors apply to both populations; a bid present - in both (a provider bid the mediator also returned) counts once, by - provenance `mediator`; deals outrank open bids regardless of CPM; ties break - to the mediator; a mediator timeout degrades to direct-provider selection - (today's short-circuit) and is reported as such. -- **One candidate-selection helper serves both mediation lifecycles** — the - ordinary/page-bids path (`orchestrator.rs:412-431`) and the initial-SSAT - split dispatch/collect path (`orchestrator.rs:1320`) — with tests for each. -- A **selection report** (`winner_source`, `mediator_superseded` counts) is - emitted separately from the delivery-conversion drop reasons, so "lost the - merge" is never conflated with "failed to serialize." - -### 6.2 Dimensions: exact membership stays; flexibility is operator-declared - -Revision 1's containment rule ("never larger on either axis") would still -reject its own motivating example (a 300×600 answer on a 300×250/728×90 slot) -while admitting pathological 1×1 sizes — withdrawn. Instead: - -- Exact size membership (`aps.rs:657-668`) remains the default; it is - consistent with discrete GAM slot formats. -- An operator may declare flexibility per slot: - `accept_sizes = [[300, 600], …]` (an explicit allow-list of additional - creative sizes, with documentation that GAM line items must accept them) — - no inference, no aspect heuristics in v1. Admitted alternate sizes set - `hb_size` targeting (set and cleared with the other `hb_*` keys) and the - served size is reported in the selection report. + now explicit) or `"merge_highest_cpm"` (opt-in); omitted from serialized + blobs at the default (G5). +- `merge_highest_cpm` semantics: comparison in decoded CPM; **currency + mismatch is a rejection** (the mismatched bid is dropped with a selection + reason; no conversion in v1); slot floors apply to both populations; ties + break to the mediator; a mediator timeout degrades to direct-provider + selection and is reported. +- **Deduplication key:** the server constructs the mediator's input, so it + records provenance at forwarding time — `(provider_name, upstream_bid_id)` + per candidate — and carries a provenance map keyed by the id it sent. + A mediator bid whose id maps back to a forwarded candidate counts once, as + provenance `mediator`. A mediator bid whose id was **transformed beyond the + map** is treated as distinct mediator demand (documented limitation). +- **Deal priority is out of scope for v1.** The internal `Bid` + (`types.rs:231`) carries no deal identity; inventing a priority rule the + model cannot express would be fiction. Extending the bid model with + `deal_id`/deal type and a deal-first rule is recorded as follow-up work; + until then deals compete by CPM like everything else and the limitation is + documented in the config reference. +- One candidate-selection helper serves **both** mediation lifecycles + (ordinary/page-bids, `orchestrator.rs:412-431`; initial-SSAT split + dispatch/collect, `orchestrator.rs:1320`), with tests for each. +- A **selection report** (`winner_source`, `mediator_superseded`, + `currency_rejected`, dedup hits) is emitted separately from + delivery-conversion drop reasons. + +### 6.2 Dimensions: the contract is "request what you accept" + +Revision 2's operator `accept_sizes` allow-list is withdrawn on the review's +sharper observation: if an alternate size is acceptable, it belongs in the +slot's **requested formats** — APS should be asked for it. Accepting an +unrequested response size would conceal an upstream protocol violation. + +- Exact size membership (`aps.rs:657-668`) remains the admission rule, + unchanged. +- The fix is configuration plus visibility: the drop summary (5.5) names the + rejected size per slot (`invalid_dimensions{300x600}`), so an operator sees + exactly which format to add to the slot's `formats` if they want that + demand. Documentation gains a "sizing your slots for APS" section. +- No `hb_size` key, no admission relaxation, no new config. ### 6.3 Script creatives Keep the secure default (`allow_script_creatives = false`) but make the -consequence loud (5.4) and document the enablement path for TAM-heavy -publishers. The renderer sandbox already isolates script tag types; this is a -policy toggle, not new machinery. +consequence loud (5.5) and document the enablement path for TAM-heavy +publishers. ### 6.4 Render identity -Implemented exactly as G2: cache UUID untouched, render token only for -renderer-only bids, one registry, token property tests -(`^[a-z0-9]{12}$`, CSPRNG, collision retry, TTL, one-time consumption, -cross-slot/auction uniqueness), and a non-APS cache-path regression test. +Implemented exactly as G2 (token scope, format, TTL, per-navigation registry +keying, one-time consumption, cache-path regression tests). ### 6.5 Fallback rendering -Implemented exactly as G4: opt-in -(`[auction].client_render_fallback = "renderer"`), driven by the render state -machine, triggered only by an **empty** GAM render or a bridge-claim timeout -with no fill evidence, cancelled by any bridge claim or nonempty render, -invalidated by navigation/refresh/destruction, and reported with the G4 event -taxonomy. The direct renderer is converted to an awaitable API first; the -fallback lands only after that conversion. - -### 6.6 Renderer endpoint - -- Document the topology: within one deployment the renderer route and the APS - provider share the same config gate (`aps.rs:1224`, `:1244`), so "server - emits descriptors but lacks the route" is a **cross-deployment or stale-CDN - problem**, and a 401 most plausibly comes from broad `[[handlers]]` auth - patterns matching `/integrations/*` before route dispatch. -- Therefore: validate at startup that no configured auth handler pattern - covers `/integrations/aps/renderer`; make the static renderer document - config-independent **iff** the deployment serves multiple origins from one - config (recorded as an open question with the operator); version the - renderer document and define its cache headers. -- The client reports `renderer_no_ready` (5.1) — no status probe is added, - because a probe would violate the no-new-critical-path-request budget. -- CSP audit (C6) unchanged from revision 1: extend `APS_RENDERER_CSP` only - with sources observed in real Amazon traffic, each justified in a comment - and covered by the browser spec. +Implemented exactly as G4e: renders only on an attributed terminal +`gam_empty`; timeouts are diagnostics-only; disabled for adopted slots. The +direct renderer is converted to an awaitable API with cancellation and a +terminal reason first; the fallback lands only after that conversion. + +### 6.6 Renderer endpoint — unconditional, versioned, observable + +Topology is resolved now rather than left conditional: + +- **The static renderer document route registers unconditionally in every + adapter.** It contains no configuration, no secrets, and validates its input + client-side; serving it cannot leak anything, and conditional registration + is exactly what created the silent cross-deployment failure class. (The APS + _provider_ stays config-gated; only the static document is unconditional.) +- The document is **versioned in its path** + (`/integrations/aps/renderer/v1`) and served `Cache-Control: no-store`; + descriptor compatibility across N/N−1 is guaranteed by the outer-tolerant + validation of 6.7. The client pins the version it targets. +- Startup validation fails loudly if any configured auth handler pattern + covers `/integrations/aps/renderer`. +- **Two-stage acknowledgement (with G4b):** the document first posts an + authenticated `document_loaded` (proving route + auth + CSP allowed the + document itself), then the separate runner-load result. This splits the old + blind timeout into `renderer_document_no_load` (route/auth/stale-CDN/network) + vs `runner_no_load` / `runner_failed` (Amazon script or CSP) — distinct + signals, as the success criteria require. +- Server-side: route status/version counters (requests, unknown-version, + auth-blocked) join the telemetry rows. +- **CSP changes ship report-only first** (`Content-Security-Policy-Report-Only` + canary with a bounded report endpoint), then enforce; each added source is + justified in a comment and covered by the browser spec. Tests cover broad + auth patterns, stale versions, and CSP failures on all adapters. ### 6.7 One descriptor schema — structural generation, semantic validators kept -- The wire truth is the **tagged enum** `BidRenderer` (the `type: "aps"` - discriminator lives there, not on `ApsRendererV1` — `types.rs:188-211`), so - the generated schema is the full tagged envelope. -- Generation lives in a **separate wire-schema crate** (or a host-side - xtask) — it cannot live in `trusted-server-js`'s build because core already - depends on that crate (`Cargo.toml:45`) and the reverse edge would be a - cycle. Generated TS artifacts are checked in; CI fails on staleness. -- Generation covers **structure only** (fields, types, discriminator, - version). The semantic security checks stay hand-written on both sides: - URL/origin policy, canonical base64, length bounds, the exact one-bid - envelope projection, cross-field equality. **Unknown-field tolerance applies - only to the outer versioned descriptor; the decoded AAX envelope remains an - exact projection** so `adm`, notification URLs, or sibling fields can never - slip through unexamined. -- A shared corpus — positive cases plus an adversarial set (extra fields, - wrong versions, oversized payloads, URL smuggling, non-canonical base64) — - runs through the Rust validator, the TS validator, and the inline renderer - document in CI. +- The wire truth is the tagged enum `BidRenderer` (discriminator lives there, + not on `ApsRendererV1` — `types.rs:188-211`); the generated schema is the + full tagged envelope. +- Generation lives in a **separate wire-schema crate** (or host-side xtask) — + core already depends on `trusted-server-js` (`Cargo.toml:45`), so the + reverse edge would be a cycle. Generated TS artifacts are checked in; CI + fails on staleness. +- Generation covers structure only; the semantic security checks stay + hand-written on both sides (URL/origin policy, canonical base64, length + bounds, the exact one-bid envelope projection, cross-field equality). + **Unknown-field tolerance applies only to the outer versioned descriptor; + the decoded AAX envelope remains an exact projection.** +- A shared positive + adversarial corpus runs through the Rust validator, the + TS validator, and the inline renderer document in CI. ### 6.8 Bridge hardening -- **Ownership proof stays source-first.** The bridge continues to resolve the - message source to a slot and only then compares that slot's expected id - (`gpt/index.ts:1599` order) — a MessageChannel port plus a token is not - ownership proof, because an inbound port has no pre-established slot - identity and the token is visible in `window.tsjs.bids`. For SafeFrame, - source resolution is extended to walk nested browsing contexts via - `window.frames` containment checks rather than DOM `querySelectorAll` only; - where the source is unresolvable the bridge refuses (with - `bridge_id_mismatch`) instead of trusting the token. -- Adversarial tests are part of the contract: wrong-slot tokens, nested - foreign frames, replayed and duplicated messages, stolen tokens, and - previous-navigation tokens — not only the positive SafeFrame case. -- Blanket top-of-listener hygiene: parse and ownership-check before any - branch logic; new branches inherit protection. -- Delete the dead duplicate renderer branch (C5); move its dedup and debug log - into the live branch. -- Renderer branches emit the same trace records as the adm and cache branches, - under G4's event taxonomy and billing rules (C7). +- **Ownership proof stays source-first**, and the SafeFrame extension is + bounded: the kernel maintains a map of known slot-root `WindowProxy` objects + (the iframes GPT created under each slot element); on a message, it walks + the **sender's own parent chain** (`event.source.parent`, …) up to depth + **5**, looking for a known root — it never enumerates or recursively scans + an attacker-controllable frame tree. Unresolvable source → refuse with + `bridge_id_mismatch`. +- Adversarial tests: wrong-slot tokens, nested foreign frames, replayed and + duplicated messages, stolen tokens, previous-navigation tokens, plus the + positive SafeFrame case. +- Blanket top-of-listener hygiene: parse and ownership-check before branch + logic. +- Delete the dead duplicate renderer branch (C5); renderer branches emit the + same trace records as adm/cache branches under G4's taxonomy and the G4d + `nurl`/`burl` split (C7). --- @@ -485,125 +526,113 @@ fallback lands only after that conversion. ### 7.1 Layering ``` -kernel/ boot, config, command queue, event bus, log, telemetry beacon +kernel/ boot, config, queue, event bus, log, beacon, sessions adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access services/ slots (registry+handoff), auction client, render engine, consent integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services) ``` -Rules, enforced by an eslint boundary rule in CI (`import/no-restricted-paths`): - -- `kernel` imports nothing above it; `adapters` import kernel only; `services` - import kernel + adapters; `integrations` import kernel + services, **never - each other**. -- Stateful services are reached through the G3 registration ABI, never through - imported module state; stateless helpers may be imported and inlined. -- This dissolves today's inversions: `core/auction.ts` and `core/request.ts` - importing `integrations/aps/render`, `gpt` and `prebid` importing `aps`, and - `prebid` owning the GPT refresh wrapper. +Boundary lint in CI (`import/no-restricted-paths`): `kernel` imports nothing +above it; `adapters` import kernel only; `services` import kernel + adapters; +`integrations` import kernel + services, never each other. Stateful services +via the G3 ABI only. ### 7.2 Adapters: explicit absence, without giving up on late loaders -Every external global is wrapped once with a state machine -`present | pending | timed_out`, a queue for `pending`, and per-operation -bounds. `timed_out` is **not terminal**: publishers legitimately lazy-load -GPT, Prebid, and CMPs, so a later arrival transitions the adapter to `present` -and drains what is still valid; individual queued operations carry their own -timeouts and expire with a disposition reason rather than the adapter -permanently disabling itself. +`present | pending | timed_out` per external global; `timed_out` is +non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and drains +what is still valid); individual queued operations carry their own timeouts +and expire with a disposition reason. ### 7.3 Slot registry service -One registry owns all slot knowledge: publisher-defined vs TS-defined, -adoption, handoff claims, responsive element resolution, refresh generation, -targeting-key history — keyed by `WeakMap` plus a -div-id index, owned by the kernel via the G3 ABI. Expando properties on live -GPT objects are eliminated. The GPT integration feeds events in and executes -registry decisions; the prebid refresh handler consumes the same registry. +One registry owns slot knowledge (publisher- vs TS-defined, adoption, handoff +claims, responsive resolution, pending request cycles per G4a, targeting-key +history), keyed by `WeakMap` plus a div-id index, +kernel-owned via the ABI. Expandos on live GPT objects are eliminated. ### 7.4 Global namespace policy — with a compatibility window -One owned global, `window.tsjs`, public API versioned, coordination state -under `tsjs._internal` (G3). But the migration must not break published -contracts: - -- **Inventory first:** every current global is classified public - (`globalThis.tscreative`, `tsCreativeConfig` — documented, settable - pre-load) or private (`__tsjs_*` flags, expandos, sentinels). -- **Public globals get a bounded dual-read/write window:** old and new names - both work for a stated deprecation period, with pre-init compatibility tests - (config set before the bundle loads must keep working); removal is its own - later, announced change. -- Private globals migrate immediately: per-slot expandos - (`__tsRenderGeneration`, `__tsRenderBid` — dead writes today, delete now) - into `SlotRecord`; function-object sentinels into a kernel-held `WeakSet`; - boot flags into the `window.tsjs = window.tsjs || {cmd: []}` pattern. +- One owned global, `window.tsjs`; public API versioned; coordination state + under `tsjs._internal` (G3). **The public queue keeps its existing name: + `window.tsjs.que`** (`types.ts:259`, drained at `core/index.ts:25`) — + revision 2's `cmd` was an error; renaming a public surface silently would + violate this very section. +- Inventory first: every current global classified public + (`globalThis.tscreative`, `tsCreativeConfig`, `tsjs.que`) or private + (`__tsjs_*` flags, expandos, sentinels). +- **Public globals: dual-read/write for a bounded window — two release + cycles, minimum 60 days — ending only after an adoption gate: beacon-observed + old-name usage below 0.1% of traces for 14 consecutive days.** Pre-init + compatibility tests pin that config set before the bundle loads keeps + working. +- Private globals migrate immediately: dead expando writes deleted now; slot + state into `SlotRecord`; function sentinels into a kernel `WeakSet`; boot + flags into `tsjs` boot fields. +- `requestAds` keeps its void signature; failure surfacing arrives as a **new + versioned async API** (`tsjs.requestAdsAsync(...): Promise`) + rather than changing the existing contract. ### 7.5 Messaging module -All `postMessage` traffic goes through one module: versioned envelopes, message -name constants (today `'Prebid Request'` appears as a bare literal at six -sites, and the APS handshake exists in three hand-synced copies), source -validation per 6.8, and one audit point. - -### 7.6 Plugin lifecycle - -`install()` replaces import-time side effects, with the semantics the review -demanded: - -- `tsjs.definePlugin(id, version, install, dispose)` registers; the kernel - resolves install requests against registrations, so **a deferred bundle that - registers after `tsjs.install([...])` was requested is installed on - arrival** (pending-install queue), bounded by a missing-module timeout that - emits `bundle_partial`. +All `postMessage` traffic through one module: versioned envelopes, message +name constants, the G4b acknowledgement nonces, source validation per 6.8, one +audit point. + +### 7.6 Plugin lifecycle and session model + +- **Activation:** Rust owns integration selection today and continues to — the + server injects a **versioned install manifest** (enabled plugin ids + + expected versions, in injection order) into the pre-core `tsjs.que`. The + kernel executes the manifest on boot; nobody else calls install in + production (the API remains callable for tests). +- `tsjs.definePlugin(id, version, install, dispose)`: synchronous `install` + by default; a plugin may return a promise, but anything `adInit` depends on + (gpt, prebid shim registration) must complete synchronously and is listed as + such in the manifest. Late registration after the manifest requested the id + installs on arrival (pending-install queue) bounded by a missing-module + timeout emitting `bundle_partial`. A stale async completion (arriving after + its `RuntimeSession` was disposed) is discarded. Duplicate `(id, version)` + is a no-op; a different version for a registered id: first-wins + loud + telemetry. Disposal runs in reverse install order. +- **Session model, split as the review required:** + - `RuntimeSession` (page lifetime): bridge listener, history hook, pbjs + subscriptions, adapters, beacon queue. + - `NavigationSession` (per SPA navigation): `trace_id`, render attempts, + slot aliases, targeting history, navigation-scoped timers/observers. + - `RenderAttempt` (per G4a cycle): state machine instance, ack nonce. + Each owns an **enumerable** disposal inventory; navigation disposes + `NavigationSession` children only. - Per-plugin exception isolation: a plugin that throws during install is - quarantined and reported; it cannot halt the bundle (today `didomi` can - throw during module evaluation and stop everything after it). -- Duplicate registration of the same `(id, version)` is a no-op; a different - version for a registered id follows a declared policy (first-wins + loud - telemetry). -- A `PageSession` object owns an **enumerable** set of listeners, timers, - observers, and slot records — registered at creation, disposed on - navigation. A `WeakMap` alone cannot dispose anything; the owned-set is the - disposal inventory. -- Error policy: no empty `catch` — every catch handles, logs with context, or - emits a disposition reason. The auction fetch gets a timeout + - `AbortController`, and `requestAds` surfaces failure to its caller. -- **Console logging is retained, not replaced.** The beacon is additive: every - issue-surfacing condition keeps (or gains) a `log.warn` debuggable from an - open DevTools console. Existing warnings survive verbatim or strengthened; - failure paths currently at `debug` (invisible at the default `warn` level — - the creative `dynamic_src_guard` and click-guard rejection paths) are - promoted to `warn` when they indicate a delivery or security-relevant - failure; every `render_fail` / dependency-timeout disposition emits a paired - `warn` carrying the same reason code, so console and beacon tell one story. + quarantined and reported; it cannot halt the bundle. +- Error policy: no empty `catch`; every catch handles, logs with context, or + emits a disposition reason. The auction fetch gets timeout + + `AbortController`. +- **Console logging is retained, not replaced.** Every issue-surfacing + condition keeps (or gains) a `log.warn` debuggable from DevTools; existing + warnings survive verbatim or strengthened; `debug`-level failure paths that + indicate delivery or security-relevant failures are promoted to `warn`; + every `render_fail`/dependency-timeout disposition emits a paired `warn` + with the same reason code. ### 7.7 The bootstrap problem -`gpt_bootstrap.js` duplicates ~400 lines of the hardest logic (handoff, -initial-load detection, hydration deferral) in hand-written ES5, always wins -the sentinel race, and has one live divergence (its simpler `adInit` can run -first and permanently suppress the bundle's `slotRenderEnded` listener). - -Target: shrink the inline bootstrap to a queue-and-flags stub (create -`googletag.cmd` interception points, record early publisher calls, expose the -enable flag), with the bundle replaying recorded calls on install. This is -**not a pure move** — replay changes observable ordering — so it ships behind -its own flag with the browser specs extended to cover replay timing, and the -no-bundle fallback ("ads still render if the bundle fails," pinned by -`gpt.rs:1174-1179`) is **generated from the same TypeScript source** at build -time, never hand-maintained. +Target: shrink the inline `gpt_bootstrap.js` to a queue-and-flags stub with +the bundle replaying recorded calls on install. This is **not a pure move** — +replay changes observable ordering — so it ships behind its own flag with +browser specs extended to cover replay timing, and the no-bundle fallback +("ads still render if the bundle fails", pinned by `gpt.rs:1174-1179`) is +**generated from the same TypeScript source** at build time. ### 7.8 GPT correctness fixes carried with the restructure - Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify against open PR #997; land whichever is canonical). -- Pass `changeCorrelator: false` on TS-initiated refreshes; correlator - behavior becomes a documented, configurable decision. -- `enableSingleRequest()` only when GPT services are not already enabled; - otherwise adopt the publisher's mode and record it. -- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` in - addition to its console warning. +- `changeCorrelator: false` on TS-initiated refreshes; correlator behavior + becomes a documented, configurable decision. +- `enableSingleRequest()` only when GPT services are not already enabled. +- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` + alongside its console warning. ### 7.9 Decomposition targets @@ -611,48 +640,38 @@ time, never hand-maintained. | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | | `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | | `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (moves onto slot registry), eids, diagnostics | -| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory the other six integrations already use | +| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | | `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | | `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state | ### 7.10 Performance -Client-side, the design is a net speedup with enforced budgets: - -- **Smaller synchronous bundle:** script-guard consolidation, single APS - module (via the ABI), dead-code deletion, trace-overlay extraction. -- **Fewer repeated DOM walks:** slot resolution once per navigation in the - registry. -- **Bounded waits instead of blind ones:** the 10 s silent renderer timeout - and forever-queued GPT cases become short, telemetered timeouts. -- **Budgets in CI, precisely specified:** per-bundle byte sizes measured raw, - gzip, and Brotli for an exact named module set, compared against a - checked-in baseline artifact with a stated tolerance; the browser-spec - timing assertion (bids-script-to-first-`display()`) runs N times and gates - on a percentile, not a single sample. - -Server-side (new in this revision): each injected page currently concatenates -and hashes the full immediate bundle, and the asset request concatenates it -again (`bundle.rs:51`). Precompute bundle bytes + hash per registry module set -(they change only at deploy/config time), and benchmark server CPU/heap before -and after. +Client-side speedups with enforced budgets: smaller synchronous bundle +(script-guard consolidation, single APS module via the ABI, dead-code +deletion, trace-overlay extraction); slot resolution once per navigation; +bounded telemetered waits. Budgets, concretely: per-bundle raw/gzip/Brotli +bytes for the exact ordered module vector of the reference config, compared +to a checked-in baseline with **+5% tolerance**; the +bids-script-to-first-`display()` browser assertion runs **20 iterations** and +gates on **p90**. Server-side: precompute bundle bytes + hash per ordered +module vector (deploy/config-time), benchmark server CPU/heap before/after. ### 7.11 Toolchain and dependency currency -- **TypeScript to latest stable** (library pins `^5.5.4` while vite 7 / - vitest 4 / typescript-eslint 8 are current), adopting +- **TypeScript:** the manifest floor is `^5.5.4` but the lockfile already + resolves **5.9.3** (`package-lock.json`), so the code compiles on 5.9 today. + Action: raise the manifest floor to the resolved 5.9 line (making the floor + honest), then evaluate the next major as its own gated PR; adopt `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, - `verbatimModuleSyntax` — these directly serve killing the `as unknown as` - escapes and the wrong `global.d.ts` declaration. + `verbatimModuleSyntax`. - **Dev toolchain to latest stable** (eslint + plugins, prettier, jsdom, `@playwright/test`, `@types/node` aligned to the pinned Node), each bump its - own mechanical CI-gated PR with changelog review — this library - monkeypatches `fetch`, `sendBeacon`, and DOM prototypes, so jsdom and - Playwright behavior changes are real risks. -- **`prebid.js` is excluded from casual bumps:** the runtime Prebid is the - external R2 bundle locked by manifest hash and SRI; upgrading it is its own - coordinated deploy. The npm pin and the deployed bundle version stay - documented together so tests exercise the version production runs. + own mechanical CI-gated PR with changelog review — this library monkeypatches + `fetch`, `sendBeacon`, and DOM prototypes, so jsdom/Playwright behavior + changes are real risks. +- **`prebid.js` is excluded from casual bumps** (runtime Prebid is the + manifest-locked external R2 bundle); the npm pin and deployed bundle version + stay documented together. - **Standing policy:** monthly dependency review; no migration phase starts more than one minor behind latest stable. @@ -660,33 +679,39 @@ and after. ## 8. Migration plan -Reordered so every phase's prerequisites precede it; each phase ships behind a -feature flag with canary thresholds and rollback criteria. - -- **Phase 0 — Contracts and toolchain.** Settle G1–G5 in code-adjacent docs; - toolchain upgrades (7.11); the trace envelope + beacon + four-adapter ingest - (accept-count-drop outside Fastly) behind a flag; server drop-reason - surfacing (5.4); reason codes on today's silent returns; delete the dead - expando writes. No runtime behavior change for pages with the flag off. -- **Phase 1 — Runtime ABI + APS admission/identity.** G3 kernel registry in - `tsjs-core` (context-provider fix is the proof); wire-schema crate + shared - corpus (6.7); mediation selection helper + opt-in merge (6.1); render token - for renderer-only bids (6.4); renderer-endpoint startup validation (6.6); - bridge hardening minus fallback (6.8). APS renders after this phase wherever - GAM line items and configuration permit — the no-GAM fallback is explicitly - Phase 2. -- **Phase 2 — Render state machine + GPT correctness.** Minimal `SlotRecord` - core (just enough for the state machine keys; full registry lands in - Phase 3); awaitable renderer conversion; the exactly-once fallback (6.5, - G4); restore #922/#997 attribution and orphan recovery; correlator and SRA - fixes (7.8). -- **Phase 3 — Structure.** Full layering + boundary lint, plugin lifecycle + - `PageSession` (7.6), adapters (7.2), full slot registry (7.3), messaging - module (7.5), namespace migration with its compatibility window (7.4), - asset content-addressing fix + server bundle precompute (G5, 7.10). -- **Phase 4 — Decomposition.** File splits (7.9), script-guard consolidation, - bootstrap shrink behind its own flag with replay-timing specs (7.7), and - the end of the public-global compatibility window. +Ordered so every phase's prerequisites precede it (the review's required +order). Every phase ships behind a feature flag; shared canary criteria: no +increase in `render_fail` rate beyond **+0.5% absolute** on canary traffic +over 24 h, no new console errors in the browser-spec run, rollback = flag off +(phases 0–2 are additive; later phases keep dual paths until their gate). + +- **Phase 0 — Asset identity, contracts, toolchain.** Path-based immutable + asset identity + manifest retention + rolling-deploy tests (G5) — this lands + **before** anything makes ABI compatibility load-bearing. Toolchain floors + (7.11). Contracts G1–G5 recorded as code-adjacent docs. Delete the dead + expando writes. Server drop-reason surfacing (5.5) — server-only, no client + dependency. +- **Phase 1 — Kernel ABI and sessions.** Minimal kernel in `tsjs-core` + publishing the versioned registry (G3); `RuntimeSession` / + `NavigationSession` scopes (7.6); context-provider fix as the ABI proof; + install manifest injection. +- **Phase 2 — Trace and beacon.** Server correlation nonce for `nav_gen 0` + + page-bids trace echo (G1); beacon service on the Phase-1 kernel; ingest + route in all four adapters (5.3); diagnostic capability; `ts_client_events` + datasource. +- **Phase 3 — APS delivery.** Wire-schema crate + corpus (6.7); mediation + helper + opt-in merge (6.1); render token (6.4); unconditional versioned + renderer route + two-stage ack (6.6, G4b); bridge hardening (6.8); GPT + request-cycle protocol + render state machine + awaitable renderer + + `nurl`/`burl` split (G4a–G4d); the opt-in fallback (G4e); restore #922/#997 + attribution; correlator and SRA fixes (7.8). APS renders after this phase + wherever GAM line items and configuration permit. +- **Phase 4 — Structure.** Full layering + boundary lint, plugin lifecycle + completion, adapters, full slot registry, messaging module, namespace + migration window (7.4), server bundle precompute (7.10). +- **Phase 5 — Decomposition.** File splits (7.9), script-guard consolidation, + bootstrap shrink behind its own flag with replay-timing specs (7.7), end of + the public-global compatibility window (gated per 7.4). --- @@ -696,92 +721,98 @@ Blocking CI is hermetic (the deterministic PUC/message harness); a separate staged smoke suite covers real GAM line items and is release-gating, not PR-gating. -| Area | Must cover | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Mediation | both lifecycles (ordinary + SSAT split dispatch/collect); ties, floors, deals, duplicate demand; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) | -| Cache identity | non-APS cache-backed bids: byte-identical `hb_adid` + cache coordinates (regression); PUC `?uuid=` fetch path | -| Render token | `^[a-z0-9]{12}$`; CSPRNG source; collision retry; TTL; one-time consumption; cross-slot/auction uniqueness | -| Fallback | races vs bridge claim; nonempty-GAM protection; repeated refresh; SPA navigation cancellation; slot destruction; exactly-once terminal transition | -| Render semantics | no billing after runner-load failure; `render_accepted` vs `render_confirmed` labeling; opaque-frame honesty (no painted/blank claim) | -| Bridge security | wrong-slot tokens; nested foreign frames; replayed + duplicate messages; stolen tokens; previous-navigation tokens; SafeFrame positive case | -| Beacon | trace joins; ordering/dedup via `(trace_id, nav_gen, seq)`; batching across navigations; loss tolerance; ingest abuse (oversize, malformed, cross-origin); all-adapter routing | -| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection | -| Runtime ABI | one kernel instance under concatenation; deferred registration after install request; per-plugin failure isolation; abi-version mismatch refusal | -| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); `PageSession` disposal inventory; pre-init `tsCreativeConfig` compatibility; dual-name global window | -| Delivery | immutable-cache behavior across a simulated rolling deploy; deterministic raw/gzip/Brotli bundle budgets vs baseline artifact | -| Observability | drop-reason summary on any partial drop; page-bids structured `debug` field gating; diagnostic mode unsampled completeness | +| Area | Must cover | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Mediation | both lifecycles; ties, floors, currency rejection; duplicate demand via provenance map; transformed mediator ids; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) | +| Cache identity | non-APS cache-backed bids byte-identical (`hb_adid` + coordinates); PUC `?uuid=` fetch path | +| Render token | `^[a-z0-9]{12}$`; CSPRNG source; in-auction collision retry; 15-min TTL; one-time consumption; per-`(trace, nav_gen)` registry scoping | +| Request cycles | pending-cycle attribution; overlapping refreshes; late `slotRenderEnded`; nonempty-before-bridge-claim; `cycle_unattributable` fail-closed | +| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame flows; stale/replayed acks | +| Render semantics | no `burl` before authenticated `render_accepted`; `nurl` at selection independent of delivery; `billed_then_failed` labeling; accepted-but-blank; no `render_confirmed` from geometry or PUC container | +| Fallback | renders only on attributed `gam_empty`; timeout is diagnostics-only; adopted-slot fallback disabled; SPA cancellation; slot destruction; exactly-once terminal transition | +| Bridge security | wrong-slot tokens; nested foreign frames; replay + duplicates; stolen tokens; previous-navigation tokens; bounded parent-chain walk (depth 5); SafeFrame positive case | +| Beacon | initial-nav server nonce join; page-bids trace echo; per-event envelope across navigation-spanning batches; `(trace_id, nav_gen, seq)` dedup; ingest abuse (oversize, malformed, cross-origin, absent Origin); capability sig | +| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection | +| Runtime ABI | one kernel under concatenation; deferred registration after manifest request; per-plugin failure isolation; **mixed-version delivery (old deferred bundle + new core)**; abi-mismatch refusal | +| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); RuntimeSession vs NavigationSession disposal inventories; stale async install completion; pre-init `tsCreativeConfig` and `tsjs.que` compatibility; dual-name global window | +| Delivery | rolling-deploy simulation (old HTML + retained old assets); unknown hash → 410 `no-store`; immutable only on exact match; ordered-module-vector keying; deterministic raw/gzip/Brotli budgets vs baseline | +| Renderer endpoint | route present in all four adapters; auth-pattern startup failure; stale version behavior; CSP report-only canary; `document_loaded` vs runner-result split | +| Adapter parity | ingest + renderer routes + drop-reason surfacing behave equivalently on Fastly/Viceroy vs Axum vs Cloudflare vs Spin | +| Policy | script-creative startup warning; `invalid_dimensions{WxH}` drop naming; page-bids `debug` field gating; diagnostic-mode unsampled completeness | --- ## 10. Alternatives considered 1. **Keep patching APS point-failures without telemetry.** Rejected: three - consecutive correct fixes have not produced ads; without disposition data - the next fix is another guess. -2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but - changes GAM reporting/pacing semantics unilaterally; kept as the opt-in, - state-machine-guarded fallback instead. + consecutive correct fixes have not produced ads. +2. **Direct-render APS always (skip GAM/PUC).** Changes GAM reporting/pacing + semantics unilaterally; kept as the opt-in, `gam_empty`-gated fallback. 3. **Single module graph / shared chunks instead of the registration ABI.** - Cleaner long-term, but changes the delivery pipeline (chunk loading) now; - recorded as the successor option behind the same ABI surface. -4. **Full library rewrite in one branch.** Rejected: the browser-spec safety - net is thin in exactly the areas being changed. + Cleaner long-term; changes the delivery pipeline now; successor option + behind the same ABI surface. +4. **Full library rewrite in one branch.** Rejected: thin browser-spec safety + net in exactly the changing areas. 5. **Drop the ES5 bootstrap entirely.** Loses the pinned "ads render if the - bundle fails" guarantee; the generated-fallback approach keeps it without - dual maintenance. + bundle fails" guarantee; generated fallback keeps it. +6. **Timeout-triggered fallback rendering.** Rejected (G4e): GPT requests + cannot be cancelled, so timeout rendering races late fills; only an + attributed terminal empty event may trigger rendering. ## 11. Risks -- **Merge strategy misconfiguration** changes auction economics; mitigated by - keeping `mediator_only` the default, the selection report, and omitted - serialization at defaults. -- **Beacon abuse/volume:** bounded by pre-parse caps, origin checks, rate - limits, sticky sampling, and closed enums. -- **ABI freeze risk:** `tsjs._internal.registry` becomes load-bearing; - versioned from day one, majors checked at lookup. -- **Bootstrap replay** changes observable ordering; own flag, replay-timing - specs, staged rollout. -- **Schema generation** adds a build step; checked-in artifacts + staleness CI. +- **Merge strategy misconfiguration:** mitigated by `mediator_only` default, + the selection report, omitted-default serialization. +- **Beacon abuse/volume:** pre-parse caps, origin checks, per-adapter rate + limiting, sticky sampling, closed enums, capability-gated diagnostics. +- **ABI freeze risk:** versioned from day one; mixed-version delivery tested. +- **Ack protocol adds a message round-trip before `burl`:** bounded by the + existing renderer timeout; the `billed_then_failed` label measures the + policy. +- **Bootstrap replay:** own flag, replay-timing specs, staged rollout. +- **Schema generation:** checked-in artifacts + staleness CI. ## 12. Success criteria 1. APS creatives render on a reference page in each configured flow (SSAT, - Prebid adapter, page-bids), proven hermetically in CI and by the staged - smoke suite against real GAM line items. -2. Every failure point in section 2 maps to a distinct observable signal, and - **diagnostic mode** yields the failing reason from one page load; production - telemetry meets the stated SLO (5.3). -3. `eslint` boundary rules pass with zero exceptions; no integration imports - another integration; stateful sharing goes through the versioned ABI. + Prebid adapter, page-bids), hermetically in CI and via the staged smoke + suite. +2. Every failure point in section 2 maps to a distinct observable signal; + diagnostic mode names the failing reason from one page load; production + telemetry meets the 5.4 SLO (≥ 1% failure modes visible within one hour). +3. Boundary lint passes with zero exceptions; stateful sharing only via the + versioned ABI; mixed-version delivery behaves as specified. 4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. -5. Trace counts are per-impression (no double counting), and orphaned-slot - recovery is covered by a non-vacuous test. +5. Trace counts are per-impression; orphaned-slot recovery has a non-vacuous + test; `refresh_gen` attribution follows the G4a cycle protocol. 6. The only TSJS-owned global is `window.tsjs` (public globals only inside - their announced compatibility window); no expandos on GPT slots, GPT - functions, or `pbjs`. -7. Per-bundle raw/gzip/Brotli sizes are at or below the checked-in baseline - within stated tolerance, and the percentile-based - bids-script-to-first-`display()` assertion does not regress; server-side - per-request bundle concatenation/hashing is precomputed. -8. No existing warning is lost: every issue-surfacing condition logs at `warn` - or above with the same reason code the beacon carries. -9. TypeScript and the dev toolchain are on latest stable with the new - strictness flags; `prebid.js`'s npm pin matches the documented deployed - bundle version; the monthly review policy is in CI docs. -10. Rolling-deploy cache tests pass: a `?v=A` request never caches bytes other - than `A`. + their announced window, which closes per the 7.4 adoption gate); no + expandos on GPT slots, GPT functions, or `pbjs`. +7. Bundle budgets (raw/gzip/Brotli, +5% tolerance vs baseline) and the p90 + 20-run timing assertion hold; server-side per-request concatenation is + precomputed. +8. No existing warning is lost; every issue-surfacing condition logs at `warn` + or above with the beacon's reason code. +9. TypeScript floor matches the resolved 5.9 line with the strictness flags + on; `prebid.js` npm pin matches the documented deployed bundle; monthly + review policy in CI docs. +10. Rolling-deploy tests: old HTML always receives its exact old assets during + the retention window; unknown hashes 410 with `no-store`; immutable + caching only on exact matches. +11. `nurl` and `burl` fire on their distinct G4d transitions, idempotently, + and never before their triggering state. ## 13. Open questions -1. Is a mediator configured in the affected production deployment? (Decides - whether A1 is the primary cause or a latent one.) +1. Is a mediator configured in the affected production deployment? 2. What share of live APS demand is `tagtype: "script"`? 3. Should `client_render_fallback` ever become default-on for publishers without GAM line items for `hb_bidder=aps`? 4. Is PR #997 the intended restoration of the lost #922 attribution core, or should the original be re-merged? -5. Does any deployment serve descriptors for an origin whose config disables - APS (decides whether the renderer route becomes config-independent, 6.6)? -6. Datasource naming/retention for client events, and whether Axum/Cloudflare/ - Spin get real sinks or keep accept-count-drop. +5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep + accept-count-drop? (Datasource `ts_client_events`, 30-day retention, and + 10% default sampling are proposed values pending operator sign-off.) +6. Does Amazon expose any creative-completion acknowledgement we could adopt + to move APS beyond `render_accepted` (G4c)? From fffd4baea350f5ce43d6752dfd549bde2492138e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:43:41 -0700 Subject: [PATCH 544/844] Revise design spec for the third review round Moves the SPA trace onto a validated X-TSJS-Trace-Id header because page-bids is a GET, adds the cache-privacy invariant for trace-bearing HTML, rebuilds cycle attribution without ordering assumptions (ts-vs- publisher classification, one outstanding TS cycle per slot, overlap fails closed, real-GAM release-gating test), scopes nurl/burl to bid paths that carry them with attempt-scoped idempotency and leaves the APS runner lifecycle untouched, removes render_confirmed after the sandboxed-srcdoc correction, pulls minimal messaging, the cycle registry, and unconditional GPT subscriptions forward so Phase 3 has its dependencies, defines mixed-version ABI verdicts (major/minMinor ranges, quarantine, first-wins among compatible), makes retained assets realizable via two-stage publishing to shared immutable storage with both rolling directions tested, groups beacon events per trace with per-trace capabilities and page-bids capability issuance, fails telemetry rate limiting closed with per-adapter trusted-address definitions, parameterizes the SLO and phase-specific gates, corrects the renderer /v1 to immutable caching with an aggregate-only counter claim and a fully specified CSP report route, allows fallback on attributed gam_empty regardless of slot ownership, and tightens the remaining bounded-schema, provenance, and performance-method edges. --- ...s-render-fix-and-tsjs-resilience-design.md | 1112 ++++++++--------- 1 file changed, 501 insertions(+), 611 deletions(-) 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 144b7ca36..9c53fca6a 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 @@ -1,17 +1,15 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 3 — reworked after the second design review round. +- **Status:** revision 4 — reworked after the third design review round. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `541298695` — the full merged state (everything merged from `main` plus every rc-only merge), not just the delta pending against `main`. -- **Inputs:** three code audits performed against this baseline (APS end-to-end - trace, TSJS architecture audit, GPT integration map); design reviews of - revisions 1 and 2; open issues #926, #941, #944, #962, #964, #977, #983, - #989, #993; open PR #997. -- **Terminology:** the per-refresh counter is `refresh_gen` everywhere in this - document (revision 2 used `render_gen` in some places; that name is - retired). +- **Inputs:** three code audits performed against this baseline; design + reviews of revisions 1–3; open issues #926, #941, #944, #962, #964, #977, + #983, #989, #993; open PR #997. +- **Terminology:** the per-refresh counter is `refresh_gen` everywhere. The + event previously named `render_confirmed` is removed (see G4c). --- @@ -20,32 +18,29 @@ APS (Amazon Publisher Services) demand is fully integrated server-side — the edge server runs the APS OpenRTB auction, wins bids, and ships a typed renderer descriptor to the page — yet APS creatives still do not appear for real users. -Every previous fix (the `bid.meta` carrier so Prebid does not strip the -descriptor, the decoupled prebid shim, the `hb_adid` fallback to the OpenRTB bid -id) addressed a real defect, and APS still does not render. That pattern — serial -single-cause fixes that each survive review and still do not produce ads — is -itself the finding: the APS pipeline has **multiple independent failure points, -most of which fail silently**, and the client library has **no way to tell the -server (or the operator) which one fired**. +Every previous fix (the `bid.meta` carrier, the decoupled prebid shim, the +`hb_adid` fallback) addressed a real defect, and APS still does not render. +That pattern is itself the finding: the APS pipeline has **multiple independent +failure points, most of which fail silently**, and the client library has **no +way to tell the server (or the operator) which one fired**. At the same time, the TSJS client library has grown organically to 56 files / ~11,900 lines with two ~1,700-line monoliths, duplicated logic maintained by -hand in two languages, inverted layering, and roughly one hundred `catch` blocks -that discard failures. The APS outage and the library's shape are the same -problem seen from two sides: a delivery pipeline whose failure modes are -invisible and whose components cannot be reasoned about independently. +hand in two languages, inverted layering, and roughly one hundred `catch` +blocks that discard failures. The APS outage and the library's shape are the +same problem seen from two sides. This design covers both: (a) the specific fixes that make APS render, and (b) -the target architecture that makes TSJS a clean, resilient library so the next -integration does not reproduce this failure class. +the target architecture that makes TSJS a clean, resilient library. ### Non-goals -- No change to the APS OpenRTB endpoint contract or Amazon-side configuration. +- No change to the APS OpenRTB endpoint contract or Amazon-side configuration + (including its deliberate absence of `nurl`/`burl` — see G4d). - No rewrite of Prebid.js integration strategy (the decoupled shim stays). -- No behavior change for publishers whose pages work today. Where this design - must migrate a public surface (section 7.4), it does so behind a bounded - compatibility window, never by immediate removal. +- No behavior change for publishers whose pages work today. Public-surface + migrations happen behind a bounded compatibility window (7.4), never by + immediate removal. --- @@ -87,43 +82,38 @@ descriptor without GAM's cooperation. ### 2.4 Observability: the common factor -There is **zero client→server reporting**. Server telemetry marks `is_win=1` at -auction time and goes quiet; a bid that never painted is byte-identical to one -that painted perfectly. Client-side evidence (`window.tsjs.renders`, console -warnings, `data-ts-*` attributes) dies with the tab. +There is **zero client→server reporting**. Server telemetry marks `is_win=1` +at auction time and goes quiet; a bid that never painted is byte-identical to +one that painted perfectly. Client-side evidence dies with the tab. --- ## 3. The GPT reality this design must respect -The GPT integration is a **bootstrap-first hybrid**: the server injects a -495-line ES5 `gpt_bootstrap.js` inline in `` before the TSJS bundle, and -the two coordinate through shared monkeypatch sentinels, with document order -deciding the winner. The audit's key facts: - -1. **The bundle's handoff and initial-load code is dead in production.** The - bootstrap installs its wrappers first and sets the same sentinels the bundle - checks; ~200 lines of the TypeScript the test suite exercises most heavily - never run on a real page. -2. **The slot handoff can alias a publisher's new div to a GPT slot bound to a - dead element** — and the orphan-recovery watcher built to repair exactly that - was **lost in the #922 merge** (`0dc9b19a9` resolved `gpt/index.ts` to the rc - side). `updateRender` now has no production caller, - `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every - bridge-served impression double-counts in the trace. Open PR #997 appears to - be the reworked replacement. -3. **TS refreshes never pass `changeCorrelator: false`**, silently changing - roadblock, competitive-exclusion, and frequency-capping behavior. +1. **Bootstrap-first hybrid:** the server injects a 495-line ES5 + `gpt_bootstrap.js` before the bundle; shared monkeypatch sentinels mean the + bundle's handoff and initial-load code is dead in production. +2. **The #922 merge loss:** orphan-slot recovery and `updateRender` enrichment + are gone (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead + writes; bridge-served impressions double-count. PR #997 appears to be the + reworked replacement. +3. **TS refreshes never pass `changeCorrelator: false`.** 4. **`enableSingleRequest()` is called blind** after the publisher's own `enableServices()` has almost always run. 5. Responsive resolution is a DOM-element-selection ladder; ambiguity silently - skips the slot for the whole pass. + skips the slot. 6. Three independent wrappers on `pubads().refresh` coordinate via - window-global booleans that async wrappers observe already reset. -7. **GPT refresh is asynchronous and offers no request-cancellation - primitive** (`gpt/index.ts:1080`): once a refresh is issued, a response can - arrive arbitrarily late. Any fallback design must treat an issued GPT - request as uncancelable. + window-global booleans. +7. **GPT offers no request cancellation** (`gpt/index.ts:1080`), and its event + contract identifies only the slot: Google documents no per-refresh + identifier and no completion-order guarantee for overlapping requests, and + `slotRenderEnded` means creative code was injected, not that its resources + loaded. Publisher code can refresh an adopted slot while a TS cycle is + pending. Any attribution scheme must survive all of that (G4a). +8. **The bundle's `slotRenderEnded` registration is gated behind + `!ts.servicesEnabled`** (`gpt/index.ts:1017`), so bootstrap-first or + services-enabled pages can miss the listener entirely — G4a requires + unconditional early subscription. --- @@ -131,264 +121,285 @@ deciding the winner. The audit's key facts: ### G1 — Trace identity and correlation -**The client-visible auction id must never be ingested.** It is EC-derived by -construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists). Server -telemetry uses an independent fresh UUID (`telemetry.rs:94-97`) — and, decisive -for the design: **initial-HTML auction telemetry is emitted before page -JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so a -client-minted trace can never retroactively join it. - -Contract — correlation is minted by whoever acts first: - -- **Initial navigation (`nav_gen = 0`):** the **server** mints a - `trace_id` — 128-bit CSPRNG, hex (`^[0-9a-f]{32}$`), derived from nothing — - per HTML response, writes it into that response's auction telemetry rows at - emit time, and injects it into the page as a `tsjs` boot field alongside the - sampling decision. The client's `NavigationSession` adopts it. It is a new - value, never `AuctionRequest.id`. -- **SPA navigations (`nav_gen > 0`):** the **client** mints the `trace_id` for - the navigation and sends it in the `/_ts/page-bids` request body; the server - records it contemporaneously in that auction's telemetry rows. No - retroactive joining anywhere. +**The client-visible auction id must never be ingested** (EC-derived: +`publisher.rs:3237`). **Initial-HTML auction telemetry is emitted before page +JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so correlation +is minted by whoever acts first: + +- **Initial navigation (`nav_gen = 0`):** the server mints `trace_id` + (128-bit CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction + telemetry rows at emit time (a new nullable `trace_id` column on the + existing auction datasource; the independent telemetry `auction_id` UUID + remains and remains separate), and injects it into the page as a `tsjs` + boot field with the sampling decision and, when the tester gate is active, + the diagnostic capability (5.3). +- **Cache-privacy invariant:** a trace or capability is injected **only** into + responses that ran a per-request auction, and any trace-bearing HTML MUST be + shared-cache-ineligible: `Cache-Control: private, no-store` and no + validators that could revalidate a shared copy. This is enforced by + construction (the injection site is the auction-bearing render path) and by + test. A shared-cached page would have no per-visitor auction to correlate + anyway; the invariant makes that alignment explicit. +- **SPA navigations (`nav_gen > 0`):** `/_ts/page-bids` **stays GET** (it is + GET in `publisher.rs:3815` and the client issues GET at + `gpt/index.ts:1152`; a browser GET cannot carry a body). The client mints + the `trace_id` and sends it in a validated **`X-TSJS-Trace-Id`** request + header — the request already carries a non-simple TSJS header, so CORS + preflight behavior is unchanged. The server records it contemporaneously in + that auction's telemetry rows and the JSON response **echoes the accepted + trace id** and returns a capability bound to it when the tester gate is + active. - **Envelope:** every event carries - `{trace_id, sampled, nav_gen, refresh_gen, seq}` — per event, not per batch, - so a transport batch may span navigations without ambiguity. `seq` is a - per-trace monotonic counter for ordering and deduplication. -- **Sampling** is decided once per trace (server-decided for `nav_gen 0`, - client-decided from the injected rate for later navigations) and recorded in - the envelope; a sampled trace is complete or absent. + `{trace_id, sampled, nav_gen, refresh_gen, seq}`; `seq` is per-trace + monotonic. +- **Sampling is trace-sticky** (decided once per trace; server-decided for + `nav_gen 0`, client-decided from the injected rate afterwards). Transport is + best-effort, so a sampled trace may still arrive partial; partiality is + detectable via `seq` gaps and is not treated as a contract violation. ### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID -**The PBS Cache contract stays untouched.** `hb_adid` deliberately prefers the -Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache coordinates -and the bridge's cache fetch assume `?uuid=` (`publisher.rs:3450`, -`gpt/index.ts:1700`). - -Contract: - -- Bids with a cache id: `hb_adid` = cache UUID, exactly as today. Bids with - markup and no cache id: existing fallback chain, exactly as today. -- **Renderer-only bids (APS): `hb_adid` = a server-minted render token**, - format `^[a-z0-9]{12}$` (12 chars exactly, 36¹² ≈ 4.7 × 10¹⁸ values), - CSPRNG-generated. Collision handling is honest about its scope: retry on - collision **within the minting auction** (the only scope the server can - check without storage); cross-auction uniqueness is **probabilistic**, with - the birthday bound documented (at 10⁶ live tokens the collision probability - is ~10⁻⁷) and made harmless by scoping: the client registry keys tokens per - `(trace_id, nav_gen)`, so a cross-page collision cannot cross wires. -- Token lifecycle: TTL **15 minutes** from mint, one-time consumption in the - bridge registry, invalidated by navigation. -- The client-side Prebid adapter path keeps Prebid's generated `adId`; both - paths register into one bridge registry keyed by whichever id that path - observes. -- Regression tests: non-APS cache-backed bids keep byte-identical `hb_adid` - and cache coordinates. +Unchanged from revision 3 (review-accepted): cache-backed bids keep the cache +UUID as `hb_adid` byte-for-byte; renderer-only bids get a server-minted token +`^[a-z0-9]{12}$` (CSPRNG; in-auction collision retry; cross-auction uniqueness +probabilistic with the birthday bound documented; harmless via per- +`(trace_id, nav_gen)` registry scoping); TTL 15 minutes; one-time consumption; +client-Prebid keeps Prebid's `adId`; non-APS cache-path regression tests. ### G3 — Runtime ABI: how code shares state under the IIFE build -Every entry point is built as a self-contained IIFE with dynamic imports -inlined (`build-all.mjs:46`), and the server concatenates already-closed IIFEs -(`bundle.rs:23`) — a module `import` never shares state across bundles. Live -proof: `core/context.ts:11` holds a private context-provider `Map` while -`permutive/index.ts:102` registers into its own copy. - -Contract — **a versioned registration ABI on `window.tsjs._internal`**: - -- The kernel ships **only** in `tsjs-core` (always first in the concatenated - unified bundle) and publishes `tsjs._internal = { abi: 1, registry }` exactly - once, guarded by a window-level sentinel. -- **Construction ownership:** the kernel constructs and registers the core - service instances (event bus, beacon queue, session objects, slot registry, - render state machine) during its own boot; integrations construct only - integration-scoped services and register them during their `install()`. -- All **stateful** services are reached only through - `tsjs._internal.registry.get(name, minVersion)` at call time. Pure stateless - helpers may be imported and inlined freely. -- `abi` majors are checked at lookup; a mismatch is a logged, telemetered - refusal, not a silent no-op. Mixed-version delivery (old deferred bundle, - new core) is a tested scenario, not an accident. -- The single-module-graph build is recorded as the successor option behind the - same ABI surface. - -### G4 — Render lifecycle: cycles, acknowledgements, and honest states - -Four sub-contracts, each fixing a hole the reviews identified. - -**G4a — GPT request-cycle protocol.** `slotRenderEnded` identifies a slot, not -a request, and the bridge currently reads live `window.tsjs.bids` -(`gpt/index.ts:1606`) while the generation snapshots are dead writes -(`gpt/index.ts:1085`). Contract: every TS-issued `display()`/`refresh()` opens -a **cycle** `(slot, refresh_gen)` pushed onto a per-slot pending-cycle queue; -`slotRequested` confirms it; GPT fires slot events in order per slot, so -`slotRenderEnded` is attributed to the oldest confirmed pending cycle for the -slot. Each bridge token and each render attempt binds to exactly one cycle. If -attribution is ambiguous (overlapping cycles the queue cannot separate, or an -event with no pending cycle), the state machine for that slot **fails closed**: -no fallback, `render_fail{cycle_unattributable}`, console warning. - -**G4b — Acknowledgement path.** Today the renderer document posts ready only -to its immediate parent (`aps.rs:105`); in the PUC path that parent is the -nested renderer frame, which resolves a local promise (`render.ts:423`) the -top-level kernel cannot observe — and callbacks currently fire right after the -bridge posts its response (`gpt/index.ts:1572`, `:1620`). Contract: the bridge -response carries a **per-attempt CSPRNG acknowledgement nonce**; the dynamic -renderer posts versioned `render_accepted` / `render_failed{reason}` messages -**to the kernel** (top window), carrying the nonce; the kernel validates -source ownership, nonce, token, `nav_gen`, and `refresh_gen` before any state -transition or callback. This protocol is pinned by tests for all three flows: -SSAT, client-Prebid, and nested SafeFrame. - -**G4c — Honest observation names.** The browser cannot see inside an opaque -APS frame, and the iframe's geometry is assigned by our own renderer — it -proves nothing about content. A nonempty `slotRenderEnded` proves GAM -delivered a creative container, not that the nested runner painted. The state -machine therefore records observations under accurate names — -`gam_nonempty`, `gam_empty`, `renderer_document_loaded`, `runner_loaded`, -`runner_failed` — and **APS attempts terminate at `render_accepted`** -(= authenticated `runner_loaded` ack) unless Amazon provides a real completion -acknowledgement. `render_confirmed` exists only for paths with same-origin -observable content (inline adm frames TS itself writes); it is never derived -from geometry or from PUC container delivery. Tests: accepted-but-blank, and -nonempty-`slotRenderEnded`-before-bridge-claim. - -**G4d — `nurl`/`burl` are separate business events.** OpenRTB 2.6 -distinguishes them: `nurl` is the win notice (implies neither delivery nor -billability); `burl` is the billable-event notice under exchange policy. -Today both fire together (`gpt/index.ts:459`). Contract — independent, -idempotent transitions: - -1. winner selection → fire `nurl`; -2. `render_accepted` (authenticated) → fire `burl` — this is the **declared - commercial policy** for APS given no paint acknowledgement exists, recorded - here explicitly rather than implied; -3. terminal failure after acceptance → no un-firing; the row is labeled - `billed_then_failed` so the policy's cost is measurable. - -**G4e — Fallback trigger.** GPT offers no cancellation (section 3.7), so a -timeout can race a late fill that arrives after a fallback has rendered and -billed. Contract: the opt-in direct fallback -(`[auction].client_render_fallback = "renderer"`) renders **only after an -explicit terminal empty event for the bound cycle** (`gam_empty` from G4a -attribution). A timeout emits diagnostics (`render_fail{bridge_claim_timeout}`) -and **never renders**. For publisher-owned (adopted) slots, fallback is -disabled entirely. TS-owned-slot timeout rendering is admitted only as a -possible future extension that must first destroy the slot to retire the -request, and is out of scope here. +IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`) +means module imports never share state across bundles (live proof: +`core/context.ts:11` vs `permutive/index.ts:102`). + +Contract — a versioned registration ABI on `window.tsjs._internal`: + +- Kernel ships only in `tsjs-core`, publishes + `tsjs._internal = { abi: 1, registry }` once (window sentinel). The kernel + constructs and registers core service instances during boot; integrations + register integration-scoped services during `install()`. +- **Version semantics (settling the mixed-version gap):** every service + registers with `(major, minor)`. `registry.get(name, {major, minMinor})` + succeeds iff an implementation with the same `major` and `minor ≥ minMinor` + is registered. The install manifest's plugin versions are **ranges with the + same semantics** (required major, minimum minor), not exact pins. + An incompatible service registration is **quarantined** — recorded, not + installed — and surfaced as `abi_mismatch`; an incompatible plugin as + `bundle_partial`. **First-wins applies only among compatible + registrations.** The old-deferred-bundle + new-core scenario therefore has a + deterministic verdict: the old plugin either satisfies the manifest range + and runs, or is quarantined loudly. +- Stateful services only via the registry at call time; stateless helpers may + be imported and inlined. Single-module-graph builds remain the successor + option behind the same surface. + +### G4 — Render lifecycle + +**G4a — Request-cycle protocol (no ordering assumptions).** GPT documents no +per-refresh identity and no completion-order guarantee, so the protocol +assumes neither: + +- Every observable request initiation is classified `ts | publisher`: TS's own + `display()`/`refresh()` calls open TS cycles; the wrapped publisher + entry-points and `slotRequested` events that match no TS cycle are recorded + as publisher-initiated. +- **TS serializes itself to at most one outstanding cycle per slot** — a new + TS refresh for a slot with a pending cycle waits or supersedes explicitly; + it is never concurrently pending. +- With ≤1 TS cycle outstanding, a `slotRenderEnded` is attributable iff no + untracked or publisher-initiated request overlaps it. **Any overlap marks + the slot `cycle_unattributable` and fails closed** (no fallback, no state + transition, console warning + disposition). +- `slotRenderEnded` is treated as "creative code injected", not "resources + loaded" — it can confirm delivery, never paint. +- The deterministic PUC/message harness exercises the protocol in CI, and a + **release-gating real-GAM overlap test** (publisher refresh racing a TS + cycle) validates the contract against actual GPT, since a FIFO-assuming + stub proves nothing. + +**G4b — Acknowledgement path.** Unchanged from revision 3 (review-accepted): +per-attempt CSPRNG nonce in the bridge response; the dynamic renderer posts +versioned accepted/failed messages to the kernel; the kernel validates source +ownership, nonce, token, `nav_gen`, `refresh_gen` before any transition or +callback; pinned for SSAT, client-Prebid, and nested SafeFrame flows. + +**G4c — Honest observations, `render_confirmed` removed.** The inline-adm +frames are sandboxed `srcdoc` documents with `allow-same-origin` deliberately +omitted (`gpt/index.ts:358`) — their origins are opaque, so revision 3's +"same-origin observable" premise was false. The event is removed entirely. +The taxonomy is now: `gam_nonempty`, `gam_empty`, `renderer_document_loaded`, +`runner_loaded`, `runner_failed`, `adm_document_loaded` (the iframe `load` +event for TS-written adm frames — document delivery, not paint). **Every +render path terminates at `render_accepted`** (authenticated per G4b where +the renderer protocol exists; `adm_document_loaded` for adm frames). No +observation claims paint. A future trusted completion acknowledgement (open +question 6) may reintroduce a confirmed state under a new name. + +**G4d — Win/billing notifications, scoped to paths that have them.** APS +**intentionally carries neither** `nurl` nor `burl` (`aps.rs:812` sets both +`None`; the minimized AAX envelope excludes notifications; the integration +guide documents that generic win/billing beacons are not fired for APS). APS +billing runs entirely inside the Amazon runner lifecycle, and this design +does not change the APS wire contract. + +For bid paths that do carry the URLs (PBS and other OpenRTB providers): + +- **Trigger semantics, published explicitly:** `nurl` fires when the render + attempt binds the bid to a cycle (Trusted Server's selection produced the + candidate GAM will render — the earliest point at which "win" is + meaningful for this pipeline); `burl` fires at the attempt's + `render_accepted`. Both are **attempt-scoped**, keyed by + `(trace_id, nav_gen, slot, refresh_gen, hb_adid)` as the idempotency key — + fired at most once per attempt, not page-wide. +- **Owner and mechanics:** the client render pipeline owns firing (as today, + `gpt/index.ts:459`), via `sendBeacon`/`no-cors fetch`, no retries (a beacon + either queues or is lost; retrying risks double-billing). +- Terminal failure after acceptance is labeled `billed_then_failed`; no + un-firing. + +**G4e — Fallback trigger.** The opt-in fallback +(`[auction].client_render_fallback = "renderer"`) renders only after a +**terminal `gam_empty` unambiguously attributed to a TS-initiated cycle** +(G4a). Timeouts are diagnostics-only and never render. Revision 3 disabled +fallback for adopted slots entirely, which — as the review noted — excludes +the common production path (pre-existing publisher slots are adopted and +refreshed, `gpt/index.ts:925`). Revised: **ownership does not gate the +fallback; attribution does.** An adopted slot whose attributed TS cycle ends +in `gam_empty` may fall back; any publisher-initiated or unattributable cycle +never triggers it. The success criteria and browser specs cover the adopted +case explicitly. ### G5 — Deployment contracts -- **Config rollback:** every new auction/config field is default-valued and - omitted from serialization at its default (`auction_config_types.rs:7` - denies unknown fields), so blobs written by a new binary remain readable by - the previous one unless an operator opts in. -- **Asset identity is path-based and retained.** A query hash the handler - ignores (`tsjs.rs:3`, `publisher.rs:294`) is not content addressing, and - redirecting an old hash to current bytes just executes new code under old - HTML, bootstrap flags, and ABI expectations. Contract: the content hash - moves into the **pathname** (`/static/tsjs//.js`); the server - serves through a hash→bytes manifest that **retains prior artifacts** beyond - the maximum HTML cache lifetime plus the deferred-load window (retention - floor: 7 days); `Cache-Control: immutable` only on exact hash matches; - unknown hashes answer `410 Gone` with `no-store` — never a redirect to - different bytes. Precomputed concatenations are keyed by the **ordered - module-ID vector** (order affects side effects), not the set. -- **Ingest routing:** the beacon route exists in all four adapters (Fastly, - Axum, Cloudflare, Spin) as an early, EC-free, filter-free route. Only Fastly - has a real sink today; the others accept-count-drop by explicit contract. -- **Storage:** a new dedicated datasource named `ts_client_events`; retention - **30 days**; production sampling default **10%** (operator-tunable); - schema versioned with the event enum. -- **Phase ordering** is section 8's; each phase ships behind a feature flag - with the named canary thresholds and rollback criteria in section 8. +- **Config rollback:** new config fields are default-valued and omitted from + serialization at defaults. **Rollback runbook rule:** after an operator has + opted into a new field, rolling the binary back requires restoring the + default and pushing the default-compatible blob first (this mirrors the + project's existing rollback guidance). +- **Asset identity and the artifact source (settling "not realizable"):** + hash in the pathname; and artifacts are **published to shared immutable + platform storage (KV/config store) as deploy stage 1, before any HTML + references them** — binaries serve the current vector from embedded bytes + (fast path) and everything else by hash lookup in shared storage. This + answers all four skew cases: new HTML hash `B` reaching an old instance + (lookup serves `B` from storage), a miss for retained `A` after only `B` is + embedded (lookup), already-issued legacy query-hash URLs (the legacy path + keeps serving current bytes with short-TTL, non-immutable caching through a + documented sunset), and renderer `/v2` reaching a `/v1`-era instance + (versioned renderer documents are published to the same storage in + stage 1). Two-stage deployment is the contract: **stage 1 publish + artifacts, stage 2 roll binaries/HTML.** Both rolling directions and the + legacy URL are tested. Retention: ≥ 7 days, which must exceed the HTML + cache lifetime — itself now bounded by contract (auction-bearing HTML is + `no-store` per G1; any cacheable non-auction HTML referencing tsjs sets + `max-age ≤ 300`). `Cache-Control: immutable` only on exact hash matches; + unknown hashes → `410 Gone`, `no-store`. Concatenations are keyed by the + **ordered module-ID vector**, precomputed in Phase 0 (which owns asset + identity). +- **Ingest routing:** the beacon route exists in all four adapters as an + early, EC-free, filter-free route; only Fastly has a real sink; others + accept-count-drop by explicit contract. +- **Storage:** datasource `ts_client_events`; retention 30 days; production + sampling 10%. These are **adopted defaults** (operator-tunable), no longer + open questions; the remaining open question is only whether non-Fastly + adapters get sinks (OQ5). +- **Phase gates are phase-specific** (section 8) — the render-fail canary + applies only from Phase 3 onward, because earlier phases don't create that + metric. --- ## 5. Workstream 1 — Observability -### 5.1 Event payload — minimized by design - -High-cardinality identifiers stay out of the beacon: no raw `hb_adid`, no raw -Prebid `adId`, no free-form slot strings. +### 5.1 Event payload — minimized, grouped per trace ``` -{ v: 1, events: [ - { trace_id, sampled, nav_gen, refresh_gen, seq, - t: "bid_received" | "targeting_set" | "bridge_request" | - "bridge_response_sent" | "render_attempt" | "render_accepted" | - "render_confirmed" | "render_fail", - slot, // configured slot id if in the injected slot set, else "s" - id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" - matched, // bridge_request only: token/id equality result - source, // "renderer" | "adm" | "pbs-cache" | "gam" - reason } // render_fail only: closed enum below +{ v: 1, traces: [ + { trace_id, sampled, capability?, // capability: only in diagnostic mode + events: [ + { nav_gen, refresh_gen, seq, + t: "bid_received" | "targeting_set" | "bridge_request" | + "bridge_response_sent" | "render_attempt" | "render_accepted" | + "render_fail", + slot, // configured slot id if in the injected set, else "s" + id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" + matched, // bridge_request only + source, // "renderer" | "adm" | "pbs-cache" | "gam" + reason, // render_fail only: closed enum below + width, height } // invalid_dimensions context only: bounded ints [0, 8192] + ] } ] } ``` -- Every stored string is either a member of a server-known allowlist (slot ids - from the injected config, enum members) or a bounded ordinal — nothing free - .form is persisted. -- Reason enum: `renderer_document_no_load`, `runner_no_load`, `runner_failed`, - `descriptor_invalid`, `bridge_id_mismatch`, `cycle_unattributable`, - `bridge_claim_timeout`, `gam_empty`, `no_render_source`, `slot_unresolved`, - `gpt_absent`, `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, - `abi_mismatch`. (`renderer_no_ready` from revision 2 is split by the G4b/6.6 - protocol into document-load vs runner-load failures.) +- **Events are grouped per trace, and the diagnostic capability is a per-trace + field** — a navigation-spanning batch carries one group per trace, so one + batch-level capability can never be ambiguous, and an initial-navigation + capability never authorizes a client-minted SPA trace (that trace's + capability comes from the page-bids response, G1). +- Reason enum (closed; no interpolation — dimension context travels in the + bounded numeric fields): `renderer_document_no_load`, `runner_no_load`, + `runner_failed`, `descriptor_invalid`, `invalid_dimensions`, + `bridge_id_mismatch`, `cycle_unattributable`, `bridge_claim_timeout`, + `gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, + `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`. ### 5.2 Transport -`fetch(..., {keepalive: true, credentials: "omit"})` primary; `sendBeacon` as -the documented last-resort `pagehide` fallback (credentialed by platform -design, and its `true` means queued, not received — the handler ignores -credentials either way). Flush on `visibilitychange`/`pagehide` and every 5 s. - -### 5.3 Ingest wire contract (numeric, complete) - -- Route: `POST /_ts/client-events`, registered in all four adapters before - auth/EC/filters. Content type: `application/json` only (no - `Content-Encoding`; compressed bodies rejected). Responds - `204 Cache-Control: no-store`. -- Limits enforced **before parse or log**: body ≤ **16 KiB**; ≤ **64** events - per batch; any string field ≤ **64** chars; `trace_id` must match - `^[0-9a-f]{32}$`; `nav_gen`/`refresh_gen`/`seq` are integers in - `[0, 2³¹)`. Violation → `204` (accepted-and-dropped) + abuse counter; the - endpoint never echoes input. -- Same-origin enforcement: `Sec-Fetch-Site: same-origin` when present; - otherwise `Origin` must match the serving host; **absent both → reject** - (drop-and-count). All strings are structurally serialized (never - interpolated into log lines). -- Client IP for rate limiting is derived per adapter from its documented - trusted source (Fastly: the platform client IP; Axum: configured trusted - proxy header; Cloudflare/Spin: platform equivalents). Rate limiting uses the - platform limiter where one exists (Fastly); portable adapters ship a - best-effort in-memory limiter and the policy is **fail-open with an abuse - counter** (dropping telemetry must never block ad delivery). -- **Diagnostic mode is a server-injected capability, not a query flag.** The - tester gate (cookie) is evaluated server-side at HTML render; the page - receives a short-lived signed capability token (HMAC over - `trace_id + expiry`, ≤ 15 minutes) which the client echoes in the batch. - The ingest handler verifies the signature — this works with - `credentials: "omit"` because the capability travels in the payload, and a - public query flag alone can never switch a session to unsampled. +`fetch(..., {keepalive: true, credentials: "omit"})` primary. The `pagehide` +fallback is `navigator.sendBeacon(url, new Blob([json], {type: +"application/json"}))` — the Blob type satisfies the ingest media-type +contract (a bare string would arrive as text); it is credentialed by platform +design and its `true` means queued, not received; the handler ignores +credentials either way. Flush on `visibilitychange`/`pagehide` and every 5 s. + +### 5.3 Ingest wire contract + +- `POST /_ts/client-events` in all four adapters, before auth/EC/filters. + `Content-Type: application/json` only; no `Content-Encoding`. Responds + `204 Cache-Control: no-store`; never echoes input. +- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars; + `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`; width/height in + `[0, 8192]`. Violation → drop-and-count with `204`. +- Same-origin: `Sec-Fetch-Site: same-origin` when present, else `Origin` + matching the serving host; **absent both → drop-and-count**. +- **Rate limiting fails closed for telemetry:** when the limiter denies, or + when a portable adapter's best-effort limiter is unavailable or errors, the + request is dropped early with `204` (count only, no parse, no sink). Ad + delivery is unaffected by construction because this route serves nothing. +- **Trusted client address, per adapter, concretely:** Fastly — the + platform's client IP API; Axum — the rightmost `X-Forwarded-For` entry + beyond `trusted_proxy_hops` (a required config value when the beacon is + enabled; without it, the socket peer address is used and forwarded headers + are ignored); Cloudflare — `CF-Connecting-IP`; Spin — the platform client + address. Spoofable headers are never trusted beyond the configured hop + count. +- **Diagnostic capability:** server-issued, HMAC over + `trace_id + expiry` (≤ 15 min), delivered via the G1 boot field (initial + trace) or the page-bids response (SPA traces), echoed per trace group. + Signature verification is what switches a trace to unsampled — a public + query flag alone never does. ### 5.4 Two modes, honestly separated -- **Production telemetry:** sticky-sampled (default 10%), SLO: **a delivery - failure mode affecting ≥ 1% of impressions is visible in `ts_client_events` - within one hour**. -- **Diagnostic mode:** capability-gated, unsampled, full event stream plus - console mirroring — the "one page load names the failing reason" tool. +- **Production telemetry** (sink-backed deployments only — today Fastly): + sticky-sampled (10%). SLO, fully parameterized: a failure mode affecting + ≥ 1% of **sampled render attempts** is visible in `ts_client_events` within + one hour, evaluated only when the deployment produced ≥ 10,000 sampled + render attempts in that hour, with sink ingestion freshness ≤ 5 minutes; + sink outages pause the SLO clock and are alarmed separately. Non-sink + adapters are explicitly out of SLO scope, and no global gate depends on + their beacon data. +- **Diagnostic mode:** capability-gated, unsampled, full stream + console + mirroring — the "one page load names the failing reason" tool. ### 5.5 Server-side drop-reason surfacing -- Emit a bounded structured summary **whenever any bid is dropped** (per-slot - reason counts, capped), not only when zero survive. -- Add `drop_reasons` to auction telemetry rows; add the drop summary to the - initial-HTML `ts-debug` comment; `/_ts/page-bids` (JSON) gains a gated - structured `debug` field under the same tester gate. -- Startup validation warnings: APS enabled while - `allow_script_creatives = false`; any direct provider configured alongside a - mediator without the 6.1 merge strategy. +- Bounded structured summary whenever **any** bid is dropped (per-slot reason + counts, capped); `drop_reasons` added to auction telemetry rows; drop + summary in the initial-HTML `ts-debug` comment; `/_ts/page-bids` gains a + tester-gated structured `debug` field. +- Startup warnings: APS enabled with `allow_script_creatives = false`; direct + provider configured alongside a mediator without 6.1's merge strategy. --- @@ -396,128 +407,77 @@ credentials either way). Flush on `visibilitychange`/`pagehide` and every 5 s. ### 6.1 Mediation: opt-in merge, `mediator_only` stays the default -- `[auction].winner_selection = "mediator_only"` (default, today's behavior, - now explicit) or `"merge_highest_cpm"` (opt-in); omitted from serialized - blobs at the default (G5). -- `merge_highest_cpm` semantics: comparison in decoded CPM; **currency - mismatch is a rejection** (the mismatched bid is dropped with a selection - reason; no conversion in v1); slot floors apply to both populations; ties - break to the mediator; a mediator timeout degrades to direct-provider - selection and is reported. -- **Deduplication key:** the server constructs the mediator's input, so it - records provenance at forwarding time — `(provider_name, upstream_bid_id)` - per candidate — and carries a provenance map keyed by the id it sent. - A mediator bid whose id maps back to a forwarded candidate counts once, as - provenance `mediator`. A mediator bid whose id was **transformed beyond the - map** is treated as distinct mediator demand (documented limitation). -- **Deal priority is out of scope for v1.** The internal `Bid` - (`types.rs:231`) carries no deal identity; inventing a priority rule the - model cannot express would be fiction. Extending the bid model with - `deal_id`/deal type and a deal-first rule is recorded as follow-up work; - until then deals compete by CPM like everything else and the limitation is - documented in the config reference. -- One candidate-selection helper serves **both** mediation lifecycles - (ordinary/page-bids, `orchestrator.rs:412-431`; initial-SSAT split - dispatch/collect, `orchestrator.rs:1320`), with tests for each. -- A **selection report** (`winner_source`, `mediator_superseded`, - `currency_rejected`, dedup hits) is emitted separately from - delivery-conversion drop reasons. +As revision 3 (review-accepted), with one tightening: a mediator bid whose id +the provenance map cannot resolve, **for a slot where the same provider had +forwarded candidates**, is counted under a dedicated +`mediator_provenance_unresolved` metric and logged at `warn` — surfacing +possible self-competition instead of silently treating it as distinct demand. +Deal priority remains out of scope (the `Bid` model carries no deal identity; +recorded as follow-up). Currency mismatch remains rejection. One selection +helper serves both mediation lifecycles, with tests for each. ### 6.2 Dimensions: the contract is "request what you accept" -Revision 2's operator `accept_sizes` allow-list is withdrawn on the review's -sharper observation: if an alternate size is acceptable, it belongs in the -slot's **requested formats** — APS should be asked for it. Accepting an -unrequested response size would conceal an upstream protocol violation. - -- Exact size membership (`aps.rs:657-668`) remains the admission rule, - unchanged. -- The fix is configuration plus visibility: the drop summary (5.5) names the - rejected size per slot (`invalid_dimensions{300x600}`), so an operator sees - exactly which format to add to the slot's `formats` if they want that - demand. Documentation gains a "sizing your slots for APS" section. -- No `hb_size` key, no admission relaxation, no new config. +Exact size membership stays. The fix is visibility plus configuration: the +drop summary names the rejected size per slot via +`reason: invalid_dimensions` with bounded numeric `width`/`height` fields +(closed enum preserved), and documentation gains "sizing your slots for APS." ### 6.3 Script creatives -Keep the secure default (`allow_script_creatives = false`) but make the -consequence loud (5.5) and document the enablement path for TAM-heavy -publishers. +Secure default kept; consequence made loud (5.5); enablement path documented. ### 6.4 Render identity -Implemented exactly as G2 (token scope, format, TTL, per-navigation registry -keying, one-time consumption, cache-path regression tests). +As G2. ### 6.5 Fallback rendering -Implemented exactly as G4e: renders only on an attributed terminal -`gam_empty`; timeouts are diagnostics-only; disabled for adopted slots. The -direct renderer is converted to an awaitable API with cancellation and a -terminal reason first; the fallback lands only after that conversion. +As G4e — attribution-gated, not ownership-gated; timeouts never render; the +renderer is converted to an awaitable API with cancellation and terminal +reasons before the fallback lands. ### 6.6 Renderer endpoint — unconditional, versioned, observable -Topology is resolved now rather than left conditional: - -- **The static renderer document route registers unconditionally in every - adapter.** It contains no configuration, no secrets, and validates its input - client-side; serving it cannot leak anything, and conditional registration - is exactly what created the silent cross-deployment failure class. (The APS - _provider_ stays config-gated; only the static document is unconditional.) -- The document is **versioned in its path** - (`/integrations/aps/renderer/v1`) and served `Cache-Control: no-store`; - descriptor compatibility across N/N−1 is guaranteed by the outer-tolerant - validation of 6.7. The client pins the version it targets. -- Startup validation fails loudly if any configured auth handler pattern - covers `/integrations/aps/renderer`. -- **Two-stage acknowledgement (with G4b):** the document first posts an - authenticated `document_loaded` (proving route + auth + CSP allowed the - document itself), then the separate runner-load result. This splits the old - blind timeout into `renderer_document_no_load` (route/auth/stale-CDN/network) - vs `runner_no_load` / `runner_failed` (Amazon script or CSP) — distinct - signals, as the success criteria require. -- Server-side: route status/version counters (requests, unknown-version, - auth-blocked) join the telemetry rows. -- **CSP changes ship report-only first** (`Content-Security-Policy-Report-Only` - canary with a bounded report endpoint), then enforce; each added source is - justified in a comment and covered by the browser spec. Tests cover broad - auth patterns, stale versions, and CSP failures on all adapters. - -### 6.7 One descriptor schema — structural generation, semantic validators kept - -- The wire truth is the tagged enum `BidRenderer` (discriminator lives there, - not on `ApsRendererV1` — `types.rs:188-211`); the generated schema is the - full tagged envelope. -- Generation lives in a **separate wire-schema crate** (or host-side xtask) — - core already depends on `trusted-server-js` (`Cargo.toml:45`), so the - reverse edge would be a cycle. Generated TS artifacts are checked in; CI - fails on staleness. -- Generation covers structure only; the semantic security checks stay - hand-written on both sides (URL/origin policy, canonical base64, length - bounds, the exact one-bid envelope projection, cross-field equality). - **Unknown-field tolerance applies only to the outer versioned descriptor; - the decoded AAX envelope remains an exact projection.** -- A shared positive + adversarial corpus runs through the Rust validator, the - TS validator, and the inline renderer document in CI. +- The static renderer document route registers unconditionally in every + adapter (the APS provider stays config-gated). Startup validation fails + loudly if an auth handler pattern covers it. +- **Caching matches immutability:** `/integrations/aps/renderer/v1` is an + immutable artifact — its bytes change only by shipping `/v2` — so it is + served with `Cache-Control: immutable` (long max-age), published to shared + artifact storage in deploy stage 1 like every versioned asset (G5), which + also answers version-skew (`/v2` requests reaching older instances are + served from storage). Revision 3's `no-store` contradicted the versioning + and is corrected. +- Two-stage acknowledgement (G4b): authenticated `document_loaded`, then the + runner-load result — splitting `renderer_document_no_load` from + `runner_no_load`/`runner_failed`. +- **Server route counters are aggregate** (requests, unknown-version, + auth-blocked): the document request carries no trace (the nonce travels in + the URL fragment, which never reaches the server), so no row-level join is + claimed. +- **CSP report-only canary, fully specified:** reports go to a dedicated + `POST /_ts/csp-reports` route (same pre-parse caps and same-origin rules as + 5.3; credentials ignored; rate-limited fail-closed); stored as **aggregate + counters only** (directive, blocked-origin **host only** — full URLs + redacted) on sink-backed adapters, count-and-drop elsewhere; enforcement + follows only after a clean canary window. + +### 6.7 One descriptor schema + +As revision 3 (review-accepted): tagged-envelope schema generated from a +separate wire-schema crate/xtask; semantic validators hand-written on both +sides; outer-tolerance only, exact AAX projection; shared +positive + adversarial corpus across Rust, TS, and the inline document; +staleness CI. ### 6.8 Bridge hardening -- **Ownership proof stays source-first**, and the SafeFrame extension is - bounded: the kernel maintains a map of known slot-root `WindowProxy` objects - (the iframes GPT created under each slot element); on a message, it walks - the **sender's own parent chain** (`event.source.parent`, …) up to depth - **5**, looking for a known root — it never enumerates or recursively scans - an attacker-controllable frame tree. Unresolvable source → refuse with - `bridge_id_mismatch`. -- Adversarial tests: wrong-slot tokens, nested foreign frames, replayed and - duplicated messages, stolen tokens, previous-navigation tokens, plus the - positive SafeFrame case. -- Blanket top-of-listener hygiene: parse and ownership-check before branch - logic. -- Delete the dead duplicate renderer branch (C5); renderer branches emit the - same trace records as adm/cache branches under G4's taxonomy and the G4d - `nurl`/`burl` split (C7). +As revision 3 (review-accepted): source-first ownership with the bounded +parent-chain SafeFrame walk (depth 5, known slot-root `WindowProxy` map, no +tree scans); adversarial test set; top-of-listener hygiene; dead branch +deleted; renderer branches emit trace records under G4's taxonomy, with G4d +notifications only where the bid path carries them (never APS). --- @@ -525,283 +485,211 @@ Topology is resolved now rather than left conditional: ### 7.1 Layering -``` -kernel/ boot, config, queue, event bus, log, beacon, sessions -adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access -services/ slots (registry+handoff), auction client, render engine, consent -integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services) -``` - -Boundary lint in CI (`import/no-restricted-paths`): `kernel` imports nothing -above it; `adapters` import kernel only; `services` import kernel + adapters; -`integrations` import kernel + services, never each other. Stateful services -via the G3 ABI only. +Kernel / adapters / services / integrations exactly as revision 3, with the +boundary lint in CI. Stateful services via the G3 ABI only. -### 7.2 Adapters: explicit absence, without giving up on late loaders +### 7.2 Adapters -`present | pending | timed_out` per external global; `timed_out` is -non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and drains -what is still valid); individual queued operations carry their own timeouts -and expire with a disposition reason. +`present | pending | timed_out` with non-terminal `timed_out` (late loaders +transition to `present`); per-operation timeouts with disposition reasons. ### 7.3 Slot registry service -One registry owns slot knowledge (publisher- vs TS-defined, adoption, handoff -claims, responsive resolution, pending request cycles per G4a, targeting-key -history), keyed by `WeakMap` plus a div-id index, -kernel-owned via the ABI. Expandos on live GPT objects are eliminated. +Kernel-owned registry (`WeakMap` + div-id index) +holding ownership, adoption, handoff claims, responsive resolution, pending +request cycles (G4a), and targeting-key history. No expandos on GPT objects. ### 7.4 Global namespace policy — with a compatibility window -- One owned global, `window.tsjs`; public API versioned; coordination state - under `tsjs._internal` (G3). **The public queue keeps its existing name: - `window.tsjs.que`** (`types.ts:259`, drained at `core/index.ts:25`) — - revision 2's `cmd` was an error; renaming a public surface silently would - violate this very section. -- Inventory first: every current global classified public - (`globalThis.tscreative`, `tsCreativeConfig`, `tsjs.que`) or private - (`__tsjs_*` flags, expandos, sentinels). -- **Public globals: dual-read/write for a bounded window — two release - cycles, minimum 60 days — ending only after an adoption gate: beacon-observed - old-name usage below 0.1% of traces for 14 consecutive days.** Pre-init - compatibility tests pin that config set before the bundle loads keeps - working. -- Private globals migrate immediately: dead expando writes deleted now; slot - state into `SlotRecord`; function sentinels into a kernel `WeakSet`; boot - flags into `tsjs` boot fields. -- `requestAds` keeps its void signature; failure surfacing arrives as a **new - versioned async API** (`tsjs.requestAdsAsync(...): Promise`) - rather than changing the existing contract. +As revision 3 (review-accepted): `window.tsjs` + `tsjs._internal`; the public +queue keeps its real name **`tsjs.que`**; public globals +(`tscreative`, `tsCreativeConfig`, `tsjs.que`) get dual-read/write for two +release cycles / ≥ 60 days, closing only on the adoption gate (old-name usage +< 0.1% of traces for 14 days, measured on sink-backed deployments); +`requestAds` keeps its void signature; `requestAdsAsync` is the new versioned +API; private globals migrate immediately. ### 7.5 Messaging module -All `postMessage` traffic through one module: versioned envelopes, message -name constants, the G4b acknowledgement nonces, source validation per 6.8, one -audit point. +All `postMessage` through one module: versioned envelopes, name constants, +G4b nonces, 6.8 source validation. A **minimal** messaging module (envelope + +constants + validation helpers used by the bridge) lands early (Phase 1) so +Phase 3 does not depend on Phase-4 structure; the full migration of every +legacy call site completes in Phase 4. ### 7.6 Plugin lifecycle and session model -- **Activation:** Rust owns integration selection today and continues to — the - server injects a **versioned install manifest** (enabled plugin ids + - expected versions, in injection order) into the pre-core `tsjs.que`. The - kernel executes the manifest on boot; nobody else calls install in - production (the API remains callable for tests). -- `tsjs.definePlugin(id, version, install, dispose)`: synchronous `install` - by default; a plugin may return a promise, but anything `adInit` depends on - (gpt, prebid shim registration) must complete synchronously and is listed as - such in the manifest. Late registration after the manifest requested the id - installs on arrival (pending-install queue) bounded by a missing-module - timeout emitting `bundle_partial`. A stale async completion (arriving after - its `RuntimeSession` was disposed) is discarded. Duplicate `(id, version)` - is a no-op; a different version for a registered id: first-wins + loud - telemetry. Disposal runs in reverse install order. -- **Session model, split as the review required:** - - `RuntimeSession` (page lifetime): bridge listener, history hook, pbjs - subscriptions, adapters, beacon queue. - - `NavigationSession` (per SPA navigation): `trace_id`, render attempts, - slot aliases, targeting history, navigation-scoped timers/observers. - - `RenderAttempt` (per G4a cycle): state machine instance, ack nonce. - Each owns an **enumerable** disposal inventory; navigation disposes - `NavigationSession` children only. -- Per-plugin exception isolation: a plugin that throws during install is - quarantined and reported; it cannot halt the bundle. -- Error policy: no empty `catch`; every catch handles, logs with context, or - emits a disposition reason. The auction fetch gets timeout + - `AbortController`. -- **Console logging is retained, not replaced.** Every issue-surfacing - condition keeps (or gains) a `log.warn` debuggable from DevTools; existing - warnings survive verbatim or strengthened; `debug`-level failure paths that - indicate delivery or security-relevant failures are promoted to `warn`; - every `render_fail`/dependency-timeout disposition emits a paired `warn` - with the same reason code. +As revision 3 (review-accepted), with G3's sharpened version semantics: +manifest versions are ranges (major + minMinor); quarantine on +incompatibility; first-wins only among compatible. Sessions: +`RuntimeSession` / `NavigationSession` / `RenderAttempt` with enumerable +disposal inventories. Error policy: no empty `catch`; auction fetch gets +timeout + `AbortController`. **Console logging retained, not replaced** +(paired `warn` with the beacon's reason code; `debug`-level +delivery/security failures promoted to `warn`). ### 7.7 The bootstrap problem -Target: shrink the inline `gpt_bootstrap.js` to a queue-and-flags stub with -the bundle replaying recorded calls on install. This is **not a pure move** — -replay changes observable ordering — so it ships behind its own flag with -browser specs extended to cover replay timing, and the no-bundle fallback -("ads still render if the bundle fails", pinned by `gpt.rs:1174-1179`) is -**generated from the same TypeScript source** at build time. +As revision 3: queue-and-flags stub + bundle replay behind its own flag with +replay-timing specs; the no-bundle fallback generated from the TypeScript +source. ### 7.8 GPT correctness fixes carried with the restructure -- Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify - against open PR #997; land whichever is canonical). -- `changeCorrelator: false` on TS-initiated refreshes; correlator behavior - becomes a documented, configurable decision. +- **Unconditional early GPT event subscription:** the `slotRenderEnded` + (and `slotRequested`) listeners register on the command queue at install, + no longer gated behind `!ts.servicesEnabled` (`gpt/index.ts:1017`) — G4a + cannot work on bootstrap-first pages otherwise. Recording is idempotent so + double-registration cannot double-count. +- Restore #922/#997 attribution and orphan recovery. +- `changeCorrelator: false` on TS-initiated refreshes (configurable). - `enableSingleRequest()` only when GPT services are not already enabled. -- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` - alongside its console warning. +- Ambiguous responsive resolution emits `render_fail{slot_unresolved}`. ### 7.9 Decomposition targets -| Today | Target | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | -| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (moves onto slot registry), eids, diagnostics | -| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | -| `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | -| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state | +As revision 3 (gpt/prebid splits, script-guard consolidation, trace model/UI +split, `global.d.ts` fix). ### 7.10 Performance -Client-side speedups with enforced budgets: smaller synchronous bundle -(script-guard consolidation, single APS module via the ABI, dead-code -deletion, trace-overlay extraction); slot resolution once per navigation; -bounded telemetered waits. Budgets, concretely: per-bundle raw/gzip/Brotli -bytes for the exact ordered module vector of the reference config, compared -to a checked-in baseline with **+5% tolerance**; the -bids-script-to-first-`display()` browser assertion runs **20 iterations** and -gates on **p90**. Server-side: precompute bundle bytes + hash per ordered -module vector (deploy/config-time), benchmark server CPU/heap before/after. +Budgets tightened per review: per-bundle raw/gzip/Brotli for the exact +ordered module vector vs a checked-in baseline, **+5% byte tolerance**; +browser timing assertion (bids-script-to-first-`display()`) on a **pinned CI +runner class and pinned browser version**, **5 warm-up runs discarded, 50 +measured samples, gate on p90 with +10% latency tolerance**; server bench +(precomputed concatenation) gates CPU and heap at **±10% vs baseline** with +pinned tool versions. Precompute lands in Phase 0 with asset identity (G5). ### 7.11 Toolchain and dependency currency -- **TypeScript:** the manifest floor is `^5.5.4` but the lockfile already - resolves **5.9.3** (`package-lock.json`), so the code compiles on 5.9 today. - Action: raise the manifest floor to the resolved 5.9 line (making the floor - honest), then evaluate the next major as its own gated PR; adopt - `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, - `verbatimModuleSyntax`. -- **Dev toolchain to latest stable** (eslint + plugins, prettier, jsdom, - `@playwright/test`, `@types/node` aligned to the pinned Node), each bump its - own mechanical CI-gated PR with changelog review — this library monkeypatches - `fetch`, `sendBeacon`, and DOM prototypes, so jsdom/Playwright behavior - changes are real risks. -- **`prebid.js` is excluded from casual bumps** (runtime Prebid is the - manifest-locked external R2 bundle); the npm pin and deployed bundle version - stay documented together. -- **Standing policy:** monthly dependency review; no migration phase starts - more than one minor behind latest stable. +As revision 3 (review-accepted): raise the TypeScript floor to the resolved +5.9 line, then evaluate the next major separately; strictness flags on; dev +toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from casual +bumps; monthly review policy. --- ## 8. Migration plan -Ordered so every phase's prerequisites precede it (the review's required -order). Every phase ships behind a feature flag; shared canary criteria: no -increase in `render_fail` rate beyond **+0.5% absolute** on canary traffic -over 24 h, no new console errors in the browser-spec run, rollback = flag off -(phases 0–2 are additive; later phases keep dual paths until their gate). - -- **Phase 0 — Asset identity, contracts, toolchain.** Path-based immutable - asset identity + manifest retention + rolling-deploy tests (G5) — this lands - **before** anything makes ABI compatibility load-bearing. Toolchain floors - (7.11). Contracts G1–G5 recorded as code-adjacent docs. Delete the dead - expando writes. Server drop-reason surfacing (5.5) — server-only, no client - dependency. -- **Phase 1 — Kernel ABI and sessions.** Minimal kernel in `tsjs-core` - publishing the versioned registry (G3); `RuntimeSession` / - `NavigationSession` scopes (7.6); context-provider fix as the ABI proof; - install manifest injection. -- **Phase 2 — Trace and beacon.** Server correlation nonce for `nav_gen 0` + - page-bids trace echo (G1); beacon service on the Phase-1 kernel; ingest - route in all four adapters (5.3); diagnostic capability; `ts_client_events` - datasource. -- **Phase 3 — APS delivery.** Wire-schema crate + corpus (6.7); mediation - helper + opt-in merge (6.1); render token (6.4); unconditional versioned - renderer route + two-stage ack (6.6, G4b); bridge hardening (6.8); GPT - request-cycle protocol + render state machine + awaitable renderer + - `nurl`/`burl` split (G4a–G4d); the opt-in fallback (G4e); restore #922/#997 - attribution; correlator and SRA fixes (7.8). APS renders after this phase - wherever GAM line items and configuration permit. -- **Phase 4 — Structure.** Full layering + boundary lint, plugin lifecycle - completion, adapters, full slot registry, messaging module, namespace - migration window (7.4), server bundle precompute (7.10). -- **Phase 5 — Decomposition.** File splits (7.9), script-guard consolidation, - bootstrap shrink behind its own flag with replay-timing specs (7.7), end of - the public-global compatibility window (gated per 7.4). +Each phase ships behind a feature flag with **phase-specific gates** (the +render-fail canary cannot evaluate phases that predate the metric): + +- **Phase 0 — Asset identity, contracts, toolchain.** Two-stage artifact + publishing (shared immutable storage) + path-based identity + ordered-vector + precompute + rolling-deploy tests in both directions + legacy-URL test; + toolchain floors; contracts G1–G5 as code-adjacent docs; delete dead expando + writes; server drop-reason surfacing. + _Gate:_ zero unexpected `410`s and zero legacy-URL breakage on canary; asset + hit/miss counters nominal. +- **Phase 1 — Kernel ABI, sessions, minimal messaging, minimal cycle + registry.** Versioned registry with `(major, minor)` semantics; + `RuntimeSession`/`NavigationSession`; install manifest; the minimal + messaging module (7.5) and the cycle-aware slot-record core (G4a's queue) + land here so Phase 3 has its dependencies; unconditional early GPT + subscriptions (7.8). + _Gate:_ ABI install-success counters clean; zero `abi_mismatch` on canary; + no listener regression in browser specs. +- **Phase 2 — Trace and beacon.** Server-minted initial trace + page-bids + `X-TSJS-Trace-Id` echo + capability issuance (G1, 5.3); beacon service; + four-adapter ingest; `ts_client_events`. + _Gate:_ ingest acceptance/drop/abuse counters nominal; trace join rate on + sink-backed canary ≥ 95% of sampled traces. +- **Phase 3 — APS delivery.** Wire-schema crate + corpus; mediation helper + + opt-in merge; render token; unconditional versioned renderer route + + two-stage ack; bridge hardening; request-cycle protocol + render state + machine + awaitable renderer + scoped notifications (G4a–G4d); the opt-in + fallback (G4e); restore #922/#997; correlator and SRA fixes. + _Gate:_ `render_fail` rate within +0.5% absolute of pre-flag baseline over + 24 h on canary; fill/latency/billing volume deltas within agreed bounds + (billing measured against GAM/server-side reporting, not the beacon); + release-gating real-GAM overlap test green. +- **Phase 4 — Structure.** Full layering + boundary lint; plugin lifecycle + completion; adapters; full slot registry; full messaging migration; + namespace window (7.4). + _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests green. +- **Phase 5 — Decomposition.** File splits; script-guard consolidation; + bootstrap shrink (own flag, replay-timing specs); compatibility-window + close (adoption-gated). + _Gate:_ bundle budgets and timing assertions hold; adoption gate met before + any removal. --- ## 9. Test acceptance matrix -Blocking CI is hermetic (the deterministic PUC/message harness); a separate -staged smoke suite covers real GAM line items and is release-gating, not -PR-gating. - -| Area | Must cover | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Mediation | both lifecycles; ties, floors, currency rejection; duplicate demand via provenance map; transformed mediator ids; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) | -| Cache identity | non-APS cache-backed bids byte-identical (`hb_adid` + coordinates); PUC `?uuid=` fetch path | -| Render token | `^[a-z0-9]{12}$`; CSPRNG source; in-auction collision retry; 15-min TTL; one-time consumption; per-`(trace, nav_gen)` registry scoping | -| Request cycles | pending-cycle attribution; overlapping refreshes; late `slotRenderEnded`; nonempty-before-bridge-claim; `cycle_unattributable` fail-closed | -| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame flows; stale/replayed acks | -| Render semantics | no `burl` before authenticated `render_accepted`; `nurl` at selection independent of delivery; `billed_then_failed` labeling; accepted-but-blank; no `render_confirmed` from geometry or PUC container | -| Fallback | renders only on attributed `gam_empty`; timeout is diagnostics-only; adopted-slot fallback disabled; SPA cancellation; slot destruction; exactly-once terminal transition | -| Bridge security | wrong-slot tokens; nested foreign frames; replay + duplicates; stolen tokens; previous-navigation tokens; bounded parent-chain walk (depth 5); SafeFrame positive case | -| Beacon | initial-nav server nonce join; page-bids trace echo; per-event envelope across navigation-spanning batches; `(trace_id, nav_gen, seq)` dedup; ingest abuse (oversize, malformed, cross-origin, absent Origin); capability sig | -| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection | -| Runtime ABI | one kernel under concatenation; deferred registration after manifest request; per-plugin failure isolation; **mixed-version delivery (old deferred bundle + new core)**; abi-mismatch refusal | -| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); RuntimeSession vs NavigationSession disposal inventories; stale async install completion; pre-init `tsCreativeConfig` and `tsjs.que` compatibility; dual-name global window | -| Delivery | rolling-deploy simulation (old HTML + retained old assets); unknown hash → 410 `no-store`; immutable only on exact match; ordered-module-vector keying; deterministic raw/gzip/Brotli budgets vs baseline | -| Renderer endpoint | route present in all four adapters; auth-pattern startup failure; stale version behavior; CSP report-only canary; `document_loaded` vs runner-result split | -| Adapter parity | ingest + renderer routes + drop-reason surfacing behave equivalently on Fastly/Viceroy vs Axum vs Cloudflare vs Spin | -| Policy | script-creative startup warning; `invalid_dimensions{WxH}` drop naming; page-bids `debug` field gating; diagnostic-mode unsampled completeness | +Blocking CI is hermetic; the staged smoke suite (real GAM line items, +including the G4a overlap test) is release-gating. + +| Area | Must cover | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Mediation | both lifecycles; ties, floors, currency rejection; provenance dedup; transformed ids → `mediator_provenance_unresolved`; `mediator_only` default; rollback blob round-trip | +| Cache identity | non-APS cache-backed bids byte-identical; PUC `?uuid=` path | +| Render token | format/CSPRNG/in-auction retry/TTL/one-time/per-`(trace, nav_gen)` scoping | +| Request cycles | ts-vs-publisher classification; one-outstanding-TS-cycle serialization; publisher refresh overlapping TS cycle → `cycle_unattributable` fail-closed; late events; **real-GAM overlap (release gate)** | +| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame; stale/replayed acks | +| Render semantics | notifications only on carrying paths (never APS); `nurl` at bind, `burl` at accepted, attempt-scoped idempotency; `billed_then_failed`; no paint claims (`adm_document_loaded` labeling); accepted-but-blank | +| Fallback | renders only on attributed `gam_empty` (adopted **and** TS-owned); publisher-initiated cycles never trigger; timeout diagnostics-only; SPA cancellation; destruction; exactly-once terminal | +| Bridge security | wrong-slot/stolen/replayed/prior-navigation tokens; nested foreign frames; bounded parent-chain walk; SafeFrame positive | +| Beacon | initial-trace join; page-bids header echo + response trace/capability; per-trace grouping across navigation-spanning batches; `seq`-gap partial traces; ingest abuse incl. absent Origin; capability signature; sendBeacon Blob | +| Schema | staleness; adversarial corpus ×3 validators; outer-tolerance vs exact AAX projection | +| Runtime ABI | one kernel under concatenation; deferred late registration; failure isolation; **mixed-version verdicts (compatible-range install vs quarantine)**; first-wins among compatible only | +| Lifecycle | `timed_out → present`; session disposal inventories; stale async install; pre-init `tsCreativeConfig` + `tsjs.que`; dual-name window | +| Delivery | two-stage deploy simulation **both rolling directions**; new-HTML-hash on old instance (storage lookup); retained-hash miss; legacy query-hash sunset path; unknown hash 410 `no-store`; immutable exact-match only; ordered vector | +| Renderer endpoint | route in all adapters; auth-pattern startup failure; `/v1` immutable caching; version-skew via storage; `document_loaded` vs runner split; CSP report route caps/redaction | +| Adapter parity | ingest, CSP-report, renderer routes and drop-reason surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | +| Policy | script-creative warning; `invalid_dimensions` + bounded width/height; page-bids `debug` gating; diagnostic unsampled completeness; trace-bearing HTML `private, no-store` invariant | --- ## 10. Alternatives considered -1. **Keep patching APS point-failures without telemetry.** Rejected: three - consecutive correct fixes have not produced ads. -2. **Direct-render APS always (skip GAM/PUC).** Changes GAM reporting/pacing - semantics unilaterally; kept as the opt-in, `gam_empty`-gated fallback. -3. **Single module graph / shared chunks instead of the registration ABI.** - Cleaner long-term; changes the delivery pipeline now; successor option - behind the same ABI surface. -4. **Full library rewrite in one branch.** Rejected: thin browser-spec safety - net in exactly the changing areas. -5. **Drop the ES5 bootstrap entirely.** Loses the pinned "ads render if the - bundle fails" guarantee; generated fallback keeps it. -6. **Timeout-triggered fallback rendering.** Rejected (G4e): GPT requests - cannot be cancelled, so timeout rendering races late fills; only an - attributed terminal empty event may trigger rendering. +Unchanged from revision 3 (patching without telemetry; always-direct-render; +single module graph; big-bang rewrite; dropping the bootstrap; +timeout-triggered fallback — all rejected for the recorded reasons). ## 11. Risks -- **Merge strategy misconfiguration:** mitigated by `mediator_only` default, - the selection report, omitted-default serialization. -- **Beacon abuse/volume:** pre-parse caps, origin checks, per-adapter rate - limiting, sticky sampling, closed enums, capability-gated diagnostics. -- **ABI freeze risk:** versioned from day one; mixed-version delivery tested. -- **Ack protocol adds a message round-trip before `burl`:** bounded by the - existing renderer timeout; the `billed_then_failed` label measures the - policy. -- **Bootstrap replay:** own flag, replay-timing specs, staged rollout. -- **Schema generation:** checked-in artifacts + staleness CI. +Revision 3's list, plus: **shared-storage dependency for assets** (stage-1 +publish becomes a deploy prerequisite; mitigated by the embedded fast path +for the current vector and deploy-time verification that storage matches the +embedded hashes); **notification-trigger semantics** are now a published +contract for PBS-path demand — changing them later is a breaking change for +SSP reporting expectations. ## 12. Success criteria -1. APS creatives render on a reference page in each configured flow (SSAT, - Prebid adapter, page-bids), hermetically in CI and via the staged smoke - suite. +1. APS creatives render on a reference page in each configured flow, + hermetically in CI and via the staged smoke suite (including the real-GAM + overlap test). 2. Every failure point in section 2 maps to a distinct observable signal; diagnostic mode names the failing reason from one page load; production - telemetry meets the 5.4 SLO (≥ 1% failure modes visible within one hour). -3. Boundary lint passes with zero exceptions; stateful sharing only via the - versioned ABI; mixed-version delivery behaves as specified. + telemetry meets the 5.4 SLO on sink-backed deployments. +3. Boundary lint zero exceptions; stateful sharing only via the versioned + ABI; mixed-version delivery resolves to the G3 verdicts. 4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. -5. Trace counts are per-impression; orphaned-slot recovery has a non-vacuous - test; `refresh_gen` attribution follows the G4a cycle protocol. +5. Trace counts are per-impression; orphan recovery has a non-vacuous test; + cycle attribution follows G4a including the publisher-overlap fail-closed + rule. 6. The only TSJS-owned global is `window.tsjs` (public globals only inside - their announced window, which closes per the 7.4 adoption gate); no - expandos on GPT slots, GPT functions, or `pbjs`. -7. Bundle budgets (raw/gzip/Brotli, +5% tolerance vs baseline) and the p90 - 20-run timing assertion hold; server-side per-request concatenation is - precomputed. -8. No existing warning is lost; every issue-surfacing condition logs at `warn` - or above with the beacon's reason code. -9. TypeScript floor matches the resolved 5.9 line with the strictness flags - on; `prebid.js` npm pin matches the documented deployed bundle; monthly - review policy in CI docs. -10. Rolling-deploy tests: old HTML always receives its exact old assets during - the retention window; unknown hashes 410 with `no-store`; immutable - caching only on exact matches. -11. `nurl` and `burl` fire on their distinct G4d transitions, idempotently, - and never before their triggering state. + their window, closed by the 7.4 adoption gate); no expandos on GPT slots, + GPT functions, or `pbjs`. +7. Bundle budgets (+5% bytes) and the pinned-environment p90 timing assertion + (50 samples, +10% tolerance) hold; server concatenation is precomputed + within ±10% CPU/heap of baseline. +8. No existing warning is lost; every issue-surfacing condition logs at + `warn` or above with the beacon's reason code. +9. TypeScript floor matches the resolved 5.9 line with strictness flags on; + `prebid.js` pin matches the documented deployed bundle; monthly review + policy in CI docs. +10. Rolling-deploy tests pass in both directions; legacy URLs serve through + their sunset; unknown hashes 410 `no-store`; immutable only on exact + match. +11. `nurl`/`burl` fire only on carrying paths, on their G4d transitions, + attempt-scoped and idempotent; APS fires neither. +12. Trace-bearing responses are `private, no-store` by test; capabilities are + per-trace and never authorize a trace they were not bound to. ## 13. Open questions @@ -812,7 +700,9 @@ PR-gating. 4. Is PR #997 the intended restoration of the lost #922 attribution core, or should the original be re-merged? 5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep - accept-count-drop? (Datasource `ts_client_events`, 30-day retention, and - 10% default sampling are proposed values pending operator sign-off.) -6. Does Amazon expose any creative-completion acknowledgement we could adopt - to move APS beyond `render_accepted` (G4c)? + accept-count-drop? +6. Does Amazon expose any creative-completion acknowledgement that could + reintroduce a confirmed state (G4c) under a new name? +7. Which shared storage backs stage-1 artifact publishing per platform (KV + store vs config store vs CDN), and who owns the publish step in the deploy + pipeline? From 09a31816abfb595c003f18f83fb80194fefa1f94 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:48:21 -0700 Subject: [PATCH 545/844] Rewrite design spec for the coordinated hard-cutover policy Revision 5 adopts the hard-cutover release model (exact release ids, config format_version, embedded-only artifacts with 410 on unknown hashes, final namespace at cutover, switch-and-purge runbook) and removes the N/N-1 machinery. It also makes the document fully self-contained and addresses the fourth review round: physical request-cycle protocol opened by slotRequested with intent separation, RuntimeSession-owned cycle state with quarantine and drain-by-match, propagation-stopping bridge order that preserves the stolen-capability defense, per-flow nurl bind definitions, a complete non-GPT direct /auction lifecycle, a fully inlined mediation algorithm with server- minted candidate ids and deterministic total ordering, a specified signed trace authorization (v1.kid.exp.mode.sig, HMAC-SHA-256, key rotation, server-decided sampling), wire events covering every G4c observation with concrete Tinybird schemas and bid_drop rows, sink dedup keys and numeric rate-limit bounds, early isolated internal routes on all adapters, a CSP rollout that cannot false-pass with both report media types and three-browser coverage, transactional plugin install with reverse-order unwind, the final global-surface table, refresh_gen-scoped token registries with capacity rules, structured dimension drops, generated inline validators, executable phase gates with control cohorts, and the expanded acceptance matrix with the section-2 failure-to-signal mapping. --- ...s-render-fix-and-tsjs-resilience-design.md | 1453 ++++++++++------- 1 file changed, 839 insertions(+), 614 deletions(-) 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 9c53fca6a..4cf8c4d4d 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 @@ -1,695 +1,921 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 4 — reworked after the third design review round. +- **Status:** revision 5 — rewritten for the coordinated hard-cutover policy + adopted in the fourth review round, and made fully self-contained (no + contract is defined by reference to an earlier revision). - **Date:** 2026-08-04 -- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything - merged from `main` plus every rc-only merge), not just the delta pending - against `main`. -- **Inputs:** three code audits performed against this baseline; design - reviews of revisions 1–3; open issues #926, #941, #944, #962, #964, #977, - #983, #989, #993; open PR #997. -- **Terminology:** the per-refresh counter is `refresh_gen` everywhere. The - event previously named `render_confirmed` is removed (see G4c). - ---- +- **Baseline:** `rc/july` @ `541298695` — the full merged state. +- **Inputs:** three code audits against this baseline; design reviews of + revisions 1–4; open issues #926, #941, #944, #962, #964, #977, #983, #989, + #993; open PR #997. + +## 0. Release policy: coordinated hard cutover + +This design targets a **single coordinated release**. Explicitly: + +- Server, TSJS bundles, config format, and page HTML ship together as one + release with one **release id** (`release_id`: the git tag / build hash). +- **No N/N−1 support.** Old pages, old bundles, old config blobs, old + globals, and old URLs may stop working at cutover. In-flight clients (pages + loaded before the switch) may fail; this is accepted and stated, not + mitigated. +- **Exact release matching only.** The kernel, every service, every plugin, + and the install manifest carry the same `release_id`; any mismatch is a + refusal, never a negotiation. There are no version ranges. +- **Config format version:** the config blob gains a top-level + `format_version`; the binary rejects any other value. No + omit-default-for-rollback serialization rules; rollback means redeploying + the previous release with its own config. +- **Assets:** binaries embed only their own release's artifacts. Hashed + pathnames are kept **for cache identity only** (correct caching and + dedup), not for retention: an unknown hash answers `410 Gone`, + `Cache-Control: no-store`. No shared artifact store — no separate + requirement for one was established. +- **Cutover runbook (normative):** (1) deploy the release to all instances + dark (flagged off); (2) verify instance health and config + `format_version`; (3) switch traffic atomically at the routing/CDN layer; + (4) purge the CDN of prior HTML and assets; (5) monitor the Phase gates + (§8); rollback = switch traffic back to the previous release and re-purge. + +Everything that revisions 3–4 built for mixed-version tolerance is removed: +no ABI version ranges, no retained-artifact storage, no legacy query-hash +sunset, no dual-name global window, no adoption gates. ## 1. Problem statement -APS (Amazon Publisher Services) demand is fully integrated server-side — the -edge server runs the APS OpenRTB auction, wins bids, and ships a typed renderer -descriptor to the page — yet APS creatives still do not appear for real users. -Every previous fix (the `bid.meta` carrier, the decoupled prebid shim, the -`hb_adid` fallback) addressed a real defect, and APS still does not render. -That pattern is itself the finding: the APS pipeline has **multiple independent -failure points, most of which fail silently**, and the client library has **no -way to tell the server (or the operator) which one fired**. - -At the same time, the TSJS client library has grown organically to 56 files / -~11,900 lines with two ~1,700-line monoliths, duplicated logic maintained by -hand in two languages, inverted layering, and roughly one hundred `catch` -blocks that discard failures. The APS outage and the library's shape are the -same problem seen from two sides. +APS demand is fully integrated server-side — the edge runs the APS OpenRTB +auction, wins bids, and ships a typed renderer descriptor to the page — yet +APS creatives do not appear for real users. Serial single-cause fixes (the +`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback) each +survived review and still did not produce ads. That pattern is the finding: +the APS pipeline has **multiple independent failure points, most of which +fail silently**, and the client cannot tell the server which one fired. -This design covers both: (a) the specific fixes that make APS render, and (b) -the target architecture that makes TSJS a clean, resilient library. +The TSJS library (56 files, ~11,900 lines, two ~1,700-line monoliths, +duplicated ES5/TS logic, inverted layering, ~100 error-swallowing `catch` +blocks) is the same problem structurally. This design fixes APS delivery and +rebuilds TSJS so the next integration cannot reproduce this failure class. ### Non-goals - No change to the APS OpenRTB endpoint contract or Amazon-side configuration - (including its deliberate absence of `nurl`/`burl` — see G4d). -- No rewrite of Prebid.js integration strategy (the decoupled shim stays). -- No behavior change for publishers whose pages work today. Public-surface - migrations happen behind a bounded compatibility window (7.4), never by - immediate removal. - ---- - -## 2. Why APS still does not render — the evidence - -The audit traced all four delivery flows: (a) SSAT server-side ad template via -`window.tsjs.bids`, (b) GAM + client-side `trustedServer` Prebid adapter, (c) -SPA `/_ts/page-bids` re-auction, (d) direct `/auction` via `tsjs.requestAds`. -Only flow (d) — the demo path nobody runs in production — can render an APS -descriptor without GAM's cooperation. - -### 2.1 Admission: APS bids are eliminated before they can win - -| # | Failure | Where | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | -| A1 | **A configured `[auction].mediator` discards every direct-provider bid.** Winners come exclusively from the mediator response; APS bids (with their renderers) are used only as mediator input and reporting. APS shows `status: success, bid_count: N` yet never wins a slot. | `orchestrator.rs:412-431` | -| A2 | **`allow_script_creatives` defaults to `false`**, dropping every `tagtype: "script"` APS bid — a large share of TAM demand. The drop is counted but invisible (see A4). | `aps.rs:141-143`, `:773-778` | -| A3 | **Strict per-bid gates**: exact `w`×`h` membership in the slot's configured formats, required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` | -| A4 | **Drop reasons never reach an operator on the production paths.** `drop_reasons` counters surface only in `/auction` `ext.orchestrator`; the SSAT and page-bids paths discard them, server logs do not carry them, and the `ts-debug` comment allowlist excludes them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | - -### 2.2 Identity: the `hb_adid` contract with GAM is unproven - -| # | Failure | Where | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| B1 | **GAM key-value values are capped at 40 characters.** The new fallback emits the raw APS OpenRTB bid `id` (a long opaque string) as `hb_adid`. If GAM truncates or rejects it, `%%PATTERN:hb_adid%%` comes back different, the bridge's equality check fails, and it bails **with no log**. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` | -| B2 | **Two id universes for the same bid.** SSAT keys the bridge on the APS bid id; the client-side Prebid adapter keys on Prebid's generated `adId`. A page running both paths registers the same slot under different ids. | `publisher.rs:3366`, `prebid/index.ts:982` | - -### 2.3 Render: the client has one narrow happy path and no fallback - -| # | Failure | Where | -| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| C1 | **If GAM never serves the Prebid Universal Creative, nothing renders and nothing is recorded.** The renderer descriptor sits unused in `window.tsjs.bids`; `renderApsCreative` is reachable only from the unused flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` | -| C2 | **A renderer endpoint that never answers is a silent 10-second death.** The sandboxed iframe cannot read an HTTP status from its opaque origin; a 404/401/misrouted document simply never posts `renderer-ready`. | `aps.rs:1188-1245`, `aps/render.ts:384-404` | -| C3 | **SafeFrame breaks slot attribution.** The bridge resolves a message source by walking top-document iframes under the slot div; a nested SafeFrame creative window is invisible to that walk, so the bridge bails silently. | `gpt/index.ts:157-183`, `:1599-1600` | -| C4 | **Three hand-maintained copies of the descriptor schema** (Rust struct, TS validator, inline renderer-document validator) with exact-key rejection: any server-side field addition instantly blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` | -| C5 | **A dead duplicate renderer branch in the bridge** contains the debug log and dedup logic people would look for while debugging; it can never execute. | `gpt/index.ts:1643-1674` | -| C6 | **The renderer CSP may kill creatives after success is reported** (`object-src`, workers, `blob:`/`data:` frames are blocked; `renderer-ready` fires before the creative actually paints). | `aps.rs:49` | -| C7 | **The renderer branches record nothing**: no `recordRender`, no win/billing beacons, no `stampCreativeTrace` — an APS win looks "never rendered" in every trace whether or not it painted. | `gpt/index.ts:1558-1632` | - -### 2.4 Observability: the common factor - -There is **zero client→server reporting**. Server telemetry marks `is_win=1` -at auction time and goes quiet; a bid that never painted is byte-identical to -one that painted perfectly. Client-side evidence dies with the tab. - ---- + (including its deliberate absence of `nurl`/`burl`, §G4d). +- No rewrite of the decoupled Prebid.js strategy. +- **No backward compatibility** (§0). Publisher-visible surfaces change at + cutover; the replacement shapes are in §7.4. + +## 2. Why APS does not render — evidence + +Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer` +Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction` +`tsjs.requestAds`. Only (d) — unused in production — renders an APS +descriptor without GAM. + +### 2.1 Admission + +| # | Failure | Where | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| A1 | A configured `[auction].mediator` discards every direct-provider bid; winners come only from the mediator response. APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` | +| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:141-143`, `:773-778` | +| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:657-668`, `:745-778`, `:838-846` | +| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and the `ts-debug` allowlist exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | + +### 2.2 Identity + +| # | Failure | Where | +| --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` | +| B2 | Two id universes: SSAT keys on the APS bid id, the client adapter on Prebid's generated `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` | + +### 2.3 Render + +| # | Failure | Where | +| --- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` | +| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1188-1245`, `aps/render.ts:384-404` | +| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:157-183`, `:1599-1600` | +| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` | +| C5 | A dead duplicate renderer branch holds the debug log and dedup logic; it can never execute. | `gpt/index.ts:1643-1674` | +| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | +| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1558-1632` | + +### 2.4 Observability + +Zero client→server reporting. Server telemetry marks `is_win=1` at auction +time; a bid that never painted is byte-identical to one that painted. + +### 2.5 Failure → signal mapping (normative) + +Every section-2 failure maps to a distinct observable **failure class** (not +a claim to distinguish unknowable root causes): + +| Failure | Client event/reason (§5.1) | Server counter/row (§5.6) | Console | +| ------- | -------------------------------------------- | -------------------------------------- | ------------ | +| A1 | — | selection report `mediator_superseded` | startup warn | +| A2 | — | `bid_drop{script_rendering_disabled}` | startup warn | +| A3 | — | `bid_drop{invalid_dimensions, w, h}` | warn | +| A4 | — (fixed by §5.6 itself) | `bid_drop` rows exist on all paths | `ts-debug` | +| B1/B2 | `bridge_request{matched: false}` | join via `trace_id` | warn | +| C1 | `gam_empty` then no `bridge_request` | join via `trace_id` | warn | +| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | warn | +| C3 | `render_fail{bridge_id_mismatch}` | join via `trace_id` | warn | +| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | warn | +| C5 | — (branch deleted) | — | — | +| C6 | `runner_failed` + CSP report buckets | CSP aggregate counters | warn | +| C7 | renderer branch emits the full §5.1 sequence | join via `trace_id` | debug/warn | ## 3. The GPT reality this design must respect -1. **Bootstrap-first hybrid:** the server injects a 495-line ES5 - `gpt_bootstrap.js` before the bundle; shared monkeypatch sentinels mean the - bundle's handoff and initial-load code is dead in production. -2. **The #922 merge loss:** orphan-slot recovery and `updateRender` enrichment - are gone (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead - writes; bridge-served impressions double-count. PR #997 appears to be the - reworked replacement. -3. **TS refreshes never pass `changeCorrelator: false`.** -4. **`enableSingleRequest()` is called blind** after the publisher's own - `enableServices()` has almost always run. -5. Responsive resolution is a DOM-element-selection ladder; ambiguity silently - skips the slot. -6. Three independent wrappers on `pubads().refresh` coordinate via - window-global booleans. -7. **GPT offers no request cancellation** (`gpt/index.ts:1080`), and its event - contract identifies only the slot: Google documents no per-refresh - identifier and no completion-order guarantee for overlapping requests, and - `slotRenderEnded` means creative code was injected, not that its resources - loaded. Publisher code can refresh an adopted slot while a TS cycle is - pending. Any attribution scheme must survive all of that (G4a). -8. **The bundle's `slotRenderEnded` registration is gated behind - `!ts.servicesEnabled`** (`gpt/index.ts:1017`), so bootstrap-first or - services-enabled pages can miss the listener entirely — G4a requires - unconditional early subscription. - ---- - -## 4. Design gates — the five contracts +1. Bootstrap-first hybrid: server-injected ES5 `gpt_bootstrap.js` wins the + sentinel race; the bundle's handoff/initial-load code is dead in + production. +2. The #922 merge loss: orphan recovery and `updateRender` are gone + (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead writes; + bridge impressions double-count. PR #997 is the apparent replacement. +3. TS refreshes never pass `changeCorrelator: false`. +4. `enableSingleRequest()` is called blind after publisher `enableServices()`. +5. Responsive resolution ambiguity silently skips slots. +6. Three independent `pubads().refresh` wrappers coordinate via window-global + booleans. +7. **GPT has no request cancellation, no documented per-refresh identity, no + overlapping-completion order.** `slotRenderEnded` means creative code was + injected, not that resources loaded. The April 2025 `responseIdentifier` + identifies the ad **response** — usable for response dedup/drain, not for + attributing which caller initiated a request. +8. **With initial load disabled, `display()` creates no request** — the + subsequent `refresh()` does (`gpt/index.ts:1059`, `ad_init.test.ts:1201`). + Any cycle protocol must model physical requests, not API calls. +9. The bundle's `slotRenderEnded` registration is gated behind + `!ts.servicesEnabled` (`gpt/index.ts:1017`); G4a needs unconditional early + subscription. + +## 4. Design gates ### G1 — Trace identity and correlation -**The client-visible auction id must never be ingested** (EC-derived: -`publisher.rs:3237`). **Initial-HTML auction telemetry is emitted before page -JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so correlation -is minted by whoever acts first: - -- **Initial navigation (`nav_gen = 0`):** the server mints `trace_id` - (128-bit CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction - telemetry rows at emit time (a new nullable `trace_id` column on the - existing auction datasource; the independent telemetry `auction_id` UUID - remains and remains separate), and injects it into the page as a `tsjs` - boot field with the sampling decision and, when the tester gate is active, - the diagnostic capability (5.3). -- **Cache-privacy invariant:** a trace or capability is injected **only** into - responses that ran a per-request auction, and any trace-bearing HTML MUST be - shared-cache-ineligible: `Cache-Control: private, no-store` and no - validators that could revalidate a shared copy. This is enforced by - construction (the injection site is the auction-bearing render path) and by - test. A shared-cached page would have no per-visitor auction to correlate - anyway; the invariant makes that alignment explicit. -- **SPA navigations (`nav_gen > 0`):** `/_ts/page-bids` **stays GET** (it is - GET in `publisher.rs:3815` and the client issues GET at - `gpt/index.ts:1152`; a browser GET cannot carry a body). The client mints - the `trace_id` and sends it in a validated **`X-TSJS-Trace-Id`** request - header — the request already carries a non-simple TSJS header, so CORS - preflight behavior is unchanged. The server records it contemporaneously in - that auction's telemetry rows and the JSON response **echoes the accepted - trace id** and returns a capability bound to it when the tester gate is - active. -- **Envelope:** every event carries - `{trace_id, sampled, nav_gen, refresh_gen, seq}`; `seq` is per-trace - monotonic. -- **Sampling is trace-sticky** (decided once per trace; server-decided for - `nav_gen 0`, client-decided from the injected rate afterwards). Transport is - best-effort, so a sampled trace may still arrive partial; partiality is - detectable via `seq` gaps and is not treated as a contract violation. - -### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID - -Unchanged from revision 3 (review-accepted): cache-backed bids keep the cache -UUID as `hb_adid` byte-for-byte; renderer-only bids get a server-minted token -`^[a-z0-9]{12}$` (CSPRNG; in-auction collision retry; cross-auction uniqueness -probabilistic with the birthday bound documented; harmless via per- -`(trace_id, nav_gen)` registry scoping); TTL 15 minutes; one-time consumption; -client-Prebid keeps Prebid's `adId`; non-APS cache-path regression tests. - -### G3 — Runtime ABI: how code shares state under the IIFE build +The client-visible auction id is EC-derived (`publisher.rs:3237`) and is +never ingested. Initial-HTML auction telemetry is emitted before page JS +exists (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by +whoever acts first: + +- **Initial navigation (`nav_gen 0`):** the server mints `trace_id` (128-bit + CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows + (§5.6 schema), and injects it into `tsjs.boot` with a **signed trace + authorization** (§5.3). It is never `AuctionRequest.id`. +- **Cache privacy invariant:** traces/authorizations are injected only into + responses that ran a per-request auction; such HTML is + `Cache-Control: private, no-store` with no validators. Enforced by + construction and by test. +- **SPA navigations:** `/_ts/page-bids` stays GET; the client mints + `trace_id` and sends it in a validated `X-TSJS-Trace-Id` header (the + request already carries a non-simple TSJS header). The server records it in + that auction's rows; the JSON response echoes the accepted trace and + returns its signed authorization. +- **Envelope:** every event carries `{nav_gen, refresh_gen, seq}` inside a + per-trace group `{trace_id, auth, events[]}` (§5.1). `seq` is per-trace + monotonic. Transport is best-effort: gaps (loss) and duplicates + (fetch/pagehide races) are both expected; §5.5 defines dedup. +- **Sampling is server-decided for every trace** — initial via boot, SPA via + the page-bids response — and carried **inside the signed authorization** + (§5.3 `mode`). The client never asserts its own sampling; an unsigned or + missing authorization means the trace group is rejected at ingest. + +### G2 — Render identity + +- Cache-backed bids: `hb_adid` = the PBS Cache UUID, byte-for-byte as today + (`publisher.rs:3355`; the PUC fetches `?uuid=`, + `publisher.rs:3450`, `gpt/index.ts:1700`). Markup bids without cache ids: + today's fallback chain unchanged. +- **Renderer-only bids: `hb_adid` = a server-minted render token**, + `^[a-z0-9]{12}$` exactly, CSPRNG, collision-retried within the minting + auction. Cross-auction uniqueness is probabilistic (36¹² ≈ 4.7×10¹⁸; + birthday-bound negligible at realistic volumes) and made harmless by + registry scoping. +- **Registry scope and bounds:** the client bridge registry keys tokens by + `(trace_id, nav_gen, refresh_gen)` — refresh auctions within one + navigation cannot collide, closing the revision-4 gap. Capacity: 64 live + entries per navigation; at capacity a new registration is refused with + disposition `registry_full`; unexpired entries are evicted only by + navigation disposal. Token TTL 15 minutes; one-time consumption. +- The client-Prebid path keeps Prebid's generated `adId`; both paths register + into the one registry keyed by whichever id that path observes. +- Regression tests: non-APS cache-backed bids byte-identical. + +### G3 — Runtime ABI under the IIFE build (exact-release model) IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`) -means module imports never share state across bundles (live proof: +means imports never share state across bundles (live defect: `core/context.ts:11` vs `permutive/index.ts:102`). -Contract — a versioned registration ABI on `window.tsjs._internal`: - -- Kernel ships only in `tsjs-core`, publishes - `tsjs._internal = { abi: 1, registry }` once (window sentinel). The kernel - constructs and registers core service instances during boot; integrations - register integration-scoped services during `install()`. -- **Version semantics (settling the mixed-version gap):** every service - registers with `(major, minor)`. `registry.get(name, {major, minMinor})` - succeeds iff an implementation with the same `major` and `minor ≥ minMinor` - is registered. The install manifest's plugin versions are **ranges with the - same semantics** (required major, minimum minor), not exact pins. - An incompatible service registration is **quarantined** — recorded, not - installed — and surfaced as `abi_mismatch`; an incompatible plugin as - `bundle_partial`. **First-wins applies only among compatible - registrations.** The old-deferred-bundle + new-core scenario therefore has a - deterministic verdict: the old plugin either satisfies the manifest range - and runs, or is quarantined loudly. -- Stateful services only via the registry at call time; stateless helpers may - be imported and inlined. Single-module-graph builds remain the successor - option behind the same surface. +- The kernel ships only in `tsjs-core`, publishes + `tsjs._internal = { release_id, registry }` once (window sentinel), and + **freezes** `_internal` after boot. The kernel constructs and registers + core services (event bus, beacon queue, sessions, slot registry, render + state machine) during boot; integrations register integration-scoped + services during `install()`. +- **Exact release matching:** every service and plugin registration carries + `release_id`; `registry.get(name)` succeeds only when the registrant's + `release_id` equals the kernel's. A mismatch quarantines the registration + and emits `abi_mismatch` (service) / `bundle_partial` (plugin) with a + console error. No ranges, no minors, no first-wins tiers — under §0 a + mismatch is a deployment error to surface, not tolerate. +- Stateful access only through the registry at call time; stateless helpers + may be imported and inlined. Single-module-graph builds remain the + recorded successor option behind the same surface. ### G4 — Render lifecycle -**G4a — Request-cycle protocol (no ordering assumptions).** GPT documents no -per-refresh identity and no completion-order guarantee, so the protocol -assumes neither: - -- Every observable request initiation is classified `ts | publisher`: TS's own - `display()`/`refresh()` calls open TS cycles; the wrapped publisher - entry-points and `slotRequested` events that match no TS cycle are recorded - as publisher-initiated. -- **TS serializes itself to at most one outstanding cycle per slot** — a new - TS refresh for a slot with a pending cycle waits or supersedes explicitly; - it is never concurrently pending. -- With ≤1 TS cycle outstanding, a `slotRenderEnded` is attributable iff no - untracked or publisher-initiated request overlaps it. **Any overlap marks - the slot `cycle_unattributable` and fails closed** (no fallback, no state - transition, console warning + disposition). -- `slotRenderEnded` is treated as "creative code injected", not "resources - loaded" — it can confirm delivery, never paint. -- The deterministic PUC/message harness exercises the protocol in CI, and a - **release-gating real-GAM overlap test** (publisher refresh racing a TS - cycle) validates the contract against actual GPT, since a FIFO-assuming - stub proves nothing. - -**G4b — Acknowledgement path.** Unchanged from revision 3 (review-accepted): -per-attempt CSPRNG nonce in the bridge response; the dynamic renderer posts -versioned accepted/failed messages to the kernel; the kernel validates source -ownership, nonce, token, `nav_gen`, `refresh_gen` before any transition or -callback; pinned for SSAT, client-Prebid, and nested SafeFrame flows. - -**G4c — Honest observations, `render_confirmed` removed.** The inline-adm -frames are sandboxed `srcdoc` documents with `allow-same-origin` deliberately -omitted (`gpt/index.ts:358`) — their origins are opaque, so revision 3's -"same-origin observable" premise was false. The event is removed entirely. -The taxonomy is now: `gam_nonempty`, `gam_empty`, `renderer_document_loaded`, -`runner_loaded`, `runner_failed`, `adm_document_loaded` (the iframe `load` -event for TS-written adm frames — document delivery, not paint). **Every -render path terminates at `render_accepted`** (authenticated per G4b where -the renderer protocol exists; `adm_document_loaded` for adm frames). No -observation claims paint. A future trusted completion acknowledgement (open -question 6) may reintroduce a confirmed state under a new name. - -**G4d — Win/billing notifications, scoped to paths that have them.** APS -**intentionally carries neither** `nurl` nor `burl` (`aps.rs:812` sets both -`None`; the minimized AAX envelope excludes notifications; the integration -guide documents that generic win/billing beacons are not fired for APS). APS -billing runs entirely inside the Amazon runner lifecycle, and this design -does not change the APS wire contract. - -For bid paths that do carry the URLs (PBS and other OpenRTB providers): - -- **Trigger semantics, published explicitly:** `nurl` fires when the render - attempt binds the bid to a cycle (Trusted Server's selection produced the - candidate GAM will render — the earliest point at which "win" is - meaningful for this pipeline); `burl` fires at the attempt's - `render_accepted`. Both are **attempt-scoped**, keyed by - `(trace_id, nav_gen, slot, refresh_gen, hb_adid)` as the idempotency key — - fired at most once per attempt, not page-wide. -- **Owner and mechanics:** the client render pipeline owns firing (as today, - `gpt/index.ts:459`), via `sendBeacon`/`no-cors fetch`, no retries (a beacon - either queues or is lost; retrying risks double-billing). -- Terminal failure after acceptance is labeled `billed_then_failed`; no - un-firing. - -**G4e — Fallback trigger.** The opt-in fallback -(`[auction].client_render_fallback = "renderer"`) renders only after a -**terminal `gam_empty` unambiguously attributed to a TS-initiated cycle** -(G4a). Timeouts are diagnostics-only and never render. Revision 3 disabled -fallback for adopted slots entirely, which — as the review noted — excludes -the common production path (pre-existing publisher slots are adopted and -refreshed, `gpt/index.ts:925`). Revised: **ownership does not gate the -fallback; attribution does.** An adopted slot whose attributed TS cycle ends -in `gam_empty` may fall back; any publisher-initiated or unattributable cycle -never triggers it. The success criteria and browser specs cover the adopted -case explicitly. - -### G5 — Deployment contracts - -- **Config rollback:** new config fields are default-valued and omitted from - serialization at defaults. **Rollback runbook rule:** after an operator has - opted into a new field, rolling the binary back requires restoring the - default and pushing the default-compatible blob first (this mirrors the - project's existing rollback guidance). -- **Asset identity and the artifact source (settling "not realizable"):** - hash in the pathname; and artifacts are **published to shared immutable - platform storage (KV/config store) as deploy stage 1, before any HTML - references them** — binaries serve the current vector from embedded bytes - (fast path) and everything else by hash lookup in shared storage. This - answers all four skew cases: new HTML hash `B` reaching an old instance - (lookup serves `B` from storage), a miss for retained `A` after only `B` is - embedded (lookup), already-issued legacy query-hash URLs (the legacy path - keeps serving current bytes with short-TTL, non-immutable caching through a - documented sunset), and renderer `/v2` reaching a `/v1`-era instance - (versioned renderer documents are published to the same storage in - stage 1). Two-stage deployment is the contract: **stage 1 publish - artifacts, stage 2 roll binaries/HTML.** Both rolling directions and the - legacy URL are tested. Retention: ≥ 7 days, which must exceed the HTML - cache lifetime — itself now bounded by contract (auction-bearing HTML is - `no-store` per G1; any cacheable non-auction HTML referencing tsjs sets - `max-age ≤ 300`). `Cache-Control: immutable` only on exact hash matches; - unknown hashes → `410 Gone`, `no-store`. Concatenations are keyed by the - **ordered module-ID vector**, precomputed in Phase 0 (which owns asset - identity). -- **Ingest routing:** the beacon route exists in all four adapters as an - early, EC-free, filter-free route; only Fastly has a real sink; others - accept-count-drop by explicit contract. -- **Storage:** datasource `ts_client_events`; retention 30 days; production - sampling 10%. These are **adopted defaults** (operator-tunable), no longer - open questions; the remaining open question is only whether non-Fastly - adapters get sinks (OQ5). -- **Phase gates are phase-specific** (section 8) — the render-fail canary - applies only from Phase 3 onward, because earlier phases don't create that - metric. - ---- - -## 5. Workstream 1 — Observability - -### 5.1 Event payload — minimized, grouped per trace +**G4a — Physical request-cycle protocol.** Two separated notions: + +- **Intent:** a TS `display()`/`refresh()` call (or an observed publisher + entry) targeting a slot. Intents are classified `ts | publisher` at the + wrapped entry points. An intent may produce zero physical requests + (initial-load-disabled `display()`; `refresh()` on a never-displayed + adopted slot); an intent that produces no `slotRequested` within its bound + (2 s) expires with disposition `intent_no_request` — diagnostics only. +- **Cycle (outstanding physical request):** opened **only by + `slotRequested`**, matched to the oldest unexpired TS intent for that slot, + else classified publisher-initiated. SRA batching yields one `slotRequested` + per slot per batch — one cycle each, all matched to the intents of the + batch call. A cycle closes on its `slotRenderEnded` (matched by slot; GPT's + `responseIdentifier`, where present, deduplicates responses during drain — + it never attributes initiation). +- **Serialization:** TS keeps at most one outstanding TS cycle per slot. A TS + intent arriving while a TS cycle is outstanding **queues** (bounded: 1 + queued replacement; further intents coalesce into it). +- **Attribution:** a `slotRenderEnded` is attributable iff exactly one + TS cycle is outstanding for the slot and no publisher-initiated or + untracked request overlaps it. Any overlap → the slot enters + **quarantine**: `cycle_unattributable`, fail closed (no fallback, no state + transition), and the drain rule applies. +- **Drain/re-arm (supersession and SPA):** physical cycle state lives in the + **RuntimeSession-owned slot record**, not the NavigationSession — adopted + slots outlive navigations. On navigation or supersession, outstanding + cycles are marked stale; their late events are **matched and discarded** + with disposition `stale_navigation` (never misattributed); a quarantined or + stale slot re-arms only after every outstanding request/render pair has + drained (or its 60 s drain bound elapses, which keeps the slot + fallback-ineligible for that navigation). Queued TS intents dispatch only + after re-arm. A timeout never makes an old event disappear — drain-by-match + does. +- CI exercises the protocol on the deterministic harness; a **release-gating + real-GAM overlap test** (publisher refresh racing a TS cycle; + initial-load-disabled cycle formation) validates it against actual GPT. + +**G4b — Acknowledgement protocol.** The renderer document currently posts +"ready" only to its immediate parent (`aps.rs:105`); in the PUC path the +top-level kernel cannot observe it, and callbacks fire on send +(`gpt/index.ts:1572`, `:1620`). Contract: the bridge response embeds a +**per-attempt 128-bit CSPRNG acknowledgement nonce**; the renderer document +posts versioned `{t: "render_accepted" | "render_failed", nonce, reason?}` +to the top window; the kernel validates, in order: source ownership (§6.8 +walk), nonce equality, token binding, `nav_gen`, `refresh_gen` — all five — +before any state transition or notification. Pinned by tests for SSAT, +client-Prebid, and nested SafeFrame flows, including stale and replayed +acks. + +**G4c — Honest observations.** Inline-adm frames are sandboxed `srcdoc` +without `allow-same-origin` (`gpt/index.ts:358`) — opaque origins; geometry +proves nothing. Observations: `gam_nonempty`, `gam_empty`, +`renderer_document_loaded`, `runner_loaded`, `runner_failed`, +`adm_document_loaded`. Every path terminates at `render_accepted` +(authenticated per G4b where the renderer protocol exists; +`adm_document_loaded` stands in for adm frames). **No observation claims +paint**; there is no `render_confirmed`. A future trusted completion ack +(OQ6) may add a new state under a new name. + +**G4d — Win/billing notifications.** APS intentionally carries neither +`nurl` nor `burl` (`aps.rs:812`; the minimized AAX envelope excludes them; +the integration guide documents no generic APS beacons). APS billing lives in +the Amazon runner lifecycle; unchanged. + +For carrying paths (PBS and other OpenRTB providers), **bind is defined per +flow and is never selection or targeting** (targeting-only firing is +explicitly prevented today, `ad_init.test.ts:1824`): + +- PUC/GAM flow: bind = an owned, slot-and-ad-id-matched bridge claim. +- Direct `/auction` flow: bind = validated render start (slot resolved, + descriptor/markup validated, attempt created). +- Fallback flow: bind = attributed `gam_empty`, immediately before the + fallback render starts. + +`nurl` fires at bind; `burl` at `render_accepted`; both attempt-scoped +(idempotency key `(trace_id, nav_gen, slot, refresh_gen, hb_adid)`), fired at +most once, via `sendBeacon`/`no-cors fetch`, no retries. Terminal failure +after acceptance → `billed_then_failed` label; no un-firing. + +**G4e — Fallback trigger.** Opt-in +(`[auction].client_render_fallback = "renderer"`). Renders only after a +terminal `gam_empty` **unambiguously attributed to a TS cycle** (G4a) — +ownership does not gate it (adopted slots are the common path and are +eligible); publisher-initiated or unattributable cycles never trigger it; +timeouts never render. The direct renderer is converted to an awaitable API +with cancellation and terminal reasons before the fallback lands. + +**G4f — Direct `/auction` lifecycle.** The non-GPT path +(`core/request.ts:52`) gets the same discipline: a `RenderAttempt` keyed +`(trace_id, nav_gen, refresh_gen, slot)` where `refresh_gen` increments per +`requestAds` invocation for the same slot within a navigation; exactly-once +terminal state; G4b acknowledgement validation; G4d direct-flow bind; +cancellation and disposal on navigation; the same §5.1 event sequence. "Every +configured flow" in the success criteria includes this one. + +### G5 — Deployment contracts (hard cutover) + +- **Config:** top-level `format_version`; exact match required; mismatch is a + startup error. No default-omission rollback rules. +- **Assets:** hash-in-pathname (`/static/tsjs//.js`) for cache + identity; binaries serve only embedded current-release artifacts; unknown + hash → `410 Gone`, `no-store`; `Cache-Control: immutable` on exact matches. + Concatenations precomputed per **ordered module-ID vector** at build time. + The cutover runbook (§0) owns HTML/asset consistency; the CDN purge step is + what retires old references. +- **Internal route isolation (all adapters):** the renderer, client-events, + and CSP-report route families (a) dispatch **before** auth, EC setup, and + publisher/integration filters (today the renderer can traverse EC setup and + pre-route filters, `app.rs:709` in the Fastly adapter); (b) reserve **all + methods and all version prefixes** locally — unsupported method → + deterministic `405` with `Allow` and `no-store`; unknown version → `404` + `no-store`; never the publisher fall-through some adapters use today + (`adapter-spin app.rs:804`); (c) never forward bodies, cookies, or + authorization headers to publisher origins; (d) compare origins as + normalized scheme + host + port, not host-only. +- **Ingest routing:** client-events in all four adapters; Fastly has the real + sink; others accept-count-drop by contract (OQ5). +- **Storage:** §5.6 schemas deploy and validate **before** any writer + enables. + +## 5. Observability + +### 5.1 Wire payload ``` { v: 1, traces: [ - { trace_id, sampled, capability?, // capability: only in diagnostic mode + { trace_id, auth, // auth: signed authorization (§5.3) events: [ { nav_gen, refresh_gen, seq, t: "bid_received" | "targeting_set" | "bridge_request" | "bridge_response_sent" | "render_attempt" | "render_accepted" | - "render_fail", - slot, // configured slot id if in the injected set, else "s" - id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" - matched, // bridge_request only - source, // "renderer" | "adm" | "pbs-cache" | "gam" - reason, // render_fail only: closed enum below - width, height } // invalid_dimensions context only: bounded ints [0, 8192] + "render_fail" | "gam_nonempty" | "gam_empty" | + "renderer_document_loaded" | "runner_loaded" | "runner_failed" | + "adm_document_loaded" | "fallback_start", + slot, // configured slot id if in the injected set, else "s" + id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" + matched, // bridge_request only + source, // "renderer" | "adm" | "pbs-cache" | "gam" + reason } // render_fail only ] } ] } ``` -- **Events are grouped per trace, and the diagnostic capability is a per-trace - field** — a navigation-spanning batch carries one group per trace, so one - batch-level capability can never be ambiguous, and an initial-navigation - capability never authorizes a client-minted SPA trace (that trace's - capability comes from the page-bids response, G1). -- Reason enum (closed; no interpolation — dimension context travels in the - bounded numeric fields): `renderer_document_no_load`, `runner_no_load`, - `runner_failed`, `descriptor_invalid`, `invalid_dimensions`, - `bridge_id_mismatch`, `cycle_unattributable`, `bridge_claim_timeout`, - `gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, - `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`. +The `t` enum now **contains every G4c observation**, so Phase-3 stage rates +(renderer-document load rate, runner load/failure, GAM fill) are queryable. +Reason enum (closed): `renderer_document_no_load`, `runner_no_load`, +`runner_failed`, `descriptor_invalid`, `invalid_dimensions`, +`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`, +`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`, `gam_empty`, +`no_render_source`, `slot_unresolved`, `gpt_absent`, `pbjs_absent`, +`bundle_partial`, `fallback_cancelled`, `abi_mismatch`, `registry_full`. ### 5.2 Transport -`fetch(..., {keepalive: true, credentials: "omit"})` primary. The `pagehide` -fallback is `navigator.sendBeacon(url, new Blob([json], {type: -"application/json"}))` — the Blob type satisfies the ingest media-type -contract (a bare string would arrive as text); it is credentialed by platform -design and its `true` means queued, not received; the handler ignores -credentials either way. Flush on `visibilitychange`/`pagehide` and every 5 s. - -### 5.3 Ingest wire contract - -- `POST /_ts/client-events` in all four adapters, before auth/EC/filters. - `Content-Type: application/json` only; no `Content-Encoding`. Responds - `204 Cache-Control: no-store`; never echoes input. +`fetch(..., {keepalive: true, credentials: "omit"})` primary; `pagehide` +fallback `navigator.sendBeacon(url, new Blob([json], {type: +"application/json"}))`. Flush every 5 s and on `visibilitychange`/`pagehide`. +**Client queue bound:** 256 events; overflow drops oldest, increments a +counter, and the final flushed batch carries one `render_fail{...}`-class +overflow marker event so truncation is visible. Duplicates from +fetch/pagehide races are expected and handled at the sink (§5.5). + +### 5.3 Signed trace authorization + +Format `v1....`: + +- `kid`: key id; **active and previous keys** live in the platform secret + store; rotation = introduce new key as active, demote, retire. +- `exp`: unix epoch seconds; verifier allows ±60 s skew; maximum future + 15 minutes from issuance. +- `mode`: `sampled` | `diagnostic`. Sampling is server-decided (G1); + diagnostic is a distinct authenticated mode gated by the tester cookie at + issuance — not an overloaded "sampling off" bit. +- `sig`: HMAC-SHA-256 over the **domain-separated, length-prefixed** input + `"ts-trace-auth-v1" || len(origin) || origin || len(trace_id) || trace_id +|| len(mode) || mode || u64(exp)`, where `origin` is the externally visible + scheme+host+port. Constant-time comparison. +- Ingest verifies per trace group; a missing key id, expired, future-dated, + or invalid signature → that **group** is dropped-and-counted (other groups + in the batch survive). An unsigned `sampled` claim does not exist in the + wire format, so it cannot be asserted. + +### 5.4 Ingest contract + +- `POST /_ts/client-events`; `Content-Type: application/json` only; no + `Content-Encoding`; responds `204`, `no-store`; never echoes input. - Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars; - `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`; width/height in - `[0, 8192]`. Violation → drop-and-count with `204`. -- Same-origin: `Sec-Fetch-Site: same-origin` when present, else `Origin` - matching the serving host; **absent both → drop-and-count**. -- **Rate limiting fails closed for telemetry:** when the limiter denies, or - when a portable adapter's best-effort limiter is unavailable or errors, the - request is dropped early with `204` (count only, no parse, no sink). Ad - delivery is unaffected by construction because this route serves nothing. -- **Trusted client address, per adapter, concretely:** Fastly — the - platform's client IP API; Axum — the rightmost `X-Forwarded-For` entry - beyond `trusted_proxy_hops` (a required config value when the beacon is - enabled; without it, the socket peer address is used and forwarded headers - are ignored); Cloudflare — `CF-Connecting-IP`; Spin — the platform client - address. Spoofable headers are never trusted beyond the configured hop - count. -- **Diagnostic capability:** server-issued, HMAC over - `trace_id + expiry` (≤ 15 min), delivered via the G1 boot field (initial - trace) or the page-bids response (SPA traces), echoed per trace group. - Signature verification is what switches a trace to unsampled — a public - query flag alone never does. - -### 5.4 Two modes, honestly separated - -- **Production telemetry** (sink-backed deployments only — today Fastly): - sticky-sampled (10%). SLO, fully parameterized: a failure mode affecting - ≥ 1% of **sampled render attempts** is visible in `ts_client_events` within - one hour, evaluated only when the deployment produced ≥ 10,000 sampled - render attempts in that hour, with sink ingestion freshness ≤ 5 minutes; - sink outages pause the SLO clock and are alarmed separately. Non-sink - adapters are explicitly out of SLO scope, and no global gate depends on - their beacon data. -- **Diagnostic mode:** capability-gated, unsampled, full stream + console - mirroring — the "one page load names the failing reason" tool. - -### 5.5 Server-side drop-reason surfacing - -- Bounded structured summary whenever **any** bid is dropped (per-slot reason - counts, capped); `drop_reasons` added to auction telemetry rows; drop - summary in the initial-HTML `ts-debug` comment; `/_ts/page-bids` gains a - tester-gated structured `debug` field. -- Startup warnings: APS enabled with `allow_script_creatives = false`; direct - provider configured alongside a mediator without 6.1's merge strategy. - ---- - -## 6. Workstream 2 — APS delivery fixes - -### 6.1 Mediation: opt-in merge, `mediator_only` stays the default - -As revision 3 (review-accepted), with one tightening: a mediator bid whose id -the provenance map cannot resolve, **for a slot where the same provider had -forwarded candidates**, is counted under a dedicated -`mediator_provenance_unresolved` metric and logged at `warn` — surfacing -possible self-competition instead of silently treating it as distinct demand. -Deal priority remains out of scope (the `Bid` model carries no deal identity; -recorded as follow-up). Currency mismatch remains rejection. One selection -helper serves both mediation lifecycles, with tests for each. - -### 6.2 Dimensions: the contract is "request what you accept" - -Exact size membership stays. The fix is visibility plus configuration: the -drop summary names the rejected size per slot via -`reason: invalid_dimensions` with bounded numeric `width`/`height` fields -(closed enum preserved), and documentation gains "sizing your slots for APS." + `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`. +- Same-origin: `Sec-Fetch-Site: same-origin` when present, else normalized + `Origin` equality; absent both → drop-and-count. +- **Rate limiting (numeric, fail-closed for telemetry):** token bucket per + client address, **10 requests/min, burst 20**; limiter map ≤ 65,536 + entries, entry TTL 10 min, LRU eviction; limiter unavailable/errored → + drop early with `204` (count only). Trusted client address per adapter: + Fastly — platform client IP; Axum — rightmost `X-Forwarded-For` entry + beyond required `trusted_proxy_hops` (absent config → socket peer only); + Cloudflare — `CF-Connecting-IP`; Spin — platform client address. + +### 5.5 Sink and deduplication + +- Stable event key `(publisher, trace_id, seq)`; deduplication at the sink + or query layer (Tinybird: latest-write or `GROUP BY` on the key), covering + fetch/pagehide double-delivery. +- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) and + cannot observe downstream schema/auth rejection — therefore + **datasource-side freshness monitoring is mandatory** (§8 gates alarm on + ingestion lag and row-rejection metrics from the datasource side). + +### 5.6 Physical schemas (deployed before writers) + +- **New datasource `ts_client_events`** (flattened rows): + `ts (DateTime64), publisher (LowCardinality String), release_id (String), +trace_id (FixedString 32), mode (Enum sampled|diagnostic), nav_gen UInt32, +refresh_gen UInt32, seq UInt32, event (Enum §5.1), slot (String ≤64), +id_kind (Enum), matched (UInt8), source (Enum), reason (Enum §5.1)`. + Sorting key `(publisher, ts, trace_id, seq)`; 30-day TTL; its own ingest + token, configured via new `TinybirdSettings` fields + (`client_events_dataset`, `client_events_token_secret`) — today's settings + configure only the auction dataset (`settings.rs:1752`). +- **Auction rows** (`AuctionEventRow`, `telemetry.rs:262`, and + `auction_events_raw.datasource`): add nullable `trace_id (FixedString 32)` + and `mode`; add a bounded **`bid_drop` row type** + `{provider, slot, reason (Enum), width UInt16?, height UInt16?, count +UInt32}` with per-auction row cap 32 and an `overflow` bucket row. +- APS parsing returns a **structured drop observation** + `{reason, slot, width?, height?}` instead of a bare reason string + (`aps.rs:722`); dimensions above 8192 use `dimensions_out_of_range` with + dimensions omitted, never clamped. + +### 5.7 Modes and SLOs + +- **Production (sink-backed only):** server-decided 10% sampling. + Two separated objectives: **pipeline availability** — ingestion freshness + ≤ 5 min and datasource rejection rate < 0.1%, alarmed on breach (fails + during sink outages, by design); **failure detection** — a failure mode + affecting ≥ 1% of sampled render attempts is visible within one hour, + evaluated only at ≥ 10,000 sampled render attempts/hour. +- **Diagnostic:** authenticated mode (§5.3), unsampled, full stream + + console mirroring; one page load names the failing class. + +### 5.8 Server-side drop surfacing + +Bounded structured summary whenever any bid is dropped; `bid_drop` rows +(§5.6); drop summary in the initial-HTML `ts-debug` comment; page-bids gains +a tester-gated structured `debug` field. Startup warnings: APS + +`allow_script_creatives = false`; mediator + direct providers without an +explicit `winner_selection` (§6.1 makes that a hard error). + +## 6. APS delivery fixes + +### 6.1 Mediation: complete inline algorithm + +Current mediation cannot support merging: it forwards no stable candidate id; +restores fields via a lossy last-write-wins `(provider, slot, bidder)` index +(`adserver_mock.rs:95`); breaks equal-price ties by response arrival order +(`orchestrator.rs:827`); and assigns parsed Prebid bids USD without +validating response currency (`prebid.rs:2318`). The algorithm below replaces +that, identically in the synchronous and split dispatch/collect paths, via +one shared candidate-selection helper. + +1. **Candidate registration.** Every direct-provider bid admitted by parsing + becomes a candidate with a **server-minted candidate id** (`c` + + 11-char CSPRNG, unique per auction). The full candidate (renderer, cache + coordinates, notification URLs, currency, provenance + `(provider, upstream_bid_id)`) is stored by candidate id. Winners are + selected **by candidate id** and their fields read from the stored + candidate — the lossy index is deleted. +2. **Currency.** The auction has one configured currency. A provider response + that declares another currency, or a path that cannot prove its currency + (the Prebid parse point must validate, not assume USD), rejects that bid + at parse with `bid_drop{currency_mismatch}`. No conversion. +3. **Mediator exchange.** Forwarded candidates carry their candidate id; the + mediator is required (wire contract, including `adserver_mock`) to echo it + on any bid derived from a forwarded candidate. A mediator bid **without** + an echoed id is mediator-native. A mediator bid with an id that does not + resolve → **the slot fails closed** for merging + (`mediation_provenance_invalid`: mediator-native bids for that slot still + compete; unresolvable forwarded claims are discarded and counted — a + warning alone is insufficient). +4. **Floors.** Slot floors filter both populations before selection. +5. **Dedup.** A mediator bid that echoes candidate id X removes direct + candidate X from the pool (it is the same demand, provenance `mediator`). +6. **Selection.** Per slot, the winner is the maximum under the **total + deterministic order**: decoded CPM desc → provenance rank (mediator + before direct) → provider name asc → candidate id asc. Response arrival + order can never matter. +7. **Strategy config.** `[auction].winner_selection` is **required whenever a + mediator and direct providers coexist** — startup error if absent (no + silent default; §0 removes the compatibility rationale for one): + `mediator_only` (mediator bids only, direct providers are signal) or + `merge_highest_cpm` (the algorithm above). A mediator timeout degrades to + direct-only selection and is reported. +8. **Reporting.** A selection report per auction: `winner_source`, + `mediator_superseded`, `currency_mismatch`, `dedup_hits`, + `mediation_provenance_invalid` — separate from delivery `bid_drop` rows. + +Deal priority remains out of scope: the `Bid` model carries no deal identity +(`types.rs:231`); a rule the model cannot express would be fiction. Recorded +as follow-up requiring a bid-model extension. + +### 6.2 Dimensions + +Exact size membership stays (`aps.rs:657-668`). The fix is visibility +(structured `bid_drop{invalid_dimensions, w, h}`, §5.6) plus documentation +("sizing your slots for APS"): if a size is acceptable, request it in the +slot's `formats` — accepting unrequested sizes would conceal an upstream +protocol violation. ### 6.3 Script creatives -Secure default kept; consequence made loud (5.5); enablement path documented. +`allow_script_creatives` stays default-`false` (defensible sandbox posture); +the consequence becomes loud (§5.8) and the enablement path documented. ### 6.4 Render identity -As G2. - -### 6.5 Fallback rendering - -As G4e — attribution-gated, not ownership-gated; timeouts never render; the -renderer is converted to an awaitable API with cancellation and terminal -reasons before the fallback lands. - -### 6.6 Renderer endpoint — unconditional, versioned, observable - -- The static renderer document route registers unconditionally in every - adapter (the APS provider stays config-gated). Startup validation fails - loudly if an auth handler pattern covers it. -- **Caching matches immutability:** `/integrations/aps/renderer/v1` is an - immutable artifact — its bytes change only by shipping `/v2` — so it is - served with `Cache-Control: immutable` (long max-age), published to shared - artifact storage in deploy stage 1 like every versioned asset (G5), which - also answers version-skew (`/v2` requests reaching older instances are - served from storage). Revision 3's `no-store` contradicted the versioning - and is corrected. -- Two-stage acknowledgement (G4b): authenticated `document_loaded`, then the - runner-load result — splitting `renderer_document_no_load` from - `runner_no_load`/`runner_failed`. -- **Server route counters are aggregate** (requests, unknown-version, - auth-blocked): the document request carries no trace (the nonce travels in - the URL fragment, which never reaches the server), so no row-level join is - claimed. -- **CSP report-only canary, fully specified:** reports go to a dedicated - `POST /_ts/csp-reports` route (same pre-parse caps and same-origin rules as - 5.3; credentials ignored; rate-limited fail-closed); stored as **aggregate - counters only** (directive, blocked-origin **host only** — full URLs - redacted) on sink-backed adapters, count-and-drop elsewhere; enforcement - follows only after a clean canary window. - -### 6.7 One descriptor schema - -As revision 3 (review-accepted): tagged-envelope schema generated from a -separate wire-schema crate/xtask; semantic validators hand-written on both -sides; outer-tolerance only, exact AAX projection; shared -positive + adversarial corpus across Rust, TS, and the inline document; -staleness CI. +As G2, including the `(trace_id, nav_gen, refresh_gen)` registry scope and +capacity rules. + +### 6.5 Fallback + +As G4e/G4a; awaitable renderer first; attribution-gated; timeouts never +render. + +### 6.6 Renderer endpoint + +- The static renderer document route registers **unconditionally in every + adapter** (the APS provider stays config-gated); startup validation fails + if an auth handler pattern covers it; §G5 route-isolation rules apply + (early dispatch, all methods reserved, no publisher fall-through). +- Path `/integrations/aps/renderer/v1`, embedded in the binary, served + `Cache-Control: immutable` (its bytes change only by shipping `/v2` in a + new release; §0's purge retires the old). Unknown versions → `404` + `no-store`. +- **Two-stage acknowledgement:** authenticated `document_loaded` (proves + route + auth + document CSP), then the runner-load result — splitting + `renderer_document_no_load` from `runner_no_load`/`runner_failed`. +- Server route counters (requests, unknown-version, auth-blocked) are + **aggregate only** — the document request carries no trace (the nonce + rides the URL fragment and never reaches the server). +- **CSP rollout that cannot false-pass:** discovery uses the **currently + enforced** policy with reporting attached (report-only alone cannot reveal + what the enforced policy already blocks). Candidate relaxations are tested + in a small enforced cohort under a short-lived canary document version; + once frozen, a new immutable `/v2` ships with the final policy. Reports: + dedicated `POST /_ts/csp-reports` accepting **both** + `application/csp-report` (legacy) and `application/reports+json` + (Reporting API), each with its own payload validator; §5.4 caps and + rate-limit rules; **origin rules account for the renderer's opaque + sandbox** (reports may carry `null` origin — validated by document URL / + policy version instead of the beacon's same-origin rule); stored as + aggregate counters with blocked sources bucketed into + `https-host | data | blob | inline | eval | other` (no arbitrary host + labels — cardinality abuse is otherwise trivial). Browser coverage for + opaque-renderer reports runs on **Chromium, Firefox, and WebKit** (CI is + Chromium-only today, `playwright.config.ts:16`; the matrix extends for + this suite). + +### 6.7 One descriptor schema — generation covers all three implementations + +- Wire truth: the tagged `BidRenderer` envelope (discriminator on the enum, + `types.rs:188-211`). +- A wire-schema crate/xtask (separate from `trusted-server-js`; core already + depends on that crate, `Cargo.toml:45`) generates: the JSON-Schema + artifact, the **TS structural parser**, the **ES5-compatible inline + validator fragment** embedded in the renderer document, and shared + fixtures — all checked in with staleness CI. Only environment-specific + semantic checks (URL/origin policy, canonical base64, length bounds, the + exact one-bid AAX projection, cross-field equality) stay handwritten. +- Tolerance only on the outer versioned descriptor; the decoded AAX envelope + remains an exact projection. A shared positive + adversarial corpus runs + through the Rust validator, the generated TS parser, and the generated + inline fragment in CI. ### 6.8 Bridge hardening -As revision 3 (review-accepted): source-first ownership with the bounded -parent-chain SafeFrame walk (depth 5, known slot-root `WindowProxy` map, no -tree scans); adversarial test set; top-of-listener hygiene; dead branch -deleted; renderer branches emit trace records under G4's taxonomy, with G4d -notifications only where the bid path carries them (never APS). - ---- - -## 7. Workstream 3 — TSJS target architecture +Processing order (normative — preserves the existing stolen-capability +defense that suppresses propagation before source validation, +`gpt/index.ts:1547`): + +1. parse `e.data` (bare `catch` → return); +2. identify a TS-reserved ad id (registry lookup); +3. if TS-reserved: `stopImmediatePropagation()` **before** any validation — + a rejected foreign frame must not be answerable by Prebid's native + handler either; +4. validate source ownership via the bounded walk: known slot-root + `WindowProxy` map, sender's own parent chain (`event.source.parent`, …) + to depth 5 — never scanning an attacker-controllable frame tree; +5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); +6. respond, or refuse with `bridge_id_mismatch`. + +Non-TS ad ids are untouched (no propagation suppression). The stolen-token +browser test asserts **neither TS nor the native Prebid listener responds**; +listener-registration ordering has a real-browser assertion. The dead +duplicate renderer branch (C5) is deleted; renderer branches emit the full +§5.1 sequence with G4d notifications only on carrying paths. + +## 7. TSJS target architecture ### 7.1 Layering -Kernel / adapters / services / integrations exactly as revision 3, with the -boundary lint in CI. Stateful services via the G3 ABI only. +``` +kernel/ boot, config, queue, event bus, log, beacon, sessions +adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access +services/ slots (registry+handoff), auction client, render engine, consent +integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services) +``` + +Boundary lint in CI (`import/no-restricted-paths`): kernel imports nothing +above it; adapters import kernel only; services import kernel + adapters; +integrations import kernel + services, never each other. This dissolves the +audited inversions (`core/auction.ts` and `core/request.ts` importing +`integrations/aps/render`; `gpt` and `prebid` importing `aps`; `prebid` +owning the GPT refresh wrapper). Stateful services via the G3 registry only. ### 7.2 Adapters -`present | pending | timed_out` with non-terminal `timed_out` (late loaders -transition to `present`); per-operation timeouts with disposition reasons. +Per external global: `present | pending | timed_out`, `timed_out` +non-terminal (late loaders transition to `present` and drain what is still +valid); queued operations carry their own timeouts and expire with +disposition reasons. ### 7.3 Slot registry service -Kernel-owned registry (`WeakMap` + div-id index) -holding ownership, adoption, handoff claims, responsive resolution, pending -request cycles (G4a), and targeting-key history. No expandos on GPT objects. - -### 7.4 Global namespace policy — with a compatibility window - -As revision 3 (review-accepted): `window.tsjs` + `tsjs._internal`; the public -queue keeps its real name **`tsjs.que`**; public globals -(`tscreative`, `tsCreativeConfig`, `tsjs.que`) get dual-read/write for two -release cycles / ≥ 60 days, closing only on the adoption gate (old-name usage -< 0.1% of traces for 14 days, measured on sink-backed deployments); -`requestAds` keeps its void signature; `requestAdsAsync` is the new versioned -API; private globals migrate immediately. +Kernel-owned; `WeakMap` + div-id index; holds +ownership (ts/publisher/adopted), handoff claims, responsive resolution, +G4a intent queue + cycle state (RuntimeSession-scoped), targeting-key +history. No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid` +deleted). + +### 7.4 Final global surface (hard cutover — no dual names) + +| Legacy surface (removed at cutover) | Final shape | +| -------------------------------------------- | ----------------------------------------------------------------------------- | +| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue | +| `globalThis.tscreative` (API) | `tsjs.creative.*` (same methods, namespaced) | +| `globalThis.tsCreativeConfig` (pre-load) | `tsjs.boot.creative` inside the boot container (below) | +| `requestAds` (void) — and rev-4's dual API | **one** async contract: `tsjs.requestAds(options): Promise` | +| `window.__tsjs_*` flags, integration configs | `tsjs.boot.*` fields written by server-injected scripts before the bundle | +| server install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | +| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | +| `tsjs._internal` | kernel-owned registry (G3), **frozen after boot** | + +Boot container lifecycle: pre-core scripts write +`window.tsjs = window.tsjs || {que: [], boot: {}}` fields; the kernel +**consumes `boot` at boot, deep-freezes the retained copy, and deletes +consumed one-shot secrets** (the trace authorization moves into the sealed +NavigationSession). Old pages referencing removed names fail at cutover — +accepted per §0. ### 7.5 Messaging module -All `postMessage` through one module: versioned envelopes, name constants, -G4b nonces, 6.8 source validation. A **minimal** messaging module (envelope + -constants + validation helpers used by the bridge) lands early (Phase 1) so -Phase 3 does not depend on Phase-4 structure; the full migration of every -legacy call site completes in Phase 4. - -### 7.6 Plugin lifecycle and session model - -As revision 3 (review-accepted), with G3's sharpened version semantics: -manifest versions are ranges (major + minMinor); quarantine on -incompatibility; first-wins only among compatible. Sessions: -`RuntimeSession` / `NavigationSession` / `RenderAttempt` with enumerable -disposal inventories. Error policy: no empty `catch`; auction fetch gets -timeout + `AbortController`. **Console logging retained, not replaced** -(paired `warn` with the beacon's reason code; `debug`-level -delivery/security failures promoted to `warn`). - -### 7.7 The bootstrap problem - -As revision 3: queue-and-flags stub + bundle replay behind its own flag with -replay-timing specs; the no-bundle fallback generated from the TypeScript -source. - -### 7.8 GPT correctness fixes carried with the restructure - -- **Unconditional early GPT event subscription:** the `slotRenderEnded` - (and `slotRequested`) listeners register on the command queue at install, - no longer gated behind `!ts.servicesEnabled` (`gpt/index.ts:1017`) — G4a - cannot work on bootstrap-first pages otherwise. Recording is idempotent so - double-registration cannot double-count. -- Restore #922/#997 attribution and orphan recovery. -- `changeCorrelator: false` on TS-initiated refreshes (configurable). -- `enableSingleRequest()` only when GPT services are not already enabled. -- Ambiguous responsive resolution emits `render_fail{slot_unresolved}`. +All `postMessage` through one module: versioned envelopes, name constants +(the `'Prebid Request'` literal exists at six sites today; the APS handshake +in three copies), G4b nonces, §6.8 validation. The minimal module (envelope + +constants + validators used by the bridge) lands in Phase 1; full call-site +migration completes in Phase 4. + +### 7.6 Plugin lifecycle — transactional — and sessions + +`tsjs.definePlugin(id, install, dispose?)` with `install(ctx)`: + +- `ctx.signal` (aborted on quarantine/disposal); synchronous + `ctx.onDispose(fn)` registration; effects must be registered as they are + made. +- **Unwind on failure:** a throw, rejection, or abort triggers automatic + reverse-order invocation of the disposers registered so far — partial + installs cannot leak effects. Per-disposer exception isolation (one + throwing disposer cannot stop the rest). +- A disposer registered (or returned) **after** the owning session was + disposed is invoked immediately. +- Pending late registrations (manifest requested, bundle not yet evaluated): + capacity 16, bound 10 s, then `bundle_partial`. +- Release matching per G3: a plugin whose `release_id` differs from the + kernel's is quarantined before `install` runs. +- Sessions: `RuntimeSession` (page lifetime: bridge listener, history hook, + pbjs subscriptions, adapters, beacon queue, **slot cycle state**); + `NavigationSession` (per navigation: trace + authorization, render + attempts, slot aliases, targeting history); `RenderAttempt` (per G4a cycle + or G4f attempt). Each owns an enumerable disposal inventory; navigation + disposes only NavigationSession children. +- Error policy: no empty `catch` — handle, log with context, or emit a + disposition. The auction fetch gains timeout + `AbortController`. +- **Console logging retained:** every issue-surfacing condition keeps or + gains a `log.warn` carrying the same reason code as its beacon event; + `debug`-level delivery/security failures are promoted to `warn`. + +### 7.7 Bootstrap + +`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/hydration +logic, with the live `servicesEnabled` divergence) shrinks to a +queue-and-flags stub; the bundle replays recorded early calls on install. +Replay changes observable ordering — it ships inside the cutover with +browser specs covering replay timing. The no-bundle fallback ("ads render if +the bundle fails", pinned by `gpt.rs:1174-1179`) is **generated from the +same TypeScript source** at build time. + +### 7.8 GPT correctness carried with the restructure + +Unconditional early `slotRequested`/`slotRenderEnded` subscription (replacing +the `!servicesEnabled` gate, `gpt/index.ts:1017`; recording idempotent); +restore #922/#997 attribution and orphan recovery; `changeCorrelator: false` +on TS refreshes (configurable); `enableSingleRequest()` only when GPT +services are not already enabled; ambiguous responsive resolution emits +`render_fail{slot_unresolved}` alongside its warning. ### 7.9 Decomposition targets -As revision 3 (gpt/prebid splits, script-guard consolidation, trace model/UI -split, `global.d.ts` fix). - -### 7.10 Performance - -Budgets tightened per review: per-bundle raw/gzip/Brotli for the exact -ordered module vector vs a checked-in baseline, **+5% byte tolerance**; -browser timing assertion (bids-script-to-first-`display()`) on a **pinned CI -runner class and pinned browser version**, **5 warm-up runs discarded, 50 -measured samples, gate on p90 with +10% latency tolerance**; server bench -(precomputed concatenation) gates CPU and heap at **±10% vs baseline** with -pinned tool versions. Precompute lands in Phase 0 with asset identity (G5). - -### 7.11 Toolchain and dependency currency - -As revision 3 (review-accepted): raise the TypeScript floor to the resolved -5.9 line, then evaluate the next major separately; strictness flags on; dev -toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from casual -bumps; monthly review policy. - ---- +| Today | Target | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | +| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | +| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | +| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | +| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | + +### 7.10 Performance (reproducible) + +- Bundle budgets: raw/gzip/Brotli per bundle for **three module vectors** + (minimal, reference, maximal), compressors pinned (`gzip -9`, + `brotli -q 11`), compared to checked-in baseline artifacts + (`perf/baselines/*.json`); baseline updates are explicit reviewed diffs. + Tolerance +5% bytes. +- Browser timing (bids-script-to-first-`display()`): pinned CI runner class + (the repository's standard Linux runner image) and the + Playwright-pinned browser build; 5 warm-up runs discarded, 50 samples, + gate p90 ≤ baseline × 1.10. +- Server (precomputed concatenation): one-sided gates, CPU and heap ≤ + baseline × 1.10; improvements always pass. Tool versions pinned in + `.tool-versions`. + +### 7.11 Toolchain + +Raise the TypeScript floor to the resolved 5.9 line (lockfile already +resolves 5.9.3 under the stale `^5.5.4` manifest); adopt +`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, +`verbatimModuleSyntax`; dev-toolchain bumps (eslint, prettier, jsdom, +`@playwright/test`, `@types/node`) as individual CI-gated PRs with changelog +review (this library monkeypatches `fetch`/`sendBeacon`/DOM prototypes — +jsdom/Playwright changes are real risks); `prebid.js` excluded from casual +bumps (runtime Prebid is the manifest-locked external bundle; the npm pin +and deployed bundle version documented together); monthly review; no phase +starts more than one minor behind stable. ## 8. Migration plan -Each phase ships behind a feature flag with **phase-specific gates** (the -render-fail canary cannot evaluate phases that predate the metric): - -- **Phase 0 — Asset identity, contracts, toolchain.** Two-stage artifact - publishing (shared immutable storage) + path-based identity + ordered-vector - precompute + rolling-deploy tests in both directions + legacy-URL test; - toolchain floors; contracts G1–G5 as code-adjacent docs; delete dead expando - writes; server drop-reason surfacing. - _Gate:_ zero unexpected `410`s and zero legacy-URL breakage on canary; asset - hit/miss counters nominal. -- **Phase 1 — Kernel ABI, sessions, minimal messaging, minimal cycle - registry.** Versioned registry with `(major, minor)` semantics; - `RuntimeSession`/`NavigationSession`; install manifest; the minimal - messaging module (7.5) and the cycle-aware slot-record core (G4a's queue) - land here so Phase 3 has its dependencies; unconditional early GPT - subscriptions (7.8). - _Gate:_ ABI install-success counters clean; zero `abi_mismatch` on canary; - no listener regression in browser specs. -- **Phase 2 — Trace and beacon.** Server-minted initial trace + page-bids - `X-TSJS-Trace-Id` echo + capability issuance (G1, 5.3); beacon service; - four-adapter ingest; `ts_client_events`. - _Gate:_ ingest acceptance/drop/abuse counters nominal; trace join rate on - sink-backed canary ≥ 95% of sampled traces. -- **Phase 3 — APS delivery.** Wire-schema crate + corpus; mediation helper + - opt-in merge; render token; unconditional versioned renderer route + - two-stage ack; bridge hardening; request-cycle protocol + render state - machine + awaitable renderer + scoped notifications (G4a–G4d); the opt-in - fallback (G4e); restore #922/#997; correlator and SRA fixes. - _Gate:_ `render_fail` rate within +0.5% absolute of pre-flag baseline over - 24 h on canary; fill/latency/billing volume deltas within agreed bounds +Phases are internal build milestones of **one coordinated release** (§0): +each lands behind a flag in the dark deployment; the cutover switches them +on together. Gates are executable — every gate names query, cohort, +denominator, minimum sample, threshold, window, owner (release owner unless +stated), and action (hold cutover / rollback switch): + +- **Phase 0 — Identity, schemas, toolchain.** Path-hashed embedded assets + + 410 semantics + ordered-vector precompute; `format_version`; §5.6 schemas + deployed and validated (writer-off); toolchain floors; dead expando writes + deleted; §5.8 server drop surfacing. + _Gate:_ dark-instance health 100%; datasource validation green (rejection + rate < 0.1% on synthetic writes, freshness ≤ 5 min); asset `410` rate on + dark probes = 0 for known hashes. +- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry.** G3 + registry (exact release ids); RuntimeSession/NavigationSession; install + manifest; minimal messaging module; G4a intent/cycle records; unconditional + GPT subscriptions. + _Gate:_ browser-spec suite green incl. listener-order assertions; zero + `abi_mismatch`/`bundle_partial` on dark probes. +- **Phase 2 — Trace + beacon.** Server-minted initial trace + page-bids + header echo + signed authorizations; beacon service; four-adapter ingest; + `ts_client_events` writers on. + _Gate:_ on dark probes: ingest acceptance ≥ 99%, group auth-rejection + < 0.5%, dedup query returns exactly-once per `(trace, seq)`; freshness + ≤ 5 min over a 24 h window. +- **Phase 3 — APS delivery.** Schema crate + corpus (6.7); mediation + algorithm + required `winner_selection` (6.1); render token (G2); + renderer route + two-stage ack + CSP report route (6.6); bridge order + (6.8); G4a–G4f state machines, awaitable renderer, scoped notifications, + fallback; #922/#997 restoration; correlator + SRA fixes. + _Gate (canary cohort vs simultaneous control cohort, 24 h, minimum 10,000 + sampled attempts each):_ APS `render_accepted` / attributable APS attempts + ≥ 95%; renderer-document load rate ≥ 99%; runner failure+timeout ≤ 1%; GAM + fill, p90 latency, and billing volume deltas within ±2% of control (billing measured against GAM/server-side reporting, not the beacon); - release-gating real-GAM overlap test green. -- **Phase 4 — Structure.** Full layering + boundary lint; plugin lifecycle - completion; adapters; full slot registry; full messaging migration; - namespace window (7.4). - _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests green. -- **Phase 5 — Decomposition.** File splits; script-guard consolidation; - bootstrap shrink (own flag, replay-timing specs); compatibility-window - close (adoption-gated). - _Gate:_ bundle budgets and timing assertions hold; adoption gate met before - any removal. - ---- + real-GAM overlap test green. Action on breach: hold cutover. +- **Phase 4 — Structure.** Full layering + boundary lint; transactional + plugin lifecycle; adapters; full slot registry; full messaging migration; + final namespace (7.4). + _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests + green; pre-cutover page smoke on the final namespace. +- **Phase 5 — Decomposition + cutover.** File splits; script-guard + consolidation; bootstrap stub + generated fallback; then the §0 runbook. + _Gate:_ bundle budgets + timing assertions hold; cutover checklist signed + off; post-switch monitor window 24 h on the §5.7 objectives; rollback = + traffic switch back. ## 9. Test acceptance matrix -Blocking CI is hermetic; the staged smoke suite (real GAM line items, -including the G4a overlap test) is release-gating. - -| Area | Must cover | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Mediation | both lifecycles; ties, floors, currency rejection; provenance dedup; transformed ids → `mediator_provenance_unresolved`; `mediator_only` default; rollback blob round-trip | -| Cache identity | non-APS cache-backed bids byte-identical; PUC `?uuid=` path | -| Render token | format/CSPRNG/in-auction retry/TTL/one-time/per-`(trace, nav_gen)` scoping | -| Request cycles | ts-vs-publisher classification; one-outstanding-TS-cycle serialization; publisher refresh overlapping TS cycle → `cycle_unattributable` fail-closed; late events; **real-GAM overlap (release gate)** | -| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame; stale/replayed acks | -| Render semantics | notifications only on carrying paths (never APS); `nurl` at bind, `burl` at accepted, attempt-scoped idempotency; `billed_then_failed`; no paint claims (`adm_document_loaded` labeling); accepted-but-blank | -| Fallback | renders only on attributed `gam_empty` (adopted **and** TS-owned); publisher-initiated cycles never trigger; timeout diagnostics-only; SPA cancellation; destruction; exactly-once terminal | -| Bridge security | wrong-slot/stolen/replayed/prior-navigation tokens; nested foreign frames; bounded parent-chain walk; SafeFrame positive | -| Beacon | initial-trace join; page-bids header echo + response trace/capability; per-trace grouping across navigation-spanning batches; `seq`-gap partial traces; ingest abuse incl. absent Origin; capability signature; sendBeacon Blob | -| Schema | staleness; adversarial corpus ×3 validators; outer-tolerance vs exact AAX projection | -| Runtime ABI | one kernel under concatenation; deferred late registration; failure isolation; **mixed-version verdicts (compatible-range install vs quarantine)**; first-wins among compatible only | -| Lifecycle | `timed_out → present`; session disposal inventories; stale async install; pre-init `tsCreativeConfig` + `tsjs.que`; dual-name window | -| Delivery | two-stage deploy simulation **both rolling directions**; new-HTML-hash on old instance (storage lookup); retained-hash miss; legacy query-hash sunset path; unknown hash 410 `no-store`; immutable exact-match only; ordered vector | -| Renderer endpoint | route in all adapters; auth-pattern startup failure; `/v1` immutable caching; version-skew via storage; `document_loaded` vs runner split; CSP report route caps/redaction | -| Adapter parity | ingest, CSP-report, renderer routes and drop-reason surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | -| Policy | script-creative warning; `invalid_dimensions` + bounded width/height; page-bids `debug` gating; diagnostic unsampled completeness; trace-bearing HTML `private, no-store` invariant | - ---- +Hermetic CI (deterministic PUC/message harness) blocks PRs; the staged +real-GAM suite is release-gating. Rows added this revision are marked •. + +| Area | Must cover | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Request cycles | intent-vs-request separation; • initial-load-disabled cycle formation (display creates no request); SRA batching; `intent_no_request`; publisher overlap → quarantine; • old-navigation completion before and after replacement; drain/re-arm; real-GAM overlap (gate) | +| Ack protocol | five-field validation; SSAT/client-Prebid/SafeFrame; stale + replayed acks; • acks after navigation disposal | +| Bridge security | • TS-reserved id rejected with propagation stopped — neither TS nor native Prebid responds; wrong-slot/stolen/prior-navigation tokens; bounded parent-chain walk; listener-order real-browser assertion; SafeFrame positive | +| Render semantics | notifications only on carrying paths (never APS); bind per flow (PUC claim / direct render-start / fallback pre-render); `burl` at `render_accepted`; attempt-scoped idempotency; `billed_then_failed`; accepted-but-blank | +| Direct `/auction` | • full G4f lifecycle: attempt keys, refresh_gen increments, cancellation on navigation, exactly-once terminal, same event sequence | +| Fallback | only attributed `gam_empty` (adopted + TS-owned); publisher-initiated never; timeout never renders; SPA cancellation; • flag change during an active attempt | +| Mediation | • candidate-id echo; • transformed/unresolvable provenance → slot fails closed for merging; • duplicate candidates dedup; • deterministic ties independent of arrival order; currency validation at the Prebid parse point; both lifecycles; required `winner_selection` | +| Render token | format/CSPRNG/in-auction retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scoping; • registry capacity → `registry_full` | +| Trace auth | • HMAC verification: expiry, skew, max-future, missing kid, rotation (previous key), constant-time path; per-group rejection; cache-privacy invariant (`private, no-store`) | +| Beacon | initial + SPA trace joins; per-trace grouping; seq gaps; • duplicate fetch/pagehide delivery deduped at sink; queue overflow marker; ingest abuse; sendBeacon Blob type | +| Ingest/limits | • token-bucket rate + burst; • limiter saturation and address churn; • map capacity/TTL/eviction; fail-closed drop with 204 | +| Internal routes | • wrong-method → 405 + Allow + no-store on every adapter; • unknown version → 404 no-store; • no publisher fall-through; • dispatch before auth/EC/filters; • no body/cookie/authorization forwarding | +| CSP | • both media types with separate validators; • opaque/null-origin renderer reports accepted; • bucketed aggregation only; • Chromium/Firefox/WebKit capture; • enforced-policy discovery vs canary-cohort relaxation | +| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection | +| Runtime ABI | one kernel under concatenation; exact-release verdicts (match runs, mismatch quarantines); late registration; failure isolation | +| Plugins | • partial synchronous install unwound in reverse order; • async rejection; • abort while pending; • disposer-after-disposal invoked immediately; per-disposer isolation | +| Lifecycle | `timed_out → present`; session disposal inventories; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`) | +| Delivery | unknown hash 410 no-store; immutable on exact match; ordered-vector precompute; cutover runbook rehearsal (switch + purge + rollback switch) | +| Sink | • datasource-side freshness + rejection monitoring (sink is fire-and-forget); • sink auth/schema rejection surfaced by monitor | +| Failure injection | • Amazon runner redirect, network hang, CSP block, script error → distinct §5.1 outcomes | +| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | +| Policy | script-creative warning; `invalid_dimensions` + bounded w/h; `dimensions_out_of_range` unclamped; page-bids `debug` gating; diagnostic completeness | ## 10. Alternatives considered -Unchanged from revision 3 (patching without telemetry; always-direct-render; -single module graph; big-bang rewrite; dropping the bootstrap; -timeout-triggered fallback — all rejected for the recorded reasons). +1. **Patching APS point-failures without telemetry** — rejected: three + correct fixes produced no ads; the next fix would be another guess. +2. **Always direct-render APS (skip GAM/PUC)** — rejected: unilaterally + changes GAM reporting/pacing; kept only as the attributed-`gam_empty` + fallback. +3. **Single module graph / shared chunks now** — rejected for this release: + changes the delivery pipeline while everything else changes; recorded as + the successor behind the same registry surface. +4. **Full rewrite in one branch without phases** — rejected: the browser-spec + safety net is thinnest exactly where behavior changes. +5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle + guarantee; generation from TS keeps it without dual maintenance. +6. **Timeout-triggered fallback** — rejected: GPT requests cannot be + cancelled; a timeout race can double-render and double-bill. +7. **N/N−1 compatibility machinery** (revisions 3–4) — removed by the §0 + policy decision: version ranges, retained artifacts, legacy URLs, and + dual-name globals deleted in favor of exact release matching and a + coordinated switch. ## 11. Risks -Revision 3's list, plus: **shared-storage dependency for assets** (stage-1 -publish becomes a deploy prerequisite; mitigated by the embedded fast path -for the current vector and deploy-time verification that storage matches the -embedded hashes); **notification-trigger semantics** are now a published -contract for PBS-path demand — changing them later is a breaking change for -SSP reporting expectations. +- **Hard cutover blast radius:** in-flight pages fail at switch; accepted by + policy (§0); bounded by the purge + 24 h monitored window + traffic-switch + rollback. +- **Mediator wire-contract change** (candidate-id echo) requires + coordinating the mediator implementation; until echoed ids exist, + `merge_highest_cpm` cannot be enabled (config validation enforces this). +- **Notification triggers become a published contract** for PBS-path demand; + changing them later is a breaking change for SSP reporting. +- **Beacon abuse:** bounded by pre-parse caps, origin checks, fail-closed + numeric rate limits, server-decided sampling, signed authorizations. +- **Registry/limiter memory:** all client and server maps carry explicit + capacities, TTLs, and eviction rules (G2, §5.4). +- **CSP relaxation:** enforced-cohort canary + new immutable version prevent + false-clean canaries; bucketed aggregation prevents cardinality abuse. +- **Sink blindness:** fire-and-forget dispatch is compensated by mandatory + datasource-side freshness/rejection monitoring. ## 12. Success criteria -1. APS creatives render on a reference page in each configured flow, - hermetically in CI and via the staged smoke suite (including the real-GAM - overlap test). -2. Every failure point in section 2 maps to a distinct observable signal; - diagnostic mode names the failing reason from one page load; production - telemetry meets the 5.4 SLO on sink-backed deployments. -3. Boundary lint zero exceptions; stateful sharing only via the versioned - ABI; mixed-version delivery resolves to the G3 verdicts. -4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or +1. APS creatives render in each configured flow — SSAT, client-Prebid, + page-bids, and direct `/auction` (G4f) — hermetically in CI and in the + release-gating real-GAM suite. +2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the + failing class from one page load; §5.7 objectives hold on sink-backed + deployments. +3. Boundary lint zero exceptions; stateful sharing only via the G3 registry; + exact-release mismatches quarantine loudly. +4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. 5. Trace counts are per-impression; orphan recovery has a non-vacuous test; - cycle attribution follows G4a including the publisher-overlap fail-closed - rule. -6. The only TSJS-owned global is `window.tsjs` (public globals only inside - their window, closed by the 7.4 adoption gate); no expandos on GPT slots, - GPT functions, or `pbjs`. -7. Bundle budgets (+5% bytes) and the pinned-environment p90 timing assertion - (50 samples, +10% tolerance) hold; server concatenation is precomputed - within ±10% CPU/heap of baseline. -8. No existing warning is lost; every issue-surfacing condition logs at - `warn` or above with the beacon's reason code. + cycle attribution follows G4a (physical requests, publisher-overlap + quarantine, drain/re-arm). +6. The only TSJS-owned global is `window.tsjs` with the §7.4 final shape; no + expandos on GPT slots, GPT functions, or `pbjs`; legacy names are gone at + cutover. +7. §7.10 budgets hold (three vectors, pinned tools, one-sided server gates). +8. No existing warning is lost; every issue-surfacing condition logs `warn`+ + with the beacon's reason code. 9. TypeScript floor matches the resolved 5.9 line with strictness flags on; - `prebid.js` pin matches the documented deployed bundle; monthly review - policy in CI docs. -10. Rolling-deploy tests pass in both directions; legacy URLs serve through - their sunset; unknown hashes 410 `no-store`; immutable only on exact - match. -11. `nurl`/`burl` fire only on carrying paths, on their G4d transitions, + `prebid.js` pin matches the documented deployed bundle. +10. `nurl`/`burl` fire only on carrying paths at their G4d binds, attempt-scoped and idempotent; APS fires neither. -12. Trace-bearing responses are `private, no-store` by test; capabilities are - per-trace and never authorize a trace they were not bound to. +11. Trace-bearing responses are `private, no-store` by test; authorizations + are per-trace, signed, mode-carrying, and never accepted unsigned. +12. The cutover runbook has been rehearsed (switch, purge, rollback switch) + before the production switch. ## 13. Open questions @@ -701,8 +927,7 @@ SSP reporting expectations. should the original be re-merged? 5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep accept-count-drop? -6. Does Amazon expose any creative-completion acknowledgement that could - reintroduce a confirmed state (G4c) under a new name? -7. Which shared storage backs stage-1 artifact publishing per platform (KV - store vs config store vs CDN), and who owns the publish step in the deploy - pipeline? +6. Does Amazon expose any creative-completion acknowledgement that could add + a confirmed state beyond `render_accepted` under a new name? +7. Who implements and owns the mediator-side candidate-id echo (6.1), and on + what timeline relative to this release? From 542c6e7a9a1ea1054ed0e223bf32828a1b15f5f5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:21:06 -0700 Subject: [PATCH 546/844] Close residual review deltas on revision 5 The fifth review round matched revision 4's text and line positions; revision 5 already contained the contested contracts (slotRequested- opened cycles, RuntimeSession drain state, propagation-stopping bridge order, per-flow nurl bind, G4f direct lifecycle, inlined mediation algorithm, signed server-decided sampling, observation events in the wire enum, ts_client_events schema, dedup keys, numeric limiter bounds, isolated internal routes, opaque-origin CSP handling, transactional plugins, final namespace table, refresh_gen token scope, generated inline validator, control-cohort Phase 3 gates, and the failure-to-signal mapping). This commit patches the genuine remainders: minimum key strength and missing-key startup behavior for the trace authorization, client-events sink batch cap with startup validation, sink-unavailable behavior, canonical join and alert ownership, the three-instrument CSP rollout (enforced discovery, report-only tightening, enforced-cohort relaxation with named gates and kill switch) with unused-field discard, p95 for the Phase 3 latency gate, a four-flow behavioral-parity gate on Phase 4, named runner image and browser pinning with baseline-validity rules, the operator-query note on the failure mapping, and the wire-event clarification for G4c observations. --- ...s-render-fix-and-tsjs-resilience-design.md | 78 ++++++++++++------- 1 file changed, 52 insertions(+), 26 deletions(-) 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 4cf8c4d4d..b02247a72 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 @@ -107,7 +107,9 @@ time; a bid that never painted is byte-identical to one that painted. ### 2.5 Failure → signal mapping (normative) Every section-2 failure maps to a distinct observable **failure class** (not -a claim to distinguish unknowable root causes): +a claim to distinguish unknowable root causes). The operator query for each +row is the §5.6 canonical join filtered by that row's event/reason or +counter; the console column is what diagnostic mode mirrors on-page: | Failure | Client event/reason (§5.1) | Server counter/row (§5.6) | Console | | ------- | -------------------------------------------- | -------------------------------------- | ------------ | @@ -368,6 +370,11 @@ configured flow" in the success criteria includes this one. ] } ``` +Every G4c observation is a **wire event** in this enum (internal state +transitions map to them one-to-one; nothing observable is state-only). +`gam_empty` additionally appears as a `render_fail` reason when it is the +terminal outcome of an attributed attempt. + The `t` enum now **contains every G4c observation**, so Phase-3 stage rates (renderer-document load rate, runner load/failure, GAM fill) are queryable. Reason enum (closed): `renderer_document_no_load`, `runner_no_load`, @@ -392,7 +399,10 @@ fetch/pagehide races are expected and handled at the sink (§5.5). Format `v1....`: - `kid`: key id; **active and previous keys** live in the platform secret - store; rotation = introduce new key as active, demote, retire. + store; keys are ≥ 256-bit CSPRNG values; rotation = introduce new key as + active, demote, retire. **Missing-key startup behavior:** if the beacon is + enabled and no signing key resolves at startup, startup fails loudly + (config error) — traces are never issued unsigned. - `exp`: unix epoch seconds; verifier allows ±60 s skew; maximum future 15 minutes from issuance. - `mode`: `sampled` | `diagnostic`. Sampling is server-decided (G1); @@ -443,7 +453,13 @@ id_kind (Enum), matched (UInt8), source (Enum), reason (Enum §5.1)`. Sorting key `(publisher, ts, trace_id, seq)`; 30-day TTL; its own ingest token, configured via new `TinybirdSettings` fields (`client_events_dataset`, `client_events_token_secret`) — today's settings - configure only the auction dataset (`settings.rs:1752`). + configure only the auction dataset (`settings.rs:1752`). Sink batch cap: + 512 rows per dispatch (matching the auction sink). Startup validation: + when client events are enabled, the dataset name and token secret must + resolve or startup fails. Sink-unavailable behavior at runtime: + accept-count-drop (ingest still answers `204`). Canonical join: + `ts_client_events` ⋈ auction rows on `(publisher, trace_id)`; dashboards + and alerts are owned by the release owner and defined with the datasource. - **Auction rows** (`AuctionEventRow`, `telemetry.rs:262`, and `auction_events_raw.datasource`): add nullable `trace_id (FixedString 32)` and `mode`; add a bounded **`bid_drop` row type** @@ -564,23 +580,27 @@ render. - Server route counters (requests, unknown-version, auth-blocked) are **aggregate only** — the document request carries no trace (the nonce rides the URL fragment and never reaches the server). -- **CSP rollout that cannot false-pass:** discovery uses the **currently - enforced** policy with reporting attached (report-only alone cannot reveal - what the enforced policy already blocks). Candidate relaxations are tested - in a small enforced cohort under a short-lived canary document version; - once frozen, a new immutable `/v2` ships with the final policy. Reports: - dedicated `POST /_ts/csp-reports` accepting **both** - `application/csp-report` (legacy) and `application/reports+json` - (Reporting API), each with its own payload validator; §5.4 caps and - rate-limit rules; **origin rules account for the renderer's opaque - sandbox** (reports may carry `null` origin — validated by document URL / - policy version instead of the beacon's same-origin rule); stored as - aggregate counters with blocked sources bucketed into - `https-host | data | blob | inline | eval | other` (no arbitrary host - labels — cardinality abuse is otherwise trivial). Browser coverage for - opaque-renderer reports runs on **Chromium, Firefox, and WebKit** (CI is - Chromium-only today, `playwright.config.ts:16`; the matrix extends for - this suite). +- **CSP rollout that cannot false-pass.** Three distinct uses, each with its + own instrument: **discovery** uses the **currently enforced** policy with + reporting attached (report-only alone cannot reveal what the enforced + policy already blocks); **tightening** candidates run report-only; + **relaxation** candidates are tested in a small enforced cohort under a + short-lived canary document version, gated on runner acceptance rate, CSP + violation rate, render-failure rate, and a kill switch that reverts the + cohort to the frozen policy. Once frozen, a new immutable `/v2` ships with + the final enforced headers. Reports: dedicated `POST /_ts/csp-reports` + accepting **both** `application/csp-report` (legacy) and + `application/reports+json` (Reporting API), each with its own payload + validator; §5.4 caps and rate-limit rules; **origin rules account for the + renderer's opaque sandbox** (reports may carry `null` origin — validated + by document URL / policy version instead of the beacon's same-origin + rule); unused report fields are **discarded before logging or + aggregation**; stored as aggregate counters with blocked sources bucketed + into `https-host (allowlisted) | data | blob | inline | eval | other` (no + arbitrary host labels — cardinality abuse is otherwise trivial). Browser + coverage for opaque-renderer reports runs on **Chromium, Firefox, and + WebKit** (CI is Chromium-only today, `playwright.config.ts:16`; the matrix + extends for this suite). ### 6.7 One descriptor schema — generation covers all three implementations @@ -747,10 +767,13 @@ services are not already enabled; ambiguous responsive resolution emits `brotli -q 11`), compared to checked-in baseline artifacts (`perf/baselines/*.json`); baseline updates are explicit reviewed diffs. Tolerance +5% bytes. -- Browser timing (bids-script-to-first-`display()`): pinned CI runner class - (the repository's standard Linux runner image) and the - Playwright-pinned browser build; 5 warm-up runs discarded, 50 samples, - gate p90 ≤ baseline × 1.10. +- Browser timing (bids-script-to-first-`display()`): named runner image + `ubuntu-24.04` (the CI image already used by this repository's workflows) + and the Chromium build bundled with the pinned `@playwright/test` version + from `package.json`; 5 warm-up runs discarded, 50 samples, gate p90 ≤ + baseline × 1.10. The baseline artifact records image, browser, and tool + versions alongside the numbers; a baseline update is invalid if any of + those differ from the pinned set. - Server (precomputed concatenation): one-sided gates, CPU and heap ≤ baseline × 1.10; improvements always pass. Tool versions pinned in `.tool-versions`. @@ -803,14 +826,17 @@ stated), and action (hold cutover / rollback switch): _Gate (canary cohort vs simultaneous control cohort, 24 h, minimum 10,000 sampled attempts each):_ APS `render_accepted` / attributable APS attempts ≥ 95%; renderer-document load rate ≥ 99%; runner failure+timeout ≤ 1%; GAM - fill, p90 latency, and billing volume deltas within ±2% of control + fill, p95 latency, and billing volume deltas within ±2% of control (billing measured against GAM/server-side reporting, not the beacon); real-GAM overlap test green. Action on breach: hold cutover. - **Phase 4 — Structure.** Full layering + boundary lint; transactional plugin lifecycle; adapters; full slot registry; full messaging migration; final namespace (7.4). _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests - green; pre-cutover page smoke on the final namespace. + green; pre-cutover page smoke on the final namespace; **behavioral-parity + suite green across all four flows** (SSAT, client-Prebid, page-bids, + direct `/auction`) comparing pre- and post-restructure event sequences on + the reference page. - **Phase 5 — Decomposition + cutover.** File splits; script-guard consolidation; bootstrap stub + generated fallback; then the §0 runbook. _Gate:_ bundle budgets + timing assertions hold; cutover checklist signed From 8732fc6d36f4fa107ca32dd56b43b349843b6596 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:02:53 -0700 Subject: [PATCH 547/844] Revise design spec: adopt the 248fe9558 baseline and answer round five Advances the baseline to the APS PUC/collapsed-shell fix (citations refreshed; C5 marked fixed; G4b rebuilt on the MessageChannel transport with a three-message authenticated sequence; gam_collapsed added as an observation with the guarded resize adopted as a sanctioned exception) and resolves the fifth review round: a 256-byte auth bound with full token encoding rules, a signed unsampled mode that transmits nothing, an operator-credential diagnostic gate replacing the non-security tester cookie, trace issuance on the direct /auction path via header and response extension, causal intent classification that retires known zero-request display intents and quarantines ambiguous overlap, removal of timeout re-arm in favor of drain/destroy/page-end, RuntimeSession tombstones preserving bridge suppression across navigations, an arrival- independent mediation order on intrinsic candidate keys with required auction currency and strategy-specific timeouts, a per-event field matrix with nullable storage columns and dedicated overflow and billing-outcome events, an adapter rate-limiter abstraction with per-platform semantics and reject-at-capacity, direct-auction serialization and notification plumbing, idempotent field-wise boot initialization with fallback activation and arbitration, auth renewal for long-lived pages, selection_summary rows, canonical dedup views with heartbeat monitoring, a complete settings schema, boot.debug envelopes making every failure class one-page-load visible, an object-form plugin API carrying release ids, construction-time concatenation caching, forgery-resistant CSP report routing with frozen per-version header manifests, a single router-weight rollout state machine with phase decision records and a checked-in gates table, survivorship-safe Phase 3 metrics, and a fully pinned performance workflow. --- ...s-render-fix-and-tsjs-resilience-design.md | 1533 +++++++++-------- 1 file changed, 784 insertions(+), 749 deletions(-) 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 b02247a72..0251485b9 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 @@ -1,57 +1,49 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 5 — rewritten for the coordinated hard-cutover policy - adopted in the fourth review round, and made fully self-contained (no - contract is defined by reference to an earlier revision). +- **Status:** revision 6 — baseline advanced to the APS PUC/collapsed-shell + fix, and reworked after the fifth review round. - **Date:** 2026-08-04 -- **Baseline:** `rc/july` @ `541298695` — the full merged state. -- **Inputs:** three code audits against this baseline; design reviews of - revisions 1–4; open issues #926, #941, #944, #962, #964, #977, #983, #989, - #993; open PR #997. +- **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed + GAM shells") — the full merged state. All file:line citations refer to this + commit. +- **Inputs:** three code audits; design reviews of revisions 1–5; open issues + #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997. ## 0. Release policy: coordinated hard cutover -This design targets a **single coordinated release**. Explicitly: - -- Server, TSJS bundles, config format, and page HTML ship together as one - release with one **release id** (`release_id`: the git tag / build hash). -- **No N/N−1 support.** Old pages, old bundles, old config blobs, old - globals, and old URLs may stop working at cutover. In-flight clients (pages - loaded before the switch) may fail; this is accepted and stated, not - mitigated. -- **Exact release matching only.** The kernel, every service, every plugin, - and the install manifest carry the same `release_id`; any mismatch is a - refusal, never a negotiation. There are no version ranges. -- **Config format version:** the config blob gains a top-level - `format_version`; the binary rejects any other value. No - omit-default-for-rollback serialization rules; rollback means redeploying - the previous release with its own config. -- **Assets:** binaries embed only their own release's artifacts. Hashed - pathnames are kept **for cache identity only** (correct caching and - dedup), not for retention: an unknown hash answers `410 Gone`, - `Cache-Control: no-store`. No shared artifact store — no separate - requirement for one was established. -- **Cutover runbook (normative):** (1) deploy the release to all instances - dark (flagged off); (2) verify instance health and config - `format_version`; (3) switch traffic atomically at the routing/CDN layer; - (4) purge the CDN of prior HTML and assets; (5) monitor the Phase gates - (§8); rollback = switch traffic back to the previous release and re-purge. - -Everything that revisions 3–4 built for mixed-version tolerance is removed: -no ABI version ranges, no retained-artifact storage, no legacy query-hash -sunset, no dual-name global window, no adoption gates. +This design targets a **single coordinated release**: + +- Server, TSJS bundles, config format, and page HTML ship together under one + **`release_id`** (git tag / build hash). **No N/N−1 support**: old pages, + bundles, config blobs, globals, and URLs may stop working at cutover; + in-flight clients may fail. Accepted and stated, not mitigated. +- **Exact release matching only** — kernel, services, plugins, and the + install manifest carry the same `release_id`; mismatch is a refusal. +- **Config:** top-level `format_version`, exact match required. Rollback = + redeploy the previous release with its own config. +- **Assets:** binaries embed only their release's artifacts; hashed pathnames + exist for cache identity only; unknown hash → `410 Gone`, `no-store`. +- **One executable rollout state machine** (resolving the §0/§8 tension the + review found): a release ships with a **deployment manifest** enumerating + the complete flag set; the new pool comes up **fully enabled but + unreachable except by probes**; phase gates (§8) run against probe traffic + and a **router-weight canary of coherent routed requests** (a request is + served end-to-end by one pool — HTML, assets, and APIs never mix pools); + **router weight is the sole activation primitive**; cutover = weight to + 100% + CDN purge; rollback = weight back + re-purge. Flags exist for + emergency kill switches inside a pool, not as the activation mechanism. ## 1. Problem statement APS demand is fully integrated server-side — the edge runs the APS OpenRTB auction, wins bids, and ships a typed renderer descriptor to the page — yet -APS creatives do not appear for real users. Serial single-cause fixes (the -`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback) each -survived review and still did not produce ads. That pattern is the finding: -the APS pipeline has **multiple independent failure points, most of which -fail silently**, and the client cannot tell the server which one fired. +APS creatives do not appear reliably for real users. Four serial fixes (the +`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback, and +now the baseline's PUC/collapsed-shell fix) each survived review; the pattern +is the finding: the pipeline has **multiple independent failure points, most +of which fail silently**, and the client cannot tell the server which fired. -The TSJS library (56 files, ~11,900 lines, two ~1,700-line monoliths, +The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths, duplicated ES5/TS logic, inverted layering, ~100 error-swallowing `catch` blocks) is the same problem structurally. This design fixes APS delivery and rebuilds TSJS so the next integration cannot reproduce this failure class. @@ -61,8 +53,7 @@ rebuilds TSJS so the next integration cannot reproduce this failure class. - No change to the APS OpenRTB endpoint contract or Amazon-side configuration (including its deliberate absence of `nurl`/`burl`, §G4d). - No rewrite of the decoupled Prebid.js strategy. -- **No backward compatibility** (§0). Publisher-visible surfaces change at - cutover; the replacement shapes are in §7.4. +- **No backward compatibility** (§0); replacement surfaces are in §7.4. ## 2. Why APS does not render — evidence @@ -76,28 +67,28 @@ descriptor without GAM. | # | Failure | Where | | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | A1 | A configured `[auction].mediator` discards every direct-provider bid; winners come only from the mediator response. APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` | -| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:141-143`, `:773-778` | -| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:657-668`, `:745-778`, `:838-846` | +| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` | +| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` | | A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and the `ts-debug` allowlist exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | ### 2.2 Identity | # | Failure | Where | | --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` | +| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` | | B2 | Two id universes: SSAT keys on the APS bid id, the client adapter on Prebid's generated `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` | ### 2.3 Render -| # | Failure | Where | -| --- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` | -| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1188-1245`, `aps/render.ts:384-404` | -| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:157-183`, `:1599-1600` | -| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` | -| C5 | A dead duplicate renderer branch holds the debug log and dedup logic; it can never execute. | `gpt/index.ts:1643-1674` | -| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | -| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1558-1632` | +| # | Failure | Where | +| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` | +| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` | +| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` | +| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` | +| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated; the served-through-APS-renderer log is reachable. | `gpt/index.ts:1729` | +| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | +| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` | ### 2.4 Observability @@ -106,458 +97,538 @@ time; a bid that never painted is byte-identical to one that painted. ### 2.5 Failure → signal mapping (normative) -Every section-2 failure maps to a distinct observable **failure class** (not -a claim to distinguish unknowable root causes). The operator query for each -row is the §5.6 canonical join filtered by that row's event/reason or -counter; the console column is what diagnostic mode mirrors on-page: - -| Failure | Client event/reason (§5.1) | Server counter/row (§5.6) | Console | -| ------- | -------------------------------------------- | -------------------------------------- | ------------ | -| A1 | — | selection report `mediator_superseded` | startup warn | -| A2 | — | `bid_drop{script_rendering_disabled}` | startup warn | -| A3 | — | `bid_drop{invalid_dimensions, w, h}` | warn | -| A4 | — (fixed by §5.6 itself) | `bid_drop` rows exist on all paths | `ts-debug` | -| B1/B2 | `bridge_request{matched: false}` | join via `trace_id` | warn | -| C1 | `gam_empty` then no `bridge_request` | join via `trace_id` | warn | -| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | warn | -| C3 | `render_fail{bridge_id_mismatch}` | join via `trace_id` | warn | -| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | warn | -| C5 | — (branch deleted) | — | — | -| C6 | `runner_failed` + CSP report buckets | CSP aggregate counters | warn | -| C7 | renderer branch emits the full §5.1 sequence | join via `trace_id` | debug/warn | - -## 3. The GPT reality this design must respect +Each failure maps to a distinct observable **failure class** (not a claim to +distinguish unknowable root causes). The operator query for each row is the +§5.6 canonical view filtered by that row's event/reason or counter. So that +**diagnostic mode really can name every class from one page load** — the +review's objection that A1–A4 are server-side-only — the tester gate also +delivers a **`tsjs.boot.debug` envelope** in the initial HTML (selection +summary + drop summary for the initial auction) and the equivalent gated +`ext.trusted_server.debug` field on page-bids and `/auction` responses; +diagnostic mode mirrors these to the console: + +| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | +| ------- | -------------------------------------------- | ------------------------------------- | ------------------------------- | +| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | +| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | +| A3 | — | `bid_drop{invalid_dimensions, w, h}` | `boot.debug` drop summary | +| A4 | — (fixed by §5.6 itself) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | +| B1/B2 | `bridge_request{matched: false}` | join via trace | console warn | +| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | +| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | console warn | +| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn | +| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn | +| C5 | — (fixed at baseline) | — | — | +| C6 | `runner_failed` + CSP buckets | CSP aggregate counters | console warn | +| C7 | renderer branch emits the full §5.1 sequence | join via trace | debug/warn | + +## 3. The GPT and baseline reality this design must respect 1. Bootstrap-first hybrid: server-injected ES5 `gpt_bootstrap.js` wins the sentinel race; the bundle's handoff/initial-load code is dead in production. 2. The #922 merge loss: orphan recovery and `updateRender` are gone - (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead writes; - bridge impressions double-count. PR #997 is the apparent replacement. + (`0dc9b19a9`); bridge impressions double-count. PR #997 is the apparent + replacement. 3. TS refreshes never pass `changeCorrelator: false`. -4. `enableSingleRequest()` is called blind after publisher `enableServices()`. -5. Responsive resolution ambiguity silently skips slots. -6. Three independent `pubads().refresh` wrappers coordinate via window-global - booleans. +4. `enableSingleRequest()` is called blind after publisher + `enableServices()`. +5. Responsive-resolution ambiguity silently skips slots. +6. Three independent `pubads().refresh` wrappers coordinate via + window-global booleans. 7. **GPT has no request cancellation, no documented per-refresh identity, no overlapping-completion order.** `slotRenderEnded` means creative code was - injected, not that resources loaded. The April 2025 `responseIdentifier` - identifies the ad **response** — usable for response dedup/drain, not for - attributing which caller initiated a request. + injected, not that resources loaded. `responseIdentifier` identifies the + ad **response** — usable for response dedup/drain, never initiation + attribution. 8. **With initial load disabled, `display()` creates no request** — the - subsequent `refresh()` does (`gpt/index.ts:1059`, `ad_init.test.ts:1201`). - Any cycle protocol must model physical requests, not API calls. + subsequent `refresh()` does (`gpt/index.ts:1175`, + `ad_init.test.ts:1201-1263`). Cycle protocols must model physical + requests. 9. The bundle's `slotRenderEnded` registration is gated behind - `!ts.servicesEnabled` (`gpt/index.ts:1017`); G4a needs unconditional early - subscription. + `!ts.servicesEnabled` (`gpt/index.ts:1091`); G4a needs unconditional + early subscription. +10. **The baseline includes the fourth serial APS fix** (`248fe9558`): (a) + the renderer handshake moved to a **MessageChannel** on the PUC path — + port-transferred nonce message, descriptor over the port (no wildcard + broadcast on that path), exact-key replies, `ports.length` checks, a + one-shot `accepted` latch, port closing (`aps.rs:65-125`, + `aps/render.ts:415-437`) — but the reply still terminates inside the PUC + creative frame, so G4b's kernel-observability gap remains; (b) a + nonempty GAM render can be a **collapsed 1×1 shell**, remediated by + `resizeCollapsedCreativeFrame` (`gpt/index.ts:217`) — a guarded style + mutation of GAM-owned elements; (c) the dead renderer branch was + consolidated (C5); (d) a real-PUC-topology browser test now exists. +11. The bridge already keeps **consumed-id tombstones** for security + (`gpt/index.ts:1527`) — G2's registry rules must preserve that property + across navigations. +12. The current tester cookie is **explicitly not a security control** + (`tester_cookie.rs:3`) — it cannot gate unsampled telemetry (§5.3). ## 4. Design gates ### G1 — Trace identity and correlation -The client-visible auction id is EC-derived (`publisher.rs:3237`) and is -never ingested. Initial-HTML auction telemetry is emitted before page JS -exists (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by +The client-visible auction id is EC-derived (`publisher.rs:3237`) and never +ingested. Initial-HTML auction telemetry is emitted before page JS exists +(`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by whoever acts first: - **Initial navigation (`nav_gen 0`):** the server mints `trace_id` (128-bit - CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows - (§5.6 schema), and injects it into `tsjs.boot` with a **signed trace - authorization** (§5.3). It is never `AuctionRequest.id`. -- **Cache privacy invariant:** traces/authorizations are injected only into + CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows, + and injects it into `tsjs.boot` with the signed authorization (§5.3). +- **Cache-privacy invariant:** traces/authorizations are injected only into responses that ran a per-request auction; such HTML is - `Cache-Control: private, no-store` with no validators. Enforced by - construction and by test. + `Cache-Control: private, no-store`, no validators. By construction + test. - **SPA navigations:** `/_ts/page-bids` stays GET; the client mints - `trace_id` and sends it in a validated `X-TSJS-Trace-Id` header (the - request already carries a non-simple TSJS header). The server records it in - that auction's rows; the JSON response echoes the accepted trace and - returns its signed authorization. + `trace_id`, sends it in `X-TSJS-Trace-Id`; the server records it and the + response echoes the trace + its authorization. +- **Direct `/auction` (closing the G4f gap):** the client sends the same + `X-TSJS-Trace-Id` header on the POST (`core/auction.ts:190` gains it); the + server validates, stamps that auction's rows, and echoes + `ext.trusted_server.trace = {trace_id, auth}` in the OpenRTB response — + covering pages whose initial HTML ran no auction. - **Envelope:** every event carries `{nav_gen, refresh_gen, seq}` inside a - per-trace group `{trace_id, auth, events[]}` (§5.1). `seq` is per-trace - monotonic. Transport is best-effort: gaps (loss) and duplicates - (fetch/pagehide races) are both expected; §5.5 defines dedup. -- **Sampling is server-decided for every trace** — initial via boot, SPA via - the page-bids response — and carried **inside the signed authorization** - (§5.3 `mode`). The client never asserts its own sampling; an unsigned or - missing authorization means the trace group is rejected at ingest. + per-trace group `{trace_id, auth, events[]}`. `seq` is per-trace + monotonic. Gaps (loss) and duplicates (fetch/pagehide races) are expected; + §5.5 dedups. +- **Sampling is server-decided for every trace** and carried inside the + signed authorization (§5.3). Traces are navigation-scoped; **impression / + attempt counts are keyed by `(trace_id, nav_gen, refresh_gen, slot)`** + (success criterion 5 uses this key, not "per-impression traces"). ### G2 — Render identity -- Cache-backed bids: `hb_adid` = the PBS Cache UUID, byte-for-byte as today - (`publisher.rs:3355`; the PUC fetches `?uuid=`, - `publisher.rs:3450`, `gpt/index.ts:1700`). Markup bids without cache ids: - today's fallback chain unchanged. -- **Renderer-only bids: `hb_adid` = a server-minted render token**, - `^[a-z0-9]{12}$` exactly, CSPRNG, collision-retried within the minting - auction. Cross-auction uniqueness is probabilistic (36¹² ≈ 4.7×10¹⁸; - birthday-bound negligible at realistic volumes) and made harmless by - registry scoping. -- **Registry scope and bounds:** the client bridge registry keys tokens by - `(trace_id, nav_gen, refresh_gen)` — refresh auctions within one - navigation cannot collide, closing the revision-4 gap. Capacity: 64 live - entries per navigation; at capacity a new registration is refused with - disposition `registry_full`; unexpired entries are evicted only by - navigation disposal. Token TTL 15 minutes; one-time consumption. -- The client-Prebid path keeps Prebid's generated `adId`; both paths register - into the one registry keyed by whichever id that path observes. -- Regression tests: non-APS cache-backed bids byte-identical. - -### G3 — Runtime ABI under the IIFE build (exact-release model) +- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte + (`publisher.rs:3355`; PUC fetches `?uuid=`, `gpt/index.ts:1772`). Markup + bids: today's fallback chain. +- **Renderer-only bids:** `hb_adid` = server-minted token `^[a-z0-9]{12}$`, + CSPRNG, collision-retried within the minting auction; cross-auction + uniqueness probabilistic (36¹²; negligible) and harmless via scoping. +- **Registry:** keyed `(trace_id, nav_gen, refresh_gen)`; capacity 64 live + entries per navigation (`registry_full` on refusal); TTL 15 min; one-time + consumption. **Navigation disposal does not erase security state** + (review's tombstone finding): consumed, stale, and disposed ids move into + a bounded **RuntimeSession tombstone set** (cap 256, FIFO, entries retained + until their original TTL) — the bridge's TS-reserved check consults live + registry **and** tombstones, so a late prior-navigation request is still + suppressed and refused, never released to native Prebid. This preserves + the baseline's existing consumed-id tombstone behavior + (`gpt/index.ts:1527`). +- The client-Prebid path keeps Prebid's `adId`; both paths share the one + registry. Non-APS cache-path regression tests. + +### G3 — Runtime ABI under the IIFE build (exact-release) IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`) means imports never share state across bundles (live defect: `core/context.ts:11` vs `permutive/index.ts:102`). -- The kernel ships only in `tsjs-core`, publishes - `tsjs._internal = { release_id, registry }` once (window sentinel), and - **freezes** `_internal` after boot. The kernel constructs and registers - core services (event bus, beacon queue, sessions, slot registry, render - state machine) during boot; integrations register integration-scoped - services during `install()`. -- **Exact release matching:** every service and plugin registration carries - `release_id`; `registry.get(name)` succeeds only when the registrant's - `release_id` equals the kernel's. A mismatch quarantines the registration - and emits `abi_mismatch` (service) / `bundle_partial` (plugin) with a - console error. No ranges, no minors, no first-wins tiers — under §0 a - mismatch is a deployment error to surface, not tolerate. -- Stateful access only through the registry at call time; stateless helpers - may be imported and inlined. Single-module-graph builds remain the - recorded successor option behind the same surface. +- Kernel ships only in `tsjs-core`; publishes + `tsjs._internal = { release_id, registry }` once; freezes after boot; + constructs and registers core services during boot. +- **Exact release matching:** every registration carries `release_id` + (plugins via the object-form API, §7.6, whose `release` field is a + build-generated constant); `registry.get(name)` succeeds only on equality; + mismatch quarantines with `abi_mismatch` / `bundle_partial` and a console + error. +- Stateful access only via the registry at call time; stateless helpers may + inline. Boundary enforcement is **two lint rules**: `import/no-restricted- +paths` for layering **and** `no-restricted-globals` forbidding + `window.googletag` / `window.pbjs` outside `adapters/` (import paths alone + cannot enforce §7.1). ### G4 — Render lifecycle -**G4a — Physical request-cycle protocol.** Two separated notions: - -- **Intent:** a TS `display()`/`refresh()` call (or an observed publisher - entry) targeting a slot. Intents are classified `ts | publisher` at the - wrapped entry points. An intent may produce zero physical requests - (initial-load-disabled `display()`; `refresh()` on a never-displayed - adopted slot); an intent that produces no `slotRequested` within its bound - (2 s) expires with disposition `intent_no_request` — diagnostics only. -- **Cycle (outstanding physical request):** opened **only by - `slotRequested`**, matched to the oldest unexpired TS intent for that slot, - else classified publisher-initiated. SRA batching yields one `slotRequested` - per slot per batch — one cycle each, all matched to the intents of the - batch call. A cycle closes on its `slotRenderEnded` (matched by slot; GPT's - `responseIdentifier`, where present, deduplicates responses during drain — - it never attributes initiation). -- **Serialization:** TS keeps at most one outstanding TS cycle per slot. A TS - intent arriving while a TS cycle is outstanding **queues** (bounded: 1 - queued replacement; further intents coalesce into it). -- **Attribution:** a `slotRenderEnded` is attributable iff exactly one - TS cycle is outstanding for the slot and no publisher-initiated or - untracked request overlaps it. Any overlap → the slot enters - **quarantine**: `cycle_unattributable`, fail closed (no fallback, no state - transition), and the drain rule applies. -- **Drain/re-arm (supersession and SPA):** physical cycle state lives in the - **RuntimeSession-owned slot record**, not the NavigationSession — adopted - slots outlive navigations. On navigation or supersession, outstanding - cycles are marked stale; their late events are **matched and discarded** - with disposition `stale_navigation` (never misattributed); a quarantined or - stale slot re-arms only after every outstanding request/render pair has - drained (or its 60 s drain bound elapses, which keeps the slot - fallback-ineligible for that navigation). Queued TS intents dispatch only - after re-arm. A timeout never makes an old event disappear — drain-by-match - does. -- CI exercises the protocol on the deterministic harness; a **release-gating - real-GAM overlap test** (publisher refresh racing a TS cycle; - initial-load-disabled cycle formation) validates it against actual GPT. - -**G4b — Acknowledgement protocol.** The renderer document currently posts -"ready" only to its immediate parent (`aps.rs:105`); in the PUC path the -top-level kernel cannot observe it, and callbacks fire on send -(`gpt/index.ts:1572`, `:1620`). Contract: the bridge response embeds a -**per-attempt 128-bit CSPRNG acknowledgement nonce**; the renderer document -posts versioned `{t: "render_accepted" | "render_failed", nonce, reason?}` -to the top window; the kernel validates, in order: source ownership (§6.8 -walk), nonce equality, token binding, `nav_gen`, `refresh_gen` — all five — -before any state transition or notification. Pinned by tests for SSAT, -client-Prebid, and nested SafeFrame flows, including stale and replayed -acks. +**G4a — Physical request-cycle protocol.** + +- **Intents, both classes, one causal queue.** Every observable initiation — + TS `display()`/`refresh()` and wrapped publisher entries — records an + intent in causal order, classified `ts | publisher`. A TS `display()` + issued while initial load is disabled is **known at call time to produce + no request** and is retired immediately as bookkeeping (it never enters + the matcher) — closing the review's misattribution case where a publisher + `refresh()` inside the 2 s window would have been consumed by a stale TS + intent. TS intents that _may_ produce no request only in hindsight + (`refresh()` on a never-displayed adopted slot) expire at 2 s with + `intent_no_request`; **if any publisher intent is recorded for the slot + while such a TS intent is pending, the next `slotRequested` is ambiguous + and the slot quarantines** — a zero-request TS intent can never silently + win FIFO matching. (Exact test in §9.) +- **Cycles:** opened only by `slotRequested`, matched to the head of the + causal intent queue; SRA batching yields one `slotRequested` per slot per + batch. A cycle closes on its `slotRenderEnded`; `responseIdentifier` + deduplicates responses during drain. +- **Serialization:** at most one outstanding TS cycle per slot; one queued + TS replacement (later intents coalesce). +- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS + cycle is outstanding and no publisher/untracked request overlaps. + Overlap → quarantine (`cycle_unattributable`, fail closed). +- **Drain/re-arm (no timeout re-arm).** Physical cycle and drain state live + in the RuntimeSession slot record; **unissued intents are + NavigationSession children** and are cancelled by navigation disposal. A + quarantined or stale slot re-arms only on: count-based drain (every + outstanding request/render pair matched), safe TS-owned slot destruction + and redefinition, or page end. **A timeout emits a diagnostic and never + restores attribution** — the 60 s bound from revision 5 is removed + because an old `slotRenderEnded` arriving after re-arm would be + indistinguishable from a new cycle. Late stale events are matched and + discarded (`stale_navigation`). +- CI exercises the protocol on the deterministic harness; a release-gating + **real-GAM overlap test** (publisher refresh racing a TS cycle; + initial-load-disabled formation) validates it against actual GPT. + +**G4b — Acknowledgement protocol (on the baseline port transport).** Since +`248fe9558` the frame pair speaks MessageChannel (parent-postMessage with +`ports.length === 0`, or transferred port with `ports.length === 1`; +exact-key replies; one-shot `accepted` latch; port closed after reply — +`aps.rs:65-125`, `aps/render.ts:415-437`). Adopted as the contract of record +within the frame pair. The kernel-observability gap remains (the PUC-flow +reply resolves inside the creative frame; callbacks fire on send, +`gpt/index.ts:1632-1760`). Contract — **three authenticated messages per +attempt**, each carrying the per-attempt 128-bit CSPRNG nonce from the +bridge response: + +1. `renderer_document_loaded` — posted to the top window after the document + validates the descriptor and nonce (this is §6.6's first stage, which + revision 5's two-message protocol omitted); +2. the port reply to its frame-pair peer (baseline behavior, unchanged); +3. `render_accepted` / `render_failed{reason}` — posted to the top window. + +The kernel validates, in order: source ownership (§6.8 walk), nonce, token, +`nav_gen`, `refresh_gen` — before any state transition or notification. The +one-shot latch + port close mean a re-render is a fresh document instance +with a fresh nonce. Pinned for SSAT, client-Prebid, and nested SafeFrame, +including stale/replayed acks and acks after navigation disposal. **G4c — Honest observations.** Inline-adm frames are sandboxed `srcdoc` -without `allow-same-origin` (`gpt/index.ts:358`) — opaque origins; geometry -proves nothing. Observations: `gam_nonempty`, `gam_empty`, -`renderer_document_loaded`, `runner_loaded`, `runner_failed`, -`adm_document_loaded`. Every path terminates at `render_accepted` -(authenticated per G4b where the renderer protocol exists; -`adm_document_loaded` stands in for adm frames). **No observation claims -paint**; there is no `render_confirmed`. A future trusted completion ack -(OQ6) may add a new state under a new name. - -**G4d — Win/billing notifications.** APS intentionally carries neither -`nurl` nor `burl` (`aps.rs:812`; the minimized AAX envelope excludes them; -the integration guide documents no generic APS beacons). APS billing lives in -the Amazon runner lifecycle; unchanged. - -For carrying paths (PBS and other OpenRTB providers), **bind is defined per -flow and is never selection or targeting** (targeting-only firing is -explicitly prevented today, `ad_init.test.ts:1824`): - -- PUC/GAM flow: bind = an owned, slot-and-ad-id-matched bridge claim. -- Direct `/auction` flow: bind = validated render start (slot resolved, - descriptor/markup validated, attempt created). -- Fallback flow: bind = attributed `gam_empty`, immediately before the - fallback render starts. - -`nurl` fires at bind; `burl` at `render_accepted`; both attempt-scoped -(idempotency key `(trace_id, nav_gen, slot, refresh_gen, hb_adid)`), fired at -most once, via `sendBeacon`/`no-cors fetch`, no retries. Terminal failure -after acceptance → `billed_then_failed` label; no un-firing. +without `allow-same-origin` (`gpt/index.ts:510`) — opaque; geometry proves +nothing (the shell dimensions are assigned by our own code). Observations: +`gam_nonempty`, `gam_empty`, **`gam_collapsed`** (nonempty render whose +shell computes ≤ 1px — the baseline's discovery), `renderer_document_loaded`, +`runner_loaded`, `runner_failed`, `adm_document_loaded`. Every path +terminates at `render_accepted`; **no observation claims paint**; there is +no `render_confirmed`. The baseline's `resizeCollapsedCreativeFrame` +(`gpt/index.ts:217`) is adopted as a **sanctioned, guarded exception** to +the no-foreign-DOM-mutation rule (authenticated source frame only; wrapper +only when both dimensions ≤ 1px; anchor-ad `ins[data-anchor-status]` and +fixed/sticky guards) and emits `gam_collapsed` when it acts. + +**G4d — Win/billing notifications.** APS carries neither `nurl` nor `burl` +by design (`aps.rs:839`; the AAX envelope excludes them; the integration +guide documents no generic APS beacons) — APS billing lives in the Amazon +runner lifecycle, unchanged, and **APS is excluded from everything below**. + +For carrying paths (PBS and other OpenRTB providers): bind is per flow and +never selection or targeting (`ad_init.test.ts:1824` pins that): + +- GAM/PUC: an owned, slot-and-ad-id-matched bridge claim. +- Direct `/auction`: validated render start. **This requires server and + client work the current code lacks**: `/auction` response conversion must + preserve `nurl`/`burl` with server-side macro expansion + (`formats.rs:423` omits them today) and the client parser must carry and + https-validate them (`core/auction.ts:43` drops them today). +- Fallback: attributed `gam_empty`, immediately before fallback render. + +`nurl` at bind; `burl` at `render_accepted`; attempt-scoped idempotency key +`(trace_id, nav_gen, slot, refresh_gen, hb_adid)`; `sendBeacon`/no-cors +fetch; no retries. Post-acceptance terminal failure emits the dedicated +**`billing_outcome{billed_then_failed}`** event (§5.1) — it is not a +`render_fail` reason. **G4e — Fallback trigger.** Opt-in -(`[auction].client_render_fallback = "renderer"`). Renders only after a -terminal `gam_empty` **unambiguously attributed to a TS cycle** (G4a) — -ownership does not gate it (adopted slots are the common path and are -eligible); publisher-initiated or unattributable cycles never trigger it; -timeouts never render. The direct renderer is converted to an awaitable API -with cancellation and terminal reasons before the fallback lands. +(`[auction].client_render_fallback = "renderer"`); renders only after a +terminal `gam_empty` unambiguously attributed to a TS cycle; ownership does +not gate it; publisher-initiated or unattributable cycles never trigger it; +timeouts never render. **G4f — Direct `/auction` lifecycle.** The non-GPT path -(`core/request.ts:52`) gets the same discipline: a `RenderAttempt` keyed -`(trace_id, nav_gen, refresh_gen, slot)` where `refresh_gen` increments per -`requestAds` invocation for the same slot within a navigation; exactly-once -terminal state; G4b acknowledgement validation; G4d direct-flow bind; -cancellation and disposal on navigation; the same §5.1 event sequence. "Every -configured flow" in the success criteria includes this one. - -### G5 — Deployment contracts (hard cutover) - -- **Config:** top-level `format_version`; exact match required; mismatch is a - startup error. No default-omission rollback rules. -- **Assets:** hash-in-pathname (`/static/tsjs//.js`) for cache - identity; binaries serve only embedded current-release artifacts; unknown - hash → `410 Gone`, `no-store`; `Cache-Control: immutable` on exact matches. - Concatenations precomputed per **ordered module-ID vector** at build time. - The cutover runbook (§0) owns HTML/asset consistency; the CDN purge step is - what retires old references. -- **Internal route isolation (all adapters):** the renderer, client-events, - and CSP-report route families (a) dispatch **before** auth, EC setup, and - publisher/integration filters (today the renderer can traverse EC setup and - pre-route filters, `app.rs:709` in the Fastly adapter); (b) reserve **all - methods and all version prefixes** locally — unsupported method → - deterministic `405` with `Allow` and `no-store`; unknown version → `404` - `no-store`; never the publisher fall-through some adapters use today - (`adapter-spin app.rs:804`); (c) never forward bodies, cookies, or - authorization headers to publisher origins; (d) compare origins as - normalized scheme + host + port, not host-only. -- **Ingest routing:** client-events in all four adapters; Fastly has the real - sink; others accept-count-drop by contract (OQ5). -- **Storage:** §5.6 schemas deploy and validate **before** any writer - enables. +(`core/request.ts:52`) gets: a `RenderAttempt` keyed +`(trace_id, nav_gen, refresh_gen, slot)` with `refresh_gen` incremented per +`requestAds` invocation touching the slot; **per-slot serialization with +latest-wins cancellation** — concurrent calls for the same slot cancel the +older attempt, and every DOM or beacon side effect re-checks its attempt +generation first, so a reversed-arrival response can never replace a newer +creative or start a second economic lifecycle (`request.ts:31` currently +races); G4b acknowledgement validation; G4d direct-flow binds; disposal on +navigation; exactly-once terminal state; the full §5.1 event sequence. +`tsjs.requestAds(options)` returns +`Promise` where +`RequestAdsResult = { traceId, slots: Array<{ slot, outcome: "rendered" | +"no_bid" | "failed" | "cancelled", reason? }> }`, settling when every slot +attempt reaches a terminal state. Reversed-response tests required. + +### G5 — Deployment contracts + +- Config `format_version` exact-match; rollback by redeploy. +- Assets: hash-in-pathname; embedded only; unknown hash 410 `no-store`; + `Cache-Control: public, max-age=31536000, immutable` on exact matches + (`immutable` alone carries no lifetime). **Concatenation is materialized + and cached at application-state construction from the validated + configured module vector** — not per request (`bundle.rs:23` today), and + not a build-time-only set, since enabled vectors are runtime + configuration; an unlisted vector is a startup error. +- Internal route families (renderer, client-events, CSP reports): dispatch + before auth/EC/publisher/integration filters (Fastly today runs EC setup + and pre-route filters first, `app.rs:709`); all methods and version + prefixes reserved locally (405 + `Allow` + `no-store`; unknown version + 404 `no-store`; never the publisher fall-through in `adapter-spin +app.rs:804`); no body/cookie/authorization forwarding; origins compared + as normalized scheme+host+port. +- Ingest routes exist in all four adapters; Fastly has the real sink; the + others accept-count-drop (OQ5 drives their gates). +- §5.6 schemas deploy and validate before writers enable. ## 5. Observability -### 5.1 Wire payload +### 5.1 Wire payload and per-event field matrix ``` { v: 1, traces: [ - { trace_id, auth, // auth: signed authorization (§5.3) - events: [ - { nav_gen, refresh_gen, seq, - t: "bid_received" | "targeting_set" | "bridge_request" | - "bridge_response_sent" | "render_attempt" | "render_accepted" | - "render_fail" | "gam_nonempty" | "gam_empty" | - "renderer_document_loaded" | "runner_loaded" | "runner_failed" | - "adm_document_loaded" | "fallback_start", - slot, // configured slot id if in the injected set, else "s" - id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none" - matched, // bridge_request only - source, // "renderer" | "adm" | "pbs-cache" | "gam" - reason } // render_fail only - ] } + { trace_id, auth, events: [ { nav_gen, refresh_gen, seq, t, ...fields } ] } ] } ``` -Every G4c observation is a **wire event** in this enum (internal state -transitions map to them one-to-one; nothing observable is state-only). -`gam_empty` additionally appears as a `render_fail` reason when it is the -terminal outcome of an attributed attempt. - -The `t` enum now **contains every G4c observation**, so Phase-3 stage rates -(renderer-document load rate, runner load/failure, GAM fill) are queryable. -Reason enum (closed): `renderer_document_no_load`, `runner_no_load`, -`runner_failed`, `descriptor_invalid`, `invalid_dimensions`, -`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`, -`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`, `gam_empty`, +Event types and their fields (closed enums; a field absent from a row is +absent from the wire and NULL in storage): + +| `t` | fields | +| -------------------------- | ------------------------------------ | +| `bid_received` | slot, id_kind, source | +| `targeting_set` | slot, id_kind | +| `bridge_request` | slot, id_kind, matched | +| `bridge_response_sent` | slot, source | +| `render_attempt` | slot, source | +| `render_accepted` | slot, source | +| `render_fail` | slot, source, reason | +| `gam_nonempty` | slot | +| `gam_empty` | slot | +| `gam_collapsed` | slot | +| `renderer_document_loaded` | slot | +| `runner_loaded` | slot | +| `runner_failed` | slot, reason | +| `adm_document_loaded` | slot | +| `fallback_start` | slot | +| `billing_outcome` | slot, outcome (`billed_then_failed`) | +| `client_queue_overflow` | dropped (count) | + +`slot` is a configured slot id or `s`; `id_kind` ∈ +`cache_uuid | render_token | prebid_adid | bid_id | none`; `source` ∈ +`renderer | adm | pbs-cache | gam`. Reason enum: +`renderer_document_no_load`, `runner_no_load`, `runner_failed`, +`descriptor_invalid`, `invalid_dimensions`, `dimensions_out_of_range`, +`bridge_id_mismatch`, `cycle_unattributable`, `intent_no_request`, +`stale_navigation`, `bridge_claim_timeout`, `gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`, `registry_full`. +Queue overflow is its own event (`client_queue_overflow`), never a +`render_fail` — failure denominators stay clean. The payload carries **no +client timestamp**; the server stamps `received_at`, and ordering within a +trace is `seq`. ### 5.2 Transport `fetch(..., {keepalive: true, credentials: "omit"})` primary; `pagehide` fallback `navigator.sendBeacon(url, new Blob([json], {type: -"application/json"}))`. Flush every 5 s and on `visibilitychange`/`pagehide`. -**Client queue bound:** 256 events; overflow drops oldest, increments a -counter, and the final flushed batch carries one `render_fail{...}`-class -overflow marker event so truncation is visible. Duplicates from -fetch/pagehide races are expected and handled at the sink (§5.5). +"application/json"}))`. Flush every 5 s and on `visibilitychange`/ +`pagehide`. Client queue bound 256 events; overflow drops oldest and emits +`client_queue_overflow{dropped}`. ### 5.3 Signed trace authorization -Format `v1....`: - -- `kid`: key id; **active and previous keys** live in the platform secret - store; keys are ≥ 256-bit CSPRNG values; rotation = introduce new key as - active, demote, retire. **Missing-key startup behavior:** if the beacon is - enabled and no signing key resolves at startup, startup fails loudly - (config error) — traces are never issued unsigned. -- `exp`: unix epoch seconds; verifier allows ±60 s skew; maximum future - 15 minutes from issuance. -- `mode`: `sampled` | `diagnostic`. Sampling is server-decided (G1); - diagnostic is a distinct authenticated mode gated by the tester cookie at - issuance — not an overloaded "sampling off" bit. -- `sig`: HMAC-SHA-256 over the **domain-separated, length-prefixed** input - `"ts-trace-auth-v1" || len(origin) || origin || len(trace_id) || trace_id -|| len(mode) || mode || u64(exp)`, where `origin` is the externally visible - scheme+host+port. Constant-time comparison. -- Ingest verifies per trace group; a missing key id, expired, future-dated, - or invalid signature → that **group** is dropped-and-counted (other groups - in the batch survive). An unsigned `sampled` claim does not exist in the - wire format, so it cannot be asserted. +Format `v1....` — **`auth` has its own ingest bound of +256 bytes** (it cannot fit the general 64-char string cap; every other +string keeps 64): + +- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform secret + store; keys ≥ 256-bit CSPRNG; **previous keys are retained at least + 24 hours** (≫ max token lifetime + skew); missing key at startup with the + beacon enabled = startup failure. +- `exp`: canonical decimal unix seconds (no sign, no leading zeros); ±60 s + skew; max future 15 min. +- `mode`: `sampled | unsampled | diagnostic`. **`unsampled` is the signed + discard decision** (the review's missing state): the client must not + enqueue or transmit events for an `unsampled` trace, and ingest rejects + any group whose token mode is `unsampled`; the decision is sticky for the + trace (renewals preserve mode). `diagnostic` is a distinct authenticated + mode — **not** gated by the tester cookie, which is explicitly + non-security (`tester_cookie.rs:3`); it requires a separate short-lived + **diagnostic credential** issued behind the existing operator/admin + authentication (`/_ts/admin` surface): HMAC-signed, bound to publisher + origin, expiry ≤ 60 min, revoked by key rotation, issuance + CSRF-protected; forgery/replay tests required. The tester cookie may + still gate cosmetic overlays; never telemetry volume. +- `sig`: base64url, unpadded, of HMAC-SHA-256 (43 chars) over the + domain-separated input + `"ts-trace-auth-v1" || u32be(len(origin)) || origin || +u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode || +u64be(exp)`, all strings UTF-8; constant-time comparison. +- **Renewal for long-lived pages:** before expiry the client calls + same-origin `GET /_ts/trace-auth` with `X-TSJS-Trace-Id`; the server + re-signs the **same trace id and mode** with a fresh `exp` (correlation is + the unchanged trace id). On renewal failure the client stops transmitting + and counts locally — silent rejection at ingest is thereby a bug, not a + policy. +- Ingest verifies per trace group; invalid/expired/unknown-kid → group + dropped-and-counted; other groups survive. ### 5.4 Ingest contract -- `POST /_ts/client-events`; `Content-Type: application/json` only; no - `Content-Encoding`; responds `204`, `no-store`; never echoes input. -- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars; - `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`. +- `POST /_ts/client-events`; `application/json` only; no + `Content-Encoding`; `204`, `no-store`; never echoes input. +- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars except + `auth` ≤ 256 bytes; `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`. - Same-origin: `Sec-Fetch-Site: same-origin` when present, else normalized `Origin` equality; absent both → drop-and-count. -- **Rate limiting (numeric, fail-closed for telemetry):** token bucket per - client address, **10 requests/min, burst 20**; limiter map ≤ 65,536 - entries, entry TTL 10 min, LRU eviction; limiter unavailable/errored → - drop early with `204` (count only). Trusted client address per adapter: - Fastly — platform client IP; Axum — rightmost `X-Forwarded-For` entry - beyond required `trusted_proxy_hops` (absent config → socket peer only); - Cloudflare — `CF-Connecting-IP`; Spin — platform client address. - -### 5.5 Sink and deduplication - -- Stable event key `(publisher, trace_id, seq)`; deduplication at the sink - or query layer (Tinybird: latest-write or `GROUP BY` on the key), covering - fetch/pagehide double-delivery. +- **Rate limiting via an adapter abstraction** (the review is right that a + cross-request in-memory token bucket cannot exist on Fastly, `app.rs:146`): + trait `ClientEventLimiter` with a declared per-adapter backing and + semantics — Fastly: the platform edge counter (`rate_limiter.rs:40`), + fixed 60 s window, limit 20/window (documented approximation of + 10 rpm + burst 20); Axum: real in-process token bucket (10 rpm, burst + 20), map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/per-instance + best-effort with the same parameters. **At capacity, unseen identities + are rejected (drop-and-count); active buckets are never evicted by + churn.** Limiter unavailable/errored → drop early with `204`. Trusted + client address per adapter: Fastly platform client IP; Axum rightmost + `X-Forwarded-For` beyond required `trusted_proxy_hops` (absent → socket + peer only); Cloudflare `CF-Connecting-IP`; Spin platform address. + +### 5.5 Sink, canonical views, and monitoring + +- Stable event key `(publisher_domain, trace_id, seq)`. +- **One named canonical dedup pipe/view per table** (`ts_client_events_v`: + latest `received_at` per key; `ts_render_attempts_v`: attempt-grain + aggregation keyed `(trace_id, nav_gen, refresh_gen, slot)`). **Joins run + at attempt/slot grain against the views, never raw-to-raw** (a raw join on + `(publisher_domain, trace_id)` multiplies rows). Dashboards and alerts + may query only canonical views. +- Field naming matches the existing auction rows: **`publisher_domain`**. - The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) and - cannot observe downstream schema/auth rejection — therefore - **datasource-side freshness monitoring is mandatory** (§8 gates alarm on - ingestion lag and row-rejection metrics from the datasource side). + cannot see downstream rejection — **datasource-side monitoring is + mandatory**, driven by **sequence-tagged synthetic heartbeats** from a + probe client (accepted rows cannot reveal rejected rows): heartbeat gaps + measure rejection; heartbeat lag measures freshness. Alert owner: the + release owner's on-call. ### 5.6 Physical schemas (deployed before writers) -- **New datasource `ts_client_events`** (flattened rows): - `ts (DateTime64), publisher (LowCardinality String), release_id (String), -trace_id (FixedString 32), mode (Enum sampled|diagnostic), nav_gen UInt32, -refresh_gen UInt32, seq UInt32, event (Enum §5.1), slot (String ≤64), -id_kind (Enum), matched (UInt8), source (Enum), reason (Enum §5.1)`. - Sorting key `(publisher, ts, trace_id, seq)`; 30-day TTL; its own ingest - token, configured via new `TinybirdSettings` fields - (`client_events_dataset`, `client_events_token_secret`) — today's settings - configure only the auction dataset (`settings.rs:1752`). Sink batch cap: - 512 rows per dispatch (matching the auction sink). Startup validation: - when client events are enabled, the dataset name and token secret must - resolve or startup fails. Sink-unavailable behavior at runtime: - accept-count-drop (ingest still answers `204`). Canonical join: - `ts_client_events` ⋈ auction rows on `(publisher, trace_id)`; dashboards - and alerts are owned by the release owner and defined with the datasource. -- **Auction rows** (`AuctionEventRow`, `telemetry.rs:262`, and - `auction_events_raw.datasource`): add nullable `trace_id (FixedString 32)` - and `mode`; add a bounded **`bid_drop` row type** - `{provider, slot, reason (Enum), width UInt16?, height UInt16?, count -UInt32}` with per-auction row cap 32 and an `overflow` bucket row. -- APS parsing returns a **structured drop observation** - `{reason, slot, width?, height?}` instead of a bare reason string - (`aps.rs:722`); dimensions above 8192 use `dimensions_out_of_range` with - dimensions omitted, never clamped. - -### 5.7 Modes and SLOs - -- **Production (sink-backed only):** server-decided 10% sampling. - Two separated objectives: **pipeline availability** — ingestion freshness - ≤ 5 min and datasource rejection rate < 0.1%, alarmed on breach (fails - during sink outages, by design); **failure detection** — a failure mode - affecting ≥ 1% of sampled render attempts is visible within one hour, - evaluated only at ≥ 10,000 sampled render attempts/hour. -- **Diagnostic:** authenticated mode (§5.3), unsampled, full stream + - console mirroring; one page load names the failing class. +- **`ts_client_events`**: `received_at DateTime64, publisher_domain +LowCardinality(String), release_id String, trace_id FixedString(32), +mode Enum(sampled|diagnostic), nav_gen UInt32, refresh_gen UInt32, +seq UInt32, event Enum(§5.1), slot Nullable(String), id_kind +Nullable(Enum), matched Nullable(UInt8), source Nullable(Enum), reason +Nullable(Enum), outcome Nullable(Enum), dropped Nullable(UInt32)`. + Sorting key `(publisher_domain, received_at, trace_id, seq)`; TTL + 30 days; own ingest token; sink batch cap 512 rows; startup validation of + dataset + token when enabled ("startup" on request-bound platforms such + as Cloudflare means first-request lazy initialization with a cached + result); sink-unavailable at runtime → accept-count-drop. +- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`): + add nullable `trace_id`, `mode`; add two bounded row types — **`bid_drop`** + `{provider, slot Nullable, reason Enum, width Nullable(UInt16), height +Nullable(UInt16), count UInt32}` (nullable slot/dimensions for + response-level failures; cap 32 rows/auction + overflow row) and + **`selection_summary`** per slot + `{slot, winner_source Enum(mediator|direct|none), winner_provider, +candidates_direct UInt16, candidates_mediator UInt16, dedup_hits, +currency_rejected, provenance_invalid, mediator_superseded}` (cap 8 + rows/auction + overflow) — §6.1's selection report now has a physical + home. +- **Settings schema (complete):** `[telemetry.client_events]` `enabled`, + `sample_rate` (0–1), `dataset`, `token_secret`; `[telemetry.trace_auth]` + `secret_store`, `active_kid`, `previous_kids = []`; the diagnostic + credential secret alongside. `RuntimeServices` (`platform/types.rs:158`) + gains the client-events sink handle next to the auction sink. +- APS parsing returns structured drop observations + `{reason, slot, width?, height?}` (`aps.rs:722` today loses slot and + values); >8192 → `dimensions_out_of_range`, dimensions omitted. + +### 5.7 Modes and SLIs + +- **Production (sink-backed only):** server-decided sampling + (`sample_rate`, default 0.10; `sampled` vs `unsampled` signed per trace). + Separated SLIs: **pipeline availability** (heartbeat freshness ≤ 5 min, + heartbeat loss < 0.1%; fails during sink outages, alarmed); **failure + detection** (a failure mode affecting ≥ 1% of sampled render attempts + visible within one hour, evaluated at ≥ 10,000 sampled attempts/hour). +- **Diagnostic:** credential-gated (§5.3), unsampled, full stream, console + mirroring, plus the `boot.debug` / response-`debug` envelopes (§2.5) — one + page load names the failing class for every §2 row. ### 5.8 Server-side drop surfacing -Bounded structured summary whenever any bid is dropped; `bid_drop` rows -(§5.6); drop summary in the initial-HTML `ts-debug` comment; page-bids gains -a tester-gated structured `debug` field. Startup warnings: APS + -`allow_script_creatives = false`; mediator + direct providers without an -explicit `winner_selection` (§6.1 makes that a hard error). +Bounded structured summary whenever any bid is dropped; `bid_drop` + +`selection_summary` rows; `ts-debug` comment carries the drop summary; +page-bids and `/auction` carry the gated structured `debug` field. Startup +warnings: APS + `allow_script_creatives = false`; mediator + direct +providers without an explicit `winner_selection` (§6.1 hard error). ## 6. APS delivery fixes -### 6.1 Mediation: complete inline algorithm - -Current mediation cannot support merging: it forwards no stable candidate id; -restores fields via a lossy last-write-wins `(provider, slot, bidder)` index -(`adserver_mock.rs:95`); breaks equal-price ties by response arrival order -(`orchestrator.rs:827`); and assigns parsed Prebid bids USD without -validating response currency (`prebid.rs:2318`). The algorithm below replaces -that, identically in the synchronous and split dispatch/collect paths, via -one shared candidate-selection helper. - -1. **Candidate registration.** Every direct-provider bid admitted by parsing - becomes a candidate with a **server-minted candidate id** (`c` + - 11-char CSPRNG, unique per auction). The full candidate (renderer, cache - coordinates, notification URLs, currency, provenance - `(provider, upstream_bid_id)`) is stored by candidate id. Winners are - selected **by candidate id** and their fields read from the stored - candidate — the lossy index is deleted. -2. **Currency.** The auction has one configured currency. A provider response - that declares another currency, or a path that cannot prove its currency - (the Prebid parse point must validate, not assume USD), rejects that bid - at parse with `bid_drop{currency_mismatch}`. No conversion. -3. **Mediator exchange.** Forwarded candidates carry their candidate id; the - mediator is required (wire contract, including `adserver_mock`) to echo it - on any bid derived from a forwarded candidate. A mediator bid **without** - an echoed id is mediator-native. A mediator bid with an id that does not - resolve → **the slot fails closed** for merging - (`mediation_provenance_invalid`: mediator-native bids for that slot still - compete; unresolvable forwarded claims are discarded and counted — a - warning alone is insufficient). -4. **Floors.** Slot floors filter both populations before selection. -5. **Dedup.** A mediator bid that echoes candidate id X removes direct - candidate X from the pool (it is the same demand, provenance `mediator`). -6. **Selection.** Per slot, the winner is the maximum under the **total - deterministic order**: decoded CPM desc → provenance rank (mediator - before direct) → provider name asc → candidate id asc. Response arrival - order can never matter. -7. **Strategy config.** `[auction].winner_selection` is **required whenever a - mediator and direct providers coexist** — startup error if absent (no - silent default; §0 removes the compatibility rationale for one): - `mediator_only` (mediator bids only, direct providers are signal) or - `merge_highest_cpm` (the algorithm above). A mediator timeout degrades to - direct-only selection and is reported. -8. **Reporting.** A selection report per auction: `winner_source`, - `mediator_superseded`, `currency_mismatch`, `dedup_hits`, - `mediation_provenance_invalid` — separate from delivery `bid_drop` rows. - -Deal priority remains out of scope: the `Bid` model carries no deal identity -(`types.rs:231`); a rule the model cannot express would be fiction. Recorded -as follow-up requiring a bid-model extension. +### 6.1 Mediation: complete, arrival-independent algorithm + +Current code cannot merge: no forwarded candidate id; lossy +last-write-wins `(provider, slot, bidder)` restoration +(`adserver_mock.rs:95`); arrival-order ties (`orchestrator.rs:827`); Prebid +assumes USD (`prebid.rs:2433`) and APS stamps USD (`aps.rs:475`) with no +configured currency. Replacement, identical in the synchronous and split +dispatch/collect paths via one shared helper: + +1. **Currency.** New required field `[auction].currency` (ISO 4217). Every + provider parse validates its response currency against it (absent + declaration where the provider contract implies one — APS's USD — is + validated as that implied value); mismatch → `bid_drop{currency_mismatch}`. + No conversion. +2. **Candidates.** Every admitted bid — direct **and mediator-native** — + becomes a candidate. Identity is two-part: `source_candidate_id` = + the **intrinsic stable key** `(provider_name, upstream_bid_id)` (for + mediator-native bids: the mediator's provider name and its bid id), and + `candidate_id` = a server-minted opaque wire id (`c` + 11-char CSPRNG) + used **only** for the mediator echo — never for ordering, so response + arrival order cannot influence selection. +3. **Mediator exchange.** Forwarded candidates carry `candidate_id` in the + named wire extension **`ext.trusted_server.candidate_id`** (contract for + every mediator implementation, `adserver_mock` included); the mediator + echoes it on derived bids. Echoed id resolves → the bid is the forwarded + candidate with provenance `mediator`; **authoritative fields:** price + and deal fields come from the mediator (repricing is its job); + render-source fields (renderer, adm, cache coordinates, notification + URLs) come from the stored candidate — a mediator that returns its own + `adm` for an echoed candidate is treated as mediator-native demand + instead. Unresolvable echoed id → the slot **fails closed for merging** + (`mediation_provenance_invalid`; mediator-native bids for the slot still + compete; the claim is discarded and counted). +4. **Floors** filter both populations. **Dedup:** an echoed candidate + removes its direct twin. +5. **Selection order (total, intrinsic):** decoded CPM desc → provenance + rank (mediator first) → `source_candidate_id` asc. Winner fields are + read from the stored candidate per rule 3. +6. **Strategy.** `[auction].winner_selection` is **required** whenever a + mediator and direct providers coexist (startup error if absent): + `mediator_only` or `merge_highest_cpm`. **Timeout behavior is + strategy-specific:** under `merge_highest_cpm`, mediator timeout → + direct-only selection, reported; under `mediator_only`, mediator timeout + → **no winners** (direct bids stay signal-only) unless + `mediator_timeout_fallback = "direct"` is explicitly configured. +7. **Reporting:** the `selection_summary` row (§5.6) per slot. + +Deal priority stays out of scope (the `Bid` model carries no deal identity; +recorded follow-up). ### 6.2 Dimensions -Exact size membership stays (`aps.rs:657-668`). The fix is visibility -(structured `bid_drop{invalid_dimensions, w, h}`, §5.6) plus documentation -("sizing your slots for APS"): if a size is acceptable, request it in the -slot's `formats` — accepting unrequested sizes would conceal an upstream -protocol violation. +Exact size membership stays (`aps.rs:675`). Fix is visibility +(`bid_drop{invalid_dimensions, w, h}`) plus documentation: request the +sizes you accept. ### 6.3 Script creatives -`allow_script_creatives` stays default-`false` (defensible sandbox posture); -the consequence becomes loud (§5.8) and the enablement path documented. +Default stays `false`; consequence loud (§5.8); enablement documented. ### 6.4 Render identity -As G2, including the `(trace_id, nav_gen, refresh_gen)` registry scope and -capacity rules. +As G2 (including tombstones). ### 6.5 Fallback @@ -566,80 +637,60 @@ render. ### 6.6 Renderer endpoint -- The static renderer document route registers **unconditionally in every - adapter** (the APS provider stays config-gated); startup validation fails - if an auth handler pattern covers it; §G5 route-isolation rules apply - (early dispatch, all methods reserved, no publisher fall-through). -- Path `/integrations/aps/renderer/v1`, embedded in the binary, served - `Cache-Control: immutable` (its bytes change only by shipping `/v2` in a - new release; §0's purge retires the old). Unknown versions → `404` +- Route registers unconditionally in every adapter (provider stays + config-gated); startup validation fails if an auth handler pattern covers + it; §G5 isolation rules apply. +- Path `/integrations/aps/renderer/v1`, embedded, served + `Cache-Control: public, max-age=31536000, immutable`; canary versions + are `no-store` (or bounded below the cohort lifetime); a **checked-in + header manifest per renderer version** freezes headers (CSP included) + with the bytes — a version's headers never change after publication, + resolving the immutable-caching/CSP conflict. Unknown versions → 404 `no-store`. -- **Two-stage acknowledgement:** authenticated `document_loaded` (proves - route + auth + document CSP), then the runner-load result — splitting - `renderer_document_no_load` from `runner_no_load`/`runner_failed`. -- Server route counters (requests, unknown-version, auth-blocked) are - **aggregate only** — the document request carries no trace (the nonce - rides the URL fragment and never reaches the server). -- **CSP rollout that cannot false-pass.** Three distinct uses, each with its - own instrument: **discovery** uses the **currently enforced** policy with - reporting attached (report-only alone cannot reveal what the enforced - policy already blocks); **tightening** candidates run report-only; - **relaxation** candidates are tested in a small enforced cohort under a - short-lived canary document version, gated on runner acceptance rate, CSP - violation rate, render-failure rate, and a kill switch that reverts the - cohort to the frozen policy. Once frozen, a new immutable `/v2` ships with - the final enforced headers. Reports: dedicated `POST /_ts/csp-reports` - accepting **both** `application/csp-report` (legacy) and - `application/reports+json` (Reporting API), each with its own payload - validator; §5.4 caps and rate-limit rules; **origin rules account for the - renderer's opaque sandbox** (reports may carry `null` origin — validated - by document URL / policy version instead of the beacon's same-origin - rule); unused report fields are **discarded before logging or - aggregation**; stored as aggregate counters with blocked sources bucketed - into `https-host (allowlisted) | data | blob | inline | eval | other` (no - arbitrary host labels — cardinality abuse is otherwise trivial). Browser - coverage for opaque-renderer reports runs on **Chromium, Firefox, and - WebKit** (CI is Chromium-only today, `playwright.config.ts:16`; the matrix - extends for this suite). - -### 6.7 One descriptor schema — generation covers all three implementations - -- Wire truth: the tagged `BidRenderer` envelope (discriminator on the enum, - `types.rs:188-211`). -- A wire-schema crate/xtask (separate from `trusted-server-js`; core already - depends on that crate, `Cargo.toml:45`) generates: the JSON-Schema - artifact, the **TS structural parser**, the **ES5-compatible inline - validator fragment** embedded in the renderer document, and shared - fixtures — all checked in with staleness CI. Only environment-specific - semantic checks (URL/origin policy, canonical base64, length bounds, the - exact one-bid AAX projection, cross-field equality) stay handwritten. -- Tolerance only on the outer versioned descriptor; the decoded AAX envelope - remains an exact projection. A shared positive + adversarial corpus runs - through the Rust validator, the generated TS parser, and the generated - inline fragment in CI. +- Three-message acknowledgement per G4b (`renderer_document_loaded` is the + first authenticated envelope). +- Server route counters are aggregate (the nonce rides the URL fragment). +- **CSP rollout:** discovery on the currently enforced policy with + reporting; tightening via report-only; relaxation via a small enforced + cohort on a short-lived canary version, gated on runner acceptance, CSP + violation rate, and render-failure rate, with a kill switch — and **CSP + reports are advisory**: for opaque-origin reports the body-supplied + document URL and policy version are forgeable, so **policy identity is + encoded in a server-selected report path** + (`POST /_ts/csp-reports/`, ids server-generated per version), + reports are bucketed into closed effective-directive buckets and + `https-host (allowlisted) | data | blob | inline | eval | other` source + buckets with global and per-cohort caps, unused fields discarded before + logging, and CSP data is **never a sole automatic rollback signal**. + Physical storage: aggregate counters only. Browser capture on Chromium, + Firefox, and WebKit (CI is Chromium-only today, + `playwright.config.ts:16`), both report media types + (`application/csp-report`, `application/reports+json`) with separate + validators. + +### 6.7 One descriptor schema + +Wire truth is the tagged `BidRenderer` envelope (`types.rs:188-211`). A +wire-schema crate/xtask (no `core → js` cycle; core already depends on the +js crate, `Cargo.toml:45`) generates the JSON-Schema artifact, the TS +structural parser, the ES5 inline validator fragment, and shared fixtures — +checked in, staleness-gated. Semantic checks (URL/origin policy, canonical +base64, bounds, exact one-bid AAX projection, cross-field equality) stay +handwritten. Outer-descriptor tolerance only; AAX projection exact. Shared +positive + adversarial corpus runs through all three validators in CI. ### 6.8 Bridge hardening -Processing order (normative — preserves the existing stolen-capability -defense that suppresses propagation before source validation, -`gpt/index.ts:1547`): - -1. parse `e.data` (bare `catch` → return); -2. identify a TS-reserved ad id (registry lookup); -3. if TS-reserved: `stopImmediatePropagation()` **before** any validation — - a rejected foreign frame must not be answerable by Prebid's native - handler either; -4. validate source ownership via the bounded walk: known slot-root - `WindowProxy` map, sender's own parent chain (`event.source.parent`, …) - to depth 5 — never scanning an attacker-controllable frame tree; -5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); -6. respond, or refuse with `bridge_id_mismatch`. - -Non-TS ad ids are untouched (no propagation suppression). The stolen-token -browser test asserts **neither TS nor the native Prebid listener responds**; -listener-registration ordering has a real-browser assertion. The dead -duplicate renderer branch (C5) is deleted; renderer branches emit the full -§5.1 sequence with G4d notifications only on carrying paths. +Order (normative; preserves the baseline defense that suppresses +propagation before source validation, `gpt/index.ts:1584-1637`): parse → +identify TS-reserved id (live registry **or tombstone**, G2) → +`stopImmediatePropagation()` → validate source ownership via the bounded +walk (known slot-root `WindowProxy` map; sender's parent chain to depth 5; +never scanning the frame tree) → validate nonce/token/`nav_gen`/ +`refresh_gen` → respond or refuse (`bridge_id_mismatch`). Non-TS ids are +untouched. The stolen-token browser test asserts neither TS nor native +Prebid responds; listener-order has a real-browser assertion. Renderer +branches emit the full §5.1 sequence; notifications only on carrying paths. ## 7. TSJS target architecture @@ -649,311 +700,295 @@ duplicate renderer branch (C5) is deleted; renderer branches emit the full kernel/ boot, config, queue, event bus, log, beacon, sessions adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access services/ slots (registry+handoff), auction client, render engine, consent -integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services) +integrations/ gpt, prebid, aps, creative, datadome, … ``` -Boundary lint in CI (`import/no-restricted-paths`): kernel imports nothing -above it; adapters import kernel only; services import kernel + adapters; -integrations import kernel + services, never each other. This dissolves the -audited inversions (`core/auction.ts` and `core/request.ts` importing -`integrations/aps/render`; `gpt` and `prebid` importing `aps`; `prebid` -owning the GPT refresh wrapper). Stateful services via the G3 registry only. +Enforced by the two G3 lint rules. Dissolves the audited inversions +(`core/auction.ts`/`core/request.ts` → `integrations/aps/render`; +`gpt`/`prebid` → `aps`; `prebid` owning the GPT refresh wrapper). ### 7.2 Adapters -Per external global: `present | pending | timed_out`, `timed_out` -non-terminal (late loaders transition to `present` and drain what is still -valid); queued operations carry their own timeouts and expire with +`present | pending | timed_out` per external global; `timed_out` +non-terminal; queued operations carry their own timeouts and expire with disposition reasons. ### 7.3 Slot registry service -Kernel-owned; `WeakMap` + div-id index; holds -ownership (ts/publisher/adopted), handoff claims, responsive resolution, -G4a intent queue + cycle state (RuntimeSession-scoped), targeting-key -history. No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid` -deleted). - -### 7.4 Final global surface (hard cutover — no dual names) - -| Legacy surface (removed at cutover) | Final shape | -| -------------------------------------------- | ----------------------------------------------------------------------------- | -| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue | -| `globalThis.tscreative` (API) | `tsjs.creative.*` (same methods, namespaced) | -| `globalThis.tsCreativeConfig` (pre-load) | `tsjs.boot.creative` inside the boot container (below) | -| `requestAds` (void) — and rev-4's dual API | **one** async contract: `tsjs.requestAds(options): Promise` | -| `window.__tsjs_*` flags, integration configs | `tsjs.boot.*` fields written by server-injected scripts before the bundle | -| server install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | -| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | -| `tsjs._internal` | kernel-owned registry (G3), **frozen after boot** | - -Boot container lifecycle: pre-core scripts write -`window.tsjs = window.tsjs || {que: [], boot: {}}` fields; the kernel -**consumes `boot` at boot, deep-freezes the retained copy, and deletes -consumed one-shot secrets** (the trace authorization moves into the sealed -NavigationSession). Old pages referencing removed names fail at cutover — -accepted per §0. +Kernel-owned; `WeakMap` + div-id index; +ownership, adoption, handoff claims, responsive resolution, the G4a causal +intent queue + cycle/drain state (RuntimeSession) and unissued intents +(NavigationSession), targeting history. No expandos +(`__tsRenderGeneration`/`__tsRenderBid` deleted). + +### 7.4 Final global surface (hard cutover) + +| Legacy surface (removed at cutover) | Final shape | +| --------------------------------------- | --------------------------------------------------------------------- | +| `window.tsjs.que` | `window.tsjs.que` — unchanged | +| `globalThis.tscreative` | `tsjs.creative.*` | +| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | +| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) | +| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` | +| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | +| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | +| `tsjs._internal` | kernel registry (G3), frozen after boot | + +**Bootstrap correctness (closing the review's two holes):** every +server-injected initializer creates the container **idempotently and +field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}` — +never only-when-absent (today the first ad-slot script does +`window.tsjs = {}`, `publisher.rs:3665`, which would clobber or starve +later `boot` writes). Kernel boot claims an **atomic owner sentinel**; it +consumes `boot`, deep-freezes the retained copy, and deletes one-shot +secrets. The generated no-bundle fallback (§7.7) activates on bundle +`error` **or** a bounded hang watchdog (10 s without kernel boot); if the +fallback has activated and the bundle later arrives, the bundle **defers +for the rest of the page** (logs + `bundle_partial` disposition) — queue +ownership never changes hands mid-page. ### 7.5 Messaging module -All `postMessage` through one module: versioned envelopes, name constants -(the `'Prebid Request'` literal exists at six sites today; the APS handshake -in three copies), G4b nonces, §6.8 validation. The minimal module (envelope + -constants + validators used by the bridge) lands in Phase 1; full call-site -migration completes in Phase 4. +All `postMessage` through one module: versioned envelopes, name constants, +G4b nonces, §6.8 validation. Minimal module lands in Phase 1; full call-site +migration in Phase 4. ### 7.6 Plugin lifecycle — transactional — and sessions -`tsjs.definePlugin(id, install, dispose?)` with `install(ctx)`: - -- `ctx.signal` (aborted on quarantine/disposal); synchronous - `ctx.onDispose(fn)` registration; effects must be registered as they are - made. -- **Unwind on failure:** a throw, rejection, or abort triggers automatic - reverse-order invocation of the disposers registered so far — partial - installs cannot leak effects. Per-disposer exception isolation (one - throwing disposer cannot stop the rest). -- A disposer registered (or returned) **after** the owning session was - disposed is invoked immediately. -- Pending late registrations (manifest requested, bundle not yet evaluated): - capacity 16, bound 10 s, then `bundle_partial`. -- Release matching per G3: a plugin whose `release_id` differs from the - kernel's is quarantined before `install` runs. -- Sessions: `RuntimeSession` (page lifetime: bridge listener, history hook, - pbjs subscriptions, adapters, beacon queue, **slot cycle state**); - `NavigationSession` (per navigation: trace + authorization, render - attempts, slot aliases, targeting history); `RenderAttempt` (per G4a cycle - or G4f attempt). Each owns an enumerable disposal inventory; navigation - disposes only NavigationSession children. -- Error policy: no empty `catch` — handle, log with context, or emit a - disposition. The auction fetch gains timeout + `AbortController`. -- **Console logging retained:** every issue-surfacing condition keeps or - gains a `log.warn` carrying the same reason code as its beacon event; - `debug`-level delivery/security failures are promoted to `warn`. +`tsjs.definePlugin({id, release, install, dispose?})` — object form; the +`release` field is the build-generated `release_id` constant (G3 needs it; +revision 5's positional API omitted it). `install(ctx): void | +Promise`: + +- `ctx.signal`; synchronous `ctx.onDispose(fn)`; reverse-order unwind on + throw/reject/abort; per-disposer isolation; disposer registered after + disposal → invoked immediately; pending late registrations capacity 16, + bound 10 s → `bundle_partial`; release mismatch quarantines before + `install`. +- Sessions: `RuntimeSession` (page-lifetime: bridge listener + tombstones, + history hook, pbjs subscriptions, adapters, beacon queue, physical slot + cycle/drain state); `NavigationSession` (trace + authorization + renewal + timer, render attempts, slot aliases, unissued intents, targeting + history); `RenderAttempt` (per G4a cycle / G4f attempt). Enumerable + disposal inventories; navigation disposes NavigationSession children + only. +- No empty `catch`; auction fetch gains timeout + `AbortController`. +- **Console logging retained**: every issue-surfacing condition keeps or + gains a `log.warn` with the beacon's reason code; `debug`-level + delivery/security failures promoted to `warn`. ### 7.7 Bootstrap -`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/hydration -logic, with the live `servicesEnabled` divergence) shrinks to a -queue-and-flags stub; the bundle replays recorded early calls on install. -Replay changes observable ordering — it ships inside the cutover with -browser specs covering replay timing. The no-bundle fallback ("ads render if -the bundle fails", pinned by `gpt.rs:1174-1179`) is **generated from the -same TypeScript source** at build time. +`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays +recorded calls on install (browser specs cover replay timing); the +no-bundle fallback is **generated from the same TypeScript source**, with +the §7.4 activation/arbitration rules. ### 7.8 GPT correctness carried with the restructure -Unconditional early `slotRequested`/`slotRenderEnded` subscription (replacing -the `!servicesEnabled` gate, `gpt/index.ts:1017`; recording idempotent); -restore #922/#997 attribution and orphan recovery; `changeCorrelator: false` -on TS refreshes (configurable); `enableSingleRequest()` only when GPT -services are not already enabled; ambiguous responsive resolution emits -`render_fail{slot_unresolved}` alongside its warning. +Unconditional early `slotRequested`/`slotRenderEnded` subscription +(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent +recording); restore #922/#997 attribution and orphan recovery; +`changeCorrelator: false` on TS refreshes (configurable); +`enableSingleRequest()` only when services are not already enabled; +ambiguous responsive resolution emits `render_fail{slot_unresolved}`. ### 7.9 Decomposition targets | Today | Target | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | +| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | | `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | | `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | -| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | +| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` | | `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | ### 7.10 Performance (reproducible) -- Bundle budgets: raw/gzip/Brotli per bundle for **three module vectors** - (minimal, reference, maximal), compressors pinned (`gzip -9`, - `brotli -q 11`), compared to checked-in baseline artifacts - (`perf/baselines/*.json`); baseline updates are explicit reviewed diffs. - Tolerance +5% bytes. -- Browser timing (bids-script-to-first-`display()`): named runner image - `ubuntu-24.04` (the CI image already used by this repository's workflows) - and the Chromium build bundled with the pinned `@playwright/test` version - from `package.json`; 5 warm-up runs discarded, 50 samples, gate p90 ≤ - baseline × 1.10. The baseline artifact records image, browser, and tool - versions alongside the numbers; a baseline update is invalid if any of - those differ from the pinned set. -- Server (precomputed concatenation): one-sided gates, CPU and heap ≤ - baseline × 1.10; improvements always pass. Tool versions pinned in - `.tool-versions`. +- **Dedicated workflow** on a fixed runner (`runs-on: ubuntu-24.04` + explicitly — browser CI is `ubuntu-latest` today, + `integration-tests.yml:155` — inside a pinned container image digest); + browser = the lockfile-resolved `@playwright/test` build with its browser + revision recorded in the baseline artifact (the manifest is a caret + range today, `browser/package.json:10` — the lockfile + recorded + revision are authoritative); compressors pinned by version in the + container (`gzip -9 -n` for determinism, `brotli -q 11`). +- Bundle budgets: raw/gzip/Brotli for three vectors (minimal, reference, + maximal) vs checked-in baselines (`perf/baselines/*.json`, updates are + reviewed diffs recording image/browser/tool versions); +5% bytes. +- Browser timing: 5 warm-ups discarded, 50 samples, p90 ≤ baseline × 1.10. +- **Server benchmark harness (complete):** workload = concatenation + + hash of the reference vector; 100 warm-up iterations, 1,000 measured; + statistic = median and p90; one-sided gates ≤ baseline × 1.10; variance + policy: 3 consecutive runs must agree within 5% or the result is + inconclusive (rerun, never pass). ### 7.11 Toolchain -Raise the TypeScript floor to the resolved 5.9 line (lockfile already -resolves 5.9.3 under the stale `^5.5.4` manifest); adopt -`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, -`verbatimModuleSyntax`; dev-toolchain bumps (eslint, prettier, jsdom, -`@playwright/test`, `@types/node`) as individual CI-gated PRs with changelog -review (this library monkeypatches `fetch`/`sendBeacon`/DOM prototypes — -jsdom/Playwright changes are real risks); `prebid.js` excluded from casual -bumps (runtime Prebid is the manifest-locked external bundle; the npm pin -and deployed bundle version documented together); monthly review; no phase -starts more than one minor behind stable. - -## 8. Migration plan - -Phases are internal build milestones of **one coordinated release** (§0): -each lands behind a flag in the dark deployment; the cutover switches them -on together. Gates are executable — every gate names query, cohort, -denominator, minimum sample, threshold, window, owner (release owner unless -stated), and action (hold cutover / rollback switch): - -- **Phase 0 — Identity, schemas, toolchain.** Path-hashed embedded assets + - 410 semantics + ordered-vector precompute; `format_version`; §5.6 schemas - deployed and validated (writer-off); toolchain floors; dead expando writes - deleted; §5.8 server drop surfacing. - _Gate:_ dark-instance health 100%; datasource validation green (rejection - rate < 0.1% on synthetic writes, freshness ≤ 5 min); asset `410` rate on - dark probes = 0 for known hashes. +TypeScript floor to the resolved 5.9 line; strictness flags on; dev +toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from +casual bumps; monthly review. + +## 8. Rollout: phases, decision records, and executable gates + +Phases are build milestones inside the §0 single-release model (dark pool → +probe gates → router-weight canary of coherent requests → full weight). + +**Phase 0 decision records** (promoted from open questions; each has an +owner, evidence, a deadline, and an explicit go/no-go): DR-1 mediator +presence in the affected deployment (OQ1 — decides whether §6.1 gates +Phase 3 entry); DR-2 script-creative share (OQ2 — decides the §6.3 +guidance priority); DR-3 #922 vs #997 (OQ4 — decides the Phase 3 work +item); DR-4 mediator candidate-id echo owner and timeline (OQ7 — +`merge_highest_cpm` is config-blocked until delivered); DR-5 non-Fastly +sink decision (OQ5 — splits Phase 2's gates below). + +**Gates are a checked-in table** (`docs/superpowers/specs/rollout-gates.md`, +created in Phase 0) with columns: query/test command, assignment key, +expected positive count, denominator, sample floor, threshold, window, +owner, hold/rollback action. The real-GAM suite's row includes its workflow +name, fixture account and credentials owner, invocation command, artifact +location, retry policy, and required approval evidence. Prose below is the +summary; the table is normative. + +- **Phase 0 — Identity, schemas, toolchain, decisions.** Path-hashed + assets + 410 semantics + construction-time concatenation cache; + `format_version`; §5.6 schemas deployed writer-off; toolchain floors; + dead expando writes deleted; §5.8 drop surfacing; the five decision + records; the gates table itself. - **Phase 1 — Kernel, sessions, minimal messaging, cycle registry.** G3 - registry (exact release ids); RuntimeSession/NavigationSession; install - manifest; minimal messaging module; G4a intent/cycle records; unconditional - GPT subscriptions. - _Gate:_ browser-spec suite green incl. listener-order assertions; zero - `abi_mismatch`/`bundle_partial` on dark probes. -- **Phase 2 — Trace + beacon.** Server-minted initial trace + page-bids - header echo + signed authorizations; beacon service; four-adapter ingest; - `ts_client_events` writers on. - _Gate:_ on dark probes: ingest acceptance ≥ 99%, group auth-rejection - < 0.5%, dedup query returns exactly-once per `(trace, seq)`; freshness - ≤ 5 min over a 24 h window. -- **Phase 3 — APS delivery.** Schema crate + corpus (6.7); mediation - algorithm + required `winner_selection` (6.1); render token (G2); - renderer route + two-stage ack + CSP report route (6.6); bridge order - (6.8); G4a–G4f state machines, awaitable renderer, scoped notifications, - fallback; #922/#997 restoration; correlator + SRA fixes. - _Gate (canary cohort vs simultaneous control cohort, 24 h, minimum 10,000 - sampled attempts each):_ APS `render_accepted` / attributable APS attempts - ≥ 95%; renderer-document load rate ≥ 99%; runner failure+timeout ≤ 1%; GAM - fill, p95 latency, and billing volume deltas within ±2% of control - (billing measured against GAM/server-side reporting, not the beacon); - real-GAM overlap test green. Action on breach: hold cutover. -- **Phase 4 — Structure.** Full layering + boundary lint; transactional - plugin lifecycle; adapters; full slot registry; full messaging migration; - final namespace (7.4). - _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests - green; pre-cutover page smoke on the final namespace; **behavioral-parity - suite green across all four flows** (SSAT, client-Prebid, page-bids, - direct `/auction`) comparing pre- and post-restructure event sequences on - the reference page. + registry; sessions; install manifest; minimal messaging; G4a intent/cycle + records; unconditional GPT subscriptions. +- **Phase 2 — Trace + beacon.** G1 issuance on all three paths (boot, + page-bids, `/auction` extension); §5.3 authorization incl. renewal and + the diagnostic credential; four-adapter ingest; `ts_client_events` + writers on. Gates split per DR-5: **HTTP parity** (all adapters: + routing, limits, 204s, method reservations) vs **persistence** + (sink-backed only: acceptance, dedup-exactly-once, heartbeat freshness). +- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required + `winner_selection` and `[auction].currency`; render token + tombstones; + renderer route + three-message ack + CSP report route; §6.8; G4a–G4f + incl. direct-flow `nurl`/`burl` plumbing; fallback; DR-3's attribution + restoration; correlator + SRA fixes. + _Gate (sticky randomized canary/control cohorts, 24 h, ≥ 10,000 sampled + attempts each; missing telemetry counts as failure):_ **denominator = + all server-observed eligible APS wins**; per-stage rates gated + separately — targeting_set/eligible, bridge_request/targeting_set, + render_accepted/bridge_response_sent, and `cycle_unattributable` rate + < 0.5% (survivorship is thereby visible, not excluded); GAM fill and p95 + latency as **one-sided non-inferiority** (canary not worse than control + by > 2%; improvements pass); billing normalized per attempt and per + thousand attempts vs control; a separate **duplicate-billing invariant** + (zero double `burl` per idempotency key); real-GAM overlap suite green + per its table row. +- **Phase 4 — Structure.** Full layering + both lint rules; plugin + lifecycle; adapters; full slot registry; full messaging migration; final + namespace. + _Gate:_ lints zero exceptions; disposal-inventory leak tests; four-flow + behavioral parity (SSAT, client-Prebid, page-bids, direct). - **Phase 5 — Decomposition + cutover.** File splits; script-guard - consolidation; bootstrap stub + generated fallback; then the §0 runbook. - _Gate:_ bundle budgets + timing assertions hold; cutover checklist signed - off; post-switch monitor window 24 h on the §5.7 objectives; rollback = - traffic switch back. + consolidation; bootstrap stub + generated fallback (with error/hang/ + fallback-arbitration tests); **four-flow parity reruns here** (it changed + bootstrap behavior after Phase 4's parity — the review's ordering + point); then the §0 runbook: weight-up, purge, 24 h monitored window, + weight-back rollback. ## 9. Test acceptance matrix -Hermetic CI (deterministic PUC/message harness) blocks PRs; the staged -real-GAM suite is release-gating. Rows added this revision are marked •. - -| Area | Must cover | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Request cycles | intent-vs-request separation; • initial-load-disabled cycle formation (display creates no request); SRA batching; `intent_no_request`; publisher overlap → quarantine; • old-navigation completion before and after replacement; drain/re-arm; real-GAM overlap (gate) | -| Ack protocol | five-field validation; SSAT/client-Prebid/SafeFrame; stale + replayed acks; • acks after navigation disposal | -| Bridge security | • TS-reserved id rejected with propagation stopped — neither TS nor native Prebid responds; wrong-slot/stolen/prior-navigation tokens; bounded parent-chain walk; listener-order real-browser assertion; SafeFrame positive | -| Render semantics | notifications only on carrying paths (never APS); bind per flow (PUC claim / direct render-start / fallback pre-render); `burl` at `render_accepted`; attempt-scoped idempotency; `billed_then_failed`; accepted-but-blank | -| Direct `/auction` | • full G4f lifecycle: attempt keys, refresh_gen increments, cancellation on navigation, exactly-once terminal, same event sequence | -| Fallback | only attributed `gam_empty` (adopted + TS-owned); publisher-initiated never; timeout never renders; SPA cancellation; • flag change during an active attempt | -| Mediation | • candidate-id echo; • transformed/unresolvable provenance → slot fails closed for merging; • duplicate candidates dedup; • deterministic ties independent of arrival order; currency validation at the Prebid parse point; both lifecycles; required `winner_selection` | -| Render token | format/CSPRNG/in-auction retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scoping; • registry capacity → `registry_full` | -| Trace auth | • HMAC verification: expiry, skew, max-future, missing kid, rotation (previous key), constant-time path; per-group rejection; cache-privacy invariant (`private, no-store`) | -| Beacon | initial + SPA trace joins; per-trace grouping; seq gaps; • duplicate fetch/pagehide delivery deduped at sink; queue overflow marker; ingest abuse; sendBeacon Blob type | -| Ingest/limits | • token-bucket rate + burst; • limiter saturation and address churn; • map capacity/TTL/eviction; fail-closed drop with 204 | -| Internal routes | • wrong-method → 405 + Allow + no-store on every adapter; • unknown version → 404 no-store; • no publisher fall-through; • dispatch before auth/EC/filters; • no body/cookie/authorization forwarding | -| CSP | • both media types with separate validators; • opaque/null-origin renderer reports accepted; • bucketed aggregation only; • Chromium/Firefox/WebKit capture; • enforced-policy discovery vs canary-cohort relaxation | -| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection | -| Runtime ABI | one kernel under concatenation; exact-release verdicts (match runs, mismatch quarantines); late registration; failure isolation | -| Plugins | • partial synchronous install unwound in reverse order; • async rejection; • abort while pending; • disposer-after-disposal invoked immediately; per-disposer isolation | -| Lifecycle | `timed_out → present`; session disposal inventories; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`) | -| Delivery | unknown hash 410 no-store; immutable on exact match; ordered-vector precompute; cutover runbook rehearsal (switch + purge + rollback switch) | -| Sink | • datasource-side freshness + rejection monitoring (sink is fire-and-forget); • sink auth/schema rejection surfaced by monitor | -| Failure injection | • Amazon runner redirect, network hang, CSP block, script error → distinct §5.1 outcomes | -| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | -| Policy | script-creative warning; `invalid_dimensions` + bounded w/h; `dimensions_out_of_range` unclamped; page-bids `debug` gating; diagnostic completeness | +Hermetic CI blocks PRs; the real-GAM suite is release-gating per its gates +row. New/changed rows this revision are marked •. + +| Area | Must cover | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request cycles | intent-vs-request; • disabled-initial-load `display()` retired at issuance, publisher `refresh()` inside 2 s window attributed to publisher (the exact review case); SRA; `intent_no_request`; overlap quarantine; • no timeout re-arm (drain/destroy/page-end only); stale discard; real-GAM overlap | +| Ack protocol | three-message sequence (• `renderer_document_loaded` envelope); five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed; after-disposal acks | +| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; • prior-navigation ids suppressed via RuntimeSession tombstones after NavigationSession disposal; bounded walk; listener order | +| Render semantics | binds per flow; `burl` at accepted; attempt idempotency; • `billing_outcome{billed_then_failed}` as its own event; accepted-but-blank; • `gam_collapsed` emission + guarded resize (authenticated source only; 1×1 wrapper; anchor/fixed guards) | +| Direct `/auction` | • trace header + response `ext.trusted_server.trace`; • per-slot latest-wins with reversed responses; • generation check before each DOM/beacon effect; • `RequestAdsResult` settlement; • server preserves + expands `nurl`/`burl`, client validates | +| Fallback | attributed `gam_empty` only; publisher-initiated never; timeout never renders; SPA cancellation; flag change mid-attempt | +| Mediation | • `[auction].currency` required + per-provider validation (Prebid parse, APS implied USD); • `ext.trusted_server.candidate_id` echo; • mediator-native candidates ordered by intrinsic key (arrival-order shuffle test); • authoritative-field rules (repricing kept, adm-swap → native); provenance fail-closed; • strategy-specific timeouts; both lifecycles | +| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; capacity → `registry_full`; • tombstone retention to original TTL, cap 256 | +| Trace auth | • auth ≤ 256 B bound accepted, 64-char cap for others; • encoding vectors (kid charset, canonical exp, u32be/u64be length prefixes, unpadded base64url); expiry/skew/max-future; • renewal preserves trace + mode; • previous-key retention ≥ 24 h; rotation; per-group rejection | +| Sampling modes | • signed `unsampled`: client transmits nothing, ingest rejects carried events, stickiness across renewal; • diagnostic requires the operator credential — tester cookie alone must fail; forgery/replay of the credential | +| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; • `client_queue_overflow` not in failure denominators; ingest abuse; sendBeacon Blob | +| Ingest/limits | • per-adapter limiter semantics as declared (Fastly fixed-window approximation, Axum token bucket); • at-capacity rejects unseen identities, never evicts active; fail-closed 204 | +| Internal routes | wrong-method 405 + Allow + no-store on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding | +| CSP | both media types; opaque/null-origin admission; • policy identity from server-selected report path (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; • header manifest per version (immutable headers frozen with bytes) | +| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX | +| Runtime ABI | one kernel; exact-release verdicts; late registration; failure isolation; • object-form `definePlugin` release check | +| Plugins | partial-install unwind; async rejection; abort pending; disposer-after-disposal; isolation | +| Lifecycle | `timed_out → present`; session inventories; • unissued intents cancelled by navigation disposal; boot container idempotent field-wise init (• ad-slot script no longer clobbers); • fallback error/hang activation + late-bundle deferral; final-namespace smoke | +| Delivery | unknown hash 410 no-store; exact-match immutable with full directive; • construction-time vector cache (unlisted vector = startup error); cutover rehearsal | +| Sink/monitoring | • sequence-tagged synthetic heartbeats measure rejection + freshness; • canonical views only (raw-join multiplication test); `publisher_domain` naming | +| Failure injection | Amazon runner redirect/hang/CSP/script error → distinct outcomes; EC/filter failure before renderer dispatch | +| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | +| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; • `boot.debug` + response `debug` gating; diagnostic completeness per §2.5 | ## 10. Alternatives considered -1. **Patching APS point-failures without telemetry** — rejected: three - correct fixes produced no ads; the next fix would be another guess. -2. **Always direct-render APS (skip GAM/PUC)** — rejected: unilaterally - changes GAM reporting/pacing; kept only as the attributed-`gam_empty` +1. Patching APS point-failures without telemetry — rejected (four correct + fixes, still no reliable ads). +2. Always direct-render APS — rejected; kept as the attributed-`gam_empty` fallback. -3. **Single module graph / shared chunks now** — rejected for this release: - changes the delivery pipeline while everything else changes; recorded as - the successor behind the same registry surface. -4. **Full rewrite in one branch without phases** — rejected: the browser-spec - safety net is thinnest exactly where behavior changes. -5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle - guarantee; generation from TS keeps it without dual maintenance. -6. **Timeout-triggered fallback** — rejected: GPT requests cannot be - cancelled; a timeout race can double-render and double-bill. -7. **N/N−1 compatibility machinery** (revisions 3–4) — removed by the §0 - policy decision: version ranges, retained artifacts, legacy URLs, and - dual-name globals deleted in favor of exact release matching and a - coordinated switch. +3. Single module graph now — rejected for this release; successor option. +4. Big-bang rewrite without phases — rejected (thin safety net). +5. Dropping the ES5 bootstrap — rejected (loses the no-bundle guarantee); + generated fallback keeps it. +6. Timeout-triggered fallback rendering — rejected (uncancelable GPT + requests race late fills). +7. Timeout-based quarantine re-arm (revision 5) — removed: it recreated + the stale-event bug it claimed to fix. +8. N/N−1 compatibility machinery (revisions 3–4) — removed by the §0 + policy. ## 11. Risks -- **Hard cutover blast radius:** in-flight pages fail at switch; accepted by - policy (§0); bounded by the purge + 24 h monitored window + traffic-switch - rollback. -- **Mediator wire-contract change** (candidate-id echo) requires - coordinating the mediator implementation; until echoed ids exist, - `merge_highest_cpm` cannot be enabled (config validation enforces this). -- **Notification triggers become a published contract** for PBS-path demand; - changing them later is a breaking change for SSP reporting. -- **Beacon abuse:** bounded by pre-parse caps, origin checks, fail-closed - numeric rate limits, server-decided sampling, signed authorizations. -- **Registry/limiter memory:** all client and server maps carry explicit - capacities, TTLs, and eviction rules (G2, §5.4). -- **CSP relaxation:** enforced-cohort canary + new immutable version prevent - false-clean canaries; bucketed aggregation prevents cardinality abuse. -- **Sink blindness:** fire-and-forget dispatch is compensated by mandatory - datasource-side freshness/rejection monitoring. +- Hard-cutover blast radius (accepted; bounded by the §0 runbook). +- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates + `merge_highest_cpm`. +- Notification triggers become a published contract for PBS-path demand. +- Beacon abuse — capped, origin-checked, fail-closed limited, signed + modes, credentialed diagnostics. +- Registry/limiter memory — explicit capacities, TTLs, reject-at-capacity. +- CSP data is advisory — never a sole rollback signal. +- Sink blindness — heartbeat-based datasource monitoring. +- `[auction].currency` and `winner_selection` are new required config in + mediated deployments — a deliberate startup-error class under §0. ## 12. Success criteria -1. APS creatives render in each configured flow — SSAT, client-Prebid, - page-bids, and direct `/auction` (G4f) — hermetically in CI and in the - release-gating real-GAM suite. -2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the - failing class from one page load; §5.7 objectives hold on sink-backed +1. APS creatives render in each configured flow (SSAT, client-Prebid, + page-bids, direct), hermetically and in the release-gating real-GAM + suite. +2. Every §2 failure maps to its §2.5 signal; **diagnostic mode names the + failing class from one page load including A1–A4 via the `boot.debug` / + response-`debug` envelopes**; §5.7 SLIs hold on sink-backed deployments. -3. Boundary lint zero exceptions; stateful sharing only via the G3 registry; - exact-release mismatches quarantine loudly. +3. Lints (both rules) zero exceptions; stateful sharing only via the + registry; exact-release mismatches quarantine loudly. 4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. -5. Trace counts are per-impression; orphan recovery has a non-vacuous test; - cycle attribution follows G4a (physical requests, publisher-overlap - quarantine, drain/re-arm). -6. The only TSJS-owned global is `window.tsjs` with the §7.4 final shape; no - expandos on GPT slots, GPT functions, or `pbjs`; legacy names are gone at - cutover. -7. §7.10 budgets hold (three vectors, pinned tools, one-sided server gates). -8. No existing warning is lost; every issue-surfacing condition logs `warn`+ - with the beacon's reason code. -9. TypeScript floor matches the resolved 5.9 line with strictness flags on; - `prebid.js` pin matches the documented deployed bundle. -10. `nurl`/`burl` fire only on carrying paths at their G4d binds, - attempt-scoped and idempotent; APS fires neither. -11. Trace-bearing responses are `private, no-store` by test; authorizations - are per-trace, signed, mode-carrying, and never accepted unsigned. -12. The cutover runbook has been rehearsed (switch, purge, rollback switch) - before the production switch. +5. Attempt counts are keyed `(trace_id, nav_gen, refresh_gen, slot)` + (traces stay navigation-scoped); no double counting; orphan recovery + has a non-vacuous test; G4a holds including the no-timeout-re-arm rule. +6. The only TSJS-owned global is `window.tsjs` (§7.4 final shape); no + expandos; legacy names gone at cutover. +7. §7.10 budgets hold on the dedicated pinned workflow. +8. No existing warning lost; issue-surfacing conditions log `warn`+ with + the beacon reason code. +9. TypeScript floor matches resolved 5.9; `prebid.js` pin documented with + the deployed bundle. +10. `nurl`/`burl` only on carrying paths at their G4d binds, idempotent + per attempt; APS fires neither; duplicate-billing invariant holds. +11. Trace-bearing responses are `private, no-store`; authorizations are + per-trace, signed, mode-carrying (`sampled|unsampled|diagnostic`), + renewal-capable; unsampled traces transmit nothing. +12. The cutover runbook rehearsed (weight switch, purge, rollback). ## 13. Open questions -1. Is a mediator configured in the affected production deployment? -2. What share of live APS demand is `tagtype: "script"`? -3. Should `client_render_fallback` ever become default-on for publishers - without GAM line items for `hb_bidder=aps`? -4. Is PR #997 the intended restoration of the lost #922 attribution core, or - should the original be re-merged? -5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep - accept-count-drop? -6. Does Amazon expose any creative-completion acknowledgement that could add - a confirmed state beyond `render_accepted` under a new name? -7. Who implements and owns the mediator-side candidate-id echo (6.1), and on - what timeline relative to this release? +Promoted to Phase 0 decision records: mediator presence (DR-1), +script-creative share (DR-2), #922 vs #997 (DR-3), candidate-id echo owner +(DR-4), non-Fastly sinks (DR-5). Remaining open: does Amazon expose any +creative-completion acknowledgement that could add a post-`render_accepted` +state under a new name (future enhancement)? From d4b34ec0ec609a804200fa1cfe6a47ef95519955 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:27:56 -0700 Subject: [PATCH 548/844] Revise design spec for the sixth review round Revision 7: renewal presents and verifies the current signed authorization so mode survives without server state, sampling becomes a deterministic keyed function, the diagnostic credential gets a complete issuance/transport/expiry protocol with the tester cookie scoped to debug content only, /_ts/trace-auth joins the isolated route families, sticky-cohort release affinity makes canary requests coherent end to end, the server auction_id is echoed into client events for attempt-grain joins with release_id as the cohort discriminator, a closed flow field drives per-flow funnel gates, the reservation store caps the live-plus-tombstone union and never evicts unexpired ids, zero-request display intents are retired for any caller with a symmetric ambiguity rule, the acknowledgement protocol is specified for all four render paths, bootstrap ownership becomes transactional with inert-install and commit, CSP ingest gets concrete bounds and physical schemas alongside ts_ops_counters, heartbeats become a first-class probe-mode event, telemetry settings split collection from sink with lazy secret initialization everywhere, mediation gains required-unique upstream ids with deterministic fingerprints and closes its authoritative-field contradictions, assets pre-materialize at release publication, notification_sent makes the duplicate-burl invariant observable, one terminal state per attempt, per-reason source nullability, gam_collapsed carries its action, the final surface adds definePlugin, lint moves to member-expression rules, performance gains marks/vectors/heap budgets, TypeScript flags are enumerated, mid-attempt kill-switch semantics are defined, DR-2 becomes a deployment decision, the Phase-3 gate reruns on the immutable Phase-5 candidate, and the normative gates table ships as Appendix A of this one file. The baseline APS fixes are adopted as contracts and re-implemented in the target architecture with their browser tests as the conformance pin. --- ...s-render-fix-and-tsjs-resilience-design.md | 1595 ++++++++--------- 1 file changed, 706 insertions(+), 889 deletions(-) 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 0251485b9..001011d91 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 @@ -1,994 +1,811 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 6 — baseline advanced to the APS PUC/collapsed-shell - fix, and reworked after the fifth review round. +- **Status:** revision 7 — reworked after the sixth review round. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed - GAM shells") — the full merged state. All file:line citations refer to this - commit. -- **Inputs:** three code audits; design reviews of revisions 1–5; open issues + GAM shells"). All file:line citations refer to this commit. +- **Inputs:** three code audits; design reviews of revisions 1–6; open issues #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997. +- **Normative gates:** the initial rollout-gates table ships as + **Appendix A of this document** (one file, one review surface); changes to + it require reviewed decision records so thresholds cannot be chosen after + observing results. +- **Adoption stance for the baseline APS fixes (`248fe9558`):** this design + adopts their **contracts** — the MessageChannel handshake semantics, the + collapsed-shell remediation behavior, the consolidated bridge branch — and + **re-implements them inside the target architecture** (the messaging + module owns the channel protocol, the render engine owns the resize, the + rebuilt `render_bridge` module owns the branch). The patch code itself is + not carried forward through the refactor; the baseline's browser tests are + retained as the conformance suite that pins the adopted behavior while the + implementation is replaced. ## 0. Release policy: coordinated hard cutover -This design targets a **single coordinated release**: +One coordinated release: -- Server, TSJS bundles, config format, and page HTML ship together under one - **`release_id`** (git tag / build hash). **No N/N−1 support**: old pages, - bundles, config blobs, globals, and URLs may stop working at cutover; - in-flight clients may fail. Accepted and stated, not mitigated. -- **Exact release matching only** — kernel, services, plugins, and the - install manifest carry the same `release_id`; mismatch is a refusal. -- **Config:** top-level `format_version`, exact match required. Rollback = +- Server, TSJS bundles, config, and HTML ship under one **`release_id`**. + **No N/N−1**; in-flight clients may fail at cutover — accepted and stated. +- **Exact release matching**; config `format_version` exact-match; rollback = redeploy the previous release with its own config. -- **Assets:** binaries embed only their release's artifacts; hashed pathnames - exist for cache identity only; unknown hash → `410 Gone`, `no-store`. -- **One executable rollout state machine** (resolving the §0/§8 tension the - review found): a release ships with a **deployment manifest** enumerating - the complete flag set; the new pool comes up **fully enabled but - unreachable except by probes**; phase gates (§8) run against probe traffic - and a **router-weight canary of coherent routed requests** (a request is - served end-to-end by one pool — HTML, assets, and APIs never mix pools); - **router weight is the sole activation primitive**; cutover = weight to - 100% + CDN purge; rollback = weight back + re-purge. Flags exist for - emergency kill switches inside a pool, not as the activation mechanism. +- **Config is a release-time input** under this policy: the config blob and + binary publish together, so the enabled module vectors are known at + release publication (this powers §G5 asset materialization). +- Assets: embedded only; hashed pathnames for cache identity; unknown hash + → `410`, `no-store`. +- **Rollout state machine with release affinity.** A deployment manifest + binds each pool to immutable `{release_id, config_store, config_key, +config_hash}` (rollback binding prevalidated). The new pool comes up fully + enabled, reachable only by probes. Canarying uses a **sticky cohort + token**: the router assigns `ts-rel=` on the HTML response, + routes every subsequent request (assets, APIs, beacons) by it, and cache + keys include it — router weights alone apply per request and would mix + pools, so affinity is what makes a canary request **coherent** end to end. + Router weight over sticky cohorts is the sole activation primitive; flags + are in-pool emergency kill switches only. Cutover = weight 100% + CDN + purge; rollback = weight back + re-purge. ## 1. Problem statement -APS demand is fully integrated server-side — the edge runs the APS OpenRTB -auction, wins bids, and ships a typed renderer descriptor to the page — yet -APS creatives do not appear reliably for real users. Four serial fixes (the -`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback, and -now the baseline's PUC/collapsed-shell fix) each survived review; the pattern -is the finding: the pipeline has **multiple independent failure points, most -of which fail silently**, and the client cannot tell the server which fired. - +APS demand is fully integrated server-side, yet APS creatives do not appear +reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim, the +`hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived +review; the pattern is the finding: **multiple independent failure points, +most failing silently**, with no client→server signal about which fired. The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths, -duplicated ES5/TS logic, inverted layering, ~100 error-swallowing `catch` -blocks) is the same problem structurally. This design fixes APS delivery and -rebuilds TSJS so the next integration cannot reproduce this failure class. +duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches) +is the same problem structurally. ### Non-goals -- No change to the APS OpenRTB endpoint contract or Amazon-side configuration - (including its deliberate absence of `nurl`/`burl`, §G4d). +- No change to the APS OpenRTB endpoint contract (including its deliberate + absence of `nurl`/`burl`, §G4d). - No rewrite of the decoupled Prebid.js strategy. -- **No backward compatibility** (§0); replacement surfaces are in §7.4. +- No backward compatibility (§0); replacement surfaces in §7.4. ## 2. Why APS does not render — evidence Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer` -Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction` -`tsjs.requestAds`. Only (d) — unused in production — renders an APS -descriptor without GAM. +adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d) renders +an APS descriptor without GAM. ### 2.1 Admission -| # | Failure | Where | -| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -| A1 | A configured `[auction].mediator` discards every direct-provider bid; winners come only from the mediator response. APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` | -| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` | -| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` | -| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and the `ts-debug` allowlist exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | +| # | Failure | Where | +| --- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | +| A1 | A configured `[auction].mediator` discards every direct-provider bid; APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` | +| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` | +| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` | +| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and `ts-debug` exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | ### 2.2 Identity -| # | Failure | Where | -| --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` | -| B2 | Two id universes: SSAT keys on the APS bid id, the client adapter on Prebid's generated `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` | +| # | Failure | Where | +| --- | --------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| B1 | GAM caps key-values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge match, no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` | +| B2 | Two id universes: SSAT keys on the APS bid id; the client adapter on Prebid's `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` | ### 2.3 Render -| # | Failure | Where | -| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` | -| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` | -| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` | -| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` | -| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated; the served-through-APS-renderer log is reachable. | `gpt/index.ts:1729` | -| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | -| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` | +| # | Failure | Where | +| --- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` | +| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` | +| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` | +| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` | +| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated. | `gpt/index.ts:1729` | +| C6 | The renderer CSP can kill creatives after "ready". | `aps.rs:49` | +| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` | ### 2.4 Observability -Zero client→server reporting. Server telemetry marks `is_win=1` at auction -time; a bid that never painted is byte-identical to one that painted. +Zero client→server reporting; a bid that never painted is byte-identical +server-side to one that painted. ### 2.5 Failure → signal mapping (normative) -Each failure maps to a distinct observable **failure class** (not a claim to -distinguish unknowable root causes). The operator query for each row is the -§5.6 canonical view filtered by that row's event/reason or counter. So that -**diagnostic mode really can name every class from one page load** — the -review's objection that A1–A4 are server-side-only — the tester gate also -delivers a **`tsjs.boot.debug` envelope** in the initial HTML (selection -summary + drop summary for the initial auction) and the equivalent gated -`ext.trusted_server.debug` field on page-bids and `/auction` responses; -diagnostic mode mirrors these to the console: - -| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | -| ------- | -------------------------------------------- | ------------------------------------- | ------------------------------- | -| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | -| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | -| A3 | — | `bid_drop{invalid_dimensions, w, h}` | `boot.debug` drop summary | -| A4 | — (fixed by §5.6 itself) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | -| B1/B2 | `bridge_request{matched: false}` | join via trace | console warn | -| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | -| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | console warn | -| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn | -| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn | -| C5 | — (fixed at baseline) | — | — | -| C6 | `runner_failed` + CSP buckets | CSP aggregate counters | console warn | -| C7 | renderer branch emits the full §5.1 sequence | join via trace | debug/warn | - -## 3. The GPT and baseline reality this design must respect - -1. Bootstrap-first hybrid: server-injected ES5 `gpt_bootstrap.js` wins the - sentinel race; the bundle's handoff/initial-load code is dead in +Two distinct gates, precisely separated: the **tester cookie** (explicitly +non-security, `tester_cookie.rs:3`) gates **debug content** — the +`tsjs.boot.debug` envelope and the page-bids/`/auction` +`ext.trusted_server.debug` fields, the same sensitivity class as the +existing tester-gated `ts-debug` HTML comment. The **diagnostic credential** +(§5.3) gates **telemetry volume** (unsampled mode). The tester cookie never +affects sampling; the credential never gates mere content. + +| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | +| ------- | ----------------------------------------- | ------------------------------------- | ------------------------------- | +| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | +| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | +| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary | +| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | +| B1/B2 | `bridge_request{matched:false}` | join via trace | console warn | +| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | +| C2 | `render_fail{renderer_document_no_load}` | route counters (`ts_ops_counters`) | console warn | +| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn | +| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn | +| C5 | — (fixed at baseline) | — | — | +| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn | +| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn | + +## 3. The GPT and baseline reality + +1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead in production. -2. The #922 merge loss: orphan recovery and `updateRender` are gone - (`0dc9b19a9`); bridge impressions double-count. PR #997 is the apparent - replacement. +2. #922 merge loss (`0dc9b19a9`); PR #997 is the apparent replacement. 3. TS refreshes never pass `changeCorrelator: false`. -4. `enableSingleRequest()` is called blind after publisher - `enableServices()`. +4. `enableSingleRequest()` called blind after publisher `enableServices()`. 5. Responsive-resolution ambiguity silently skips slots. -6. Three independent `pubads().refresh` wrappers coordinate via - window-global booleans. -7. **GPT has no request cancellation, no documented per-refresh identity, no - overlapping-completion order.** `slotRenderEnded` means creative code was - injected, not that resources loaded. `responseIdentifier` identifies the - ad **response** — usable for response dedup/drain, never initiation - attribution. -8. **With initial load disabled, `display()` creates no request** — the - subsequent `refresh()` does (`gpt/index.ts:1175`, - `ad_init.test.ts:1201-1263`). Cycle protocols must model physical - requests. -9. The bundle's `slotRenderEnded` registration is gated behind - `!ts.servicesEnabled` (`gpt/index.ts:1091`); G4a needs unconditional - early subscription. -10. **The baseline includes the fourth serial APS fix** (`248fe9558`): (a) - the renderer handshake moved to a **MessageChannel** on the PUC path — - port-transferred nonce message, descriptor over the port (no wildcard - broadcast on that path), exact-key replies, `ports.length` checks, a - one-shot `accepted` latch, port closing (`aps.rs:65-125`, - `aps/render.ts:415-437`) — but the reply still terminates inside the PUC - creative frame, so G4b's kernel-observability gap remains; (b) a - nonempty GAM render can be a **collapsed 1×1 shell**, remediated by - `resizeCollapsedCreativeFrame` (`gpt/index.ts:217`) — a guarded style - mutation of GAM-owned elements; (c) the dead renderer branch was - consolidated (C5); (d) a real-PUC-topology browser test now exists. -11. The bridge already keeps **consumed-id tombstones** for security - (`gpt/index.ts:1527`) — G2's registry rules must preserve that property - across navigations. -12. The current tester cookie is **explicitly not a security control** - (`tester_cookie.rs:3`) — it cannot gate unsampled telemetry (§5.3). +6. Three independent `pubads().refresh` wrappers. +7. **GPT has no cancellation, no per-refresh identity, no completion-order + guarantee**; `slotRenderEnded` = code injected, not resources loaded; + `responseIdentifier` identifies responses only. +8. **`display()` under disabled initial load creates no request — for any + caller.** GPT's behavior is caller-independent (`gpt/index.ts:1175`, + `ad_init.test.ts:1201-1263`); the G4a rules therefore apply to publisher + `display()` exactly as to TS `display()`. +9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled` + (`gpt/index.ts:1091`); G4a needs unconditional early subscription. +10. Baseline fix `248fe9558`: MessageChannel APS-PUC handshake + (`aps.rs:65-125`, `aps/render.ts:415-437`; reply still terminates inside + the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5 + consolidated, real-PUC browser test added. +11. The bridge keeps consumed-id tombstones for security + (`gpt/index.ts:1527`); G2 preserves that across navigations. +12. The tester cookie is not a security control (`tester_cookie.rs:3`). +13. **Fastly constructs application state per request** (`app.rs:146`) — no + cross-request in-memory state may be assumed on that adapter. ## 4. Design gates -### G1 — Trace identity and correlation - -The client-visible auction id is EC-derived (`publisher.rs:3237`) and never -ingested. Initial-HTML auction telemetry is emitted before page JS exists -(`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by -whoever acts first: - -- **Initial navigation (`nav_gen 0`):** the server mints `trace_id` (128-bit - CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows, - and injects it into `tsjs.boot` with the signed authorization (§5.3). -- **Cache-privacy invariant:** traces/authorizations are injected only into - responses that ran a per-request auction; such HTML is - `Cache-Control: private, no-store`, no validators. By construction + test. -- **SPA navigations:** `/_ts/page-bids` stays GET; the client mints - `trace_id`, sends it in `X-TSJS-Trace-Id`; the server records it and the - response echoes the trace + its authorization. -- **Direct `/auction` (closing the G4f gap):** the client sends the same - `X-TSJS-Trace-Id` header on the POST (`core/auction.ts:190` gains it); the - server validates, stamps that auction's rows, and echoes - `ext.trusted_server.trace = {trace_id, auth}` in the OpenRTB response — - covering pages whose initial HTML ran no auction. -- **Envelope:** every event carries `{nav_gen, refresh_gen, seq}` inside a - per-trace group `{trace_id, auth, events[]}`. `seq` is per-trace - monotonic. Gaps (loss) and duplicates (fetch/pagehide races) are expected; - §5.5 dedups. -- **Sampling is server-decided for every trace** and carried inside the - signed authorization (§5.3). Traces are navigation-scoped; **impression / - attempt counts are keyed by `(trace_id, nav_gen, refresh_gen, slot)`** - (success criterion 5 uses this key, not "per-impression traces"). +### G1 — Trace identity, sampling, and correlation + +- Client-visible auction id (EC-derived, `publisher.rs:3237`) is never + ingested. Initial-HTML telemetry precedes page JS, so correlation is + minted by whoever acts first: server for `nav_gen 0` (trace + signed + authorization in `tsjs.boot`); client afterwards, via `X-TSJS-Trace-Id` + on page-bids (GET) and on the `/auction` POST, echoed back with the + signed authorization (`ext.trusted_server.trace = {trace_id, auth, +auction_id}` on JSON/OpenRTB responses). +- **Sampling is a deterministic keyed function**, not a coin flip: + `mode = sampled iff HMAC(sampling_key, trace_id) < sample_rate` — so + concurrent requests presenting the same trace always derive the same + mode, and first issuance needs no shared state (Fastly is per-request, + §3.13). +- **Cross-tier join key (closing the row-multiplication gap):** the server + echoes its telemetry `auction_id` to the client (boot / response + extensions); the client stamps `auction_id` on every event of attempts + born from that auction. Server rows additionally carry `release_id`. + Canonical joins run on `(publisher_domain, trace_id, auction_id, slot, +refresh_gen)` at attempt grain; canary/control comparison uses the + server-side `release_id`. +- Cache-privacy invariant: traces/authorizations only in per-request + auction-bearing responses; such HTML is `private, no-store`. +- Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry + `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow` is a closed + field** `ssat | prebid | page_bids | direct | fallback` set by the + attempt owner — the per-flow funnels in the gates table depend on it. +- Traces are navigation-scoped; attempt counts key on + `(trace_id, nav_gen, refresh_gen, slot)`. ### G2 — Render identity -- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte - (`publisher.rs:3355`; PUC fetches `?uuid=`, `gpt/index.ts:1772`). Markup - bids: today's fallback chain. -- **Renderer-only bids:** `hb_adid` = server-minted token `^[a-z0-9]{12}$`, - CSPRNG, collision-retried within the minting auction; cross-auction - uniqueness probabilistic (36¹²; negligible) and harmless via scoping. -- **Registry:** keyed `(trace_id, nav_gen, refresh_gen)`; capacity 64 live - entries per navigation (`registry_full` on refusal); TTL 15 min; one-time - consumption. **Navigation disposal does not erase security state** - (review's tombstone finding): consumed, stale, and disposed ids move into - a bounded **RuntimeSession tombstone set** (cap 256, FIFO, entries retained - until their original TTL) — the bridge's TS-reserved check consults live - registry **and** tombstones, so a late prior-navigation request is still - suppressed and refused, never released to native Prebid. This preserves - the baseline's existing consumed-id tombstone behavior - (`gpt/index.ts:1527`). -- The client-Prebid path keeps Prebid's `adId`; both paths share the one - registry. Non-APS cache-path regression tests. - -### G3 — Runtime ABI under the IIFE build (exact-release) - -IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`) -means imports never share state across bundles (live defect: -`core/context.ts:11` vs `permutive/index.ts:102`). - -- Kernel ships only in `tsjs-core`; publishes - `tsjs._internal = { release_id, registry }` once; freezes after boot; - constructs and registers core services during boot. -- **Exact release matching:** every registration carries `release_id` - (plugins via the object-form API, §7.6, whose `release` field is a - build-generated constant); `registry.get(name)` succeeds only on equality; - mismatch quarantines with `abi_mismatch` / `bundle_partial` and a console - error. -- Stateful access only via the registry at call time; stateless helpers may - inline. Boundary enforcement is **two lint rules**: `import/no-restricted- -paths` for layering **and** `no-restricted-globals` forbidding - `window.googletag` / `window.pbjs` outside `adapters/` (import paths alone - cannot enforce §7.1). +- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte. Markup bids: + existing fallback chain. Renderer-only bids: server-minted token + `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min, + one-time consumption. +- **Registry + tombstones, one capacity, no unexpired eviction:** the + bridge-reservation store holds live registrations and tombstones + (consumed / stale / navigation-disposed ids) in one bounded structure — + **capacity 320 for the union**; expired entries are pruned; **unexpired + entries are never evicted**; when the union is at capacity, **new + registration is refused** with `registry_full`. A late prior-navigation + bridge request therefore always meets suppression until its id's original + TTL passes (preserving `gpt/index.ts:1527`). Test: >320 registrations, + then a late request for the oldest unexpired id. +- Client-Prebid keeps Prebid's `adId`; one store serves both paths. + +### G3 — Runtime ABI (exact-release) + +- Kernel only in `tsjs-core`; publishes + `tsjs._internal = {release_id, registry}`; frozen after boot; core + services constructed at boot; plugins register via the object-form API + (§7.6) whose `release` is a build-generated constant; `registry.get` + succeeds only on `release_id` equality; mismatch quarantines + (`abi_mismatch`/`bundle_partial`) with a console error. +- Boundary enforcement: `import/no-restricted-paths` for layering plus + **`no-restricted-properties`/`no-restricted-syntax`** rules covering + `window`, `globalThis`, `self`, and local aliases for `googletag`/`pbjs` + access outside `adapters/` (`no-restricted-globals` cannot catch member + expressions). Adapters are the only access to **external ad-tech + globals**; the kernel and messaging module necessarily touch + `window.tsjs`, listeners, and `postMessage`. ### G4 — Render lifecycle -**G4a — Physical request-cycle protocol.** - -- **Intents, both classes, one causal queue.** Every observable initiation — - TS `display()`/`refresh()` and wrapped publisher entries — records an - intent in causal order, classified `ts | publisher`. A TS `display()` - issued while initial load is disabled is **known at call time to produce - no request** and is retired immediately as bookkeeping (it never enters - the matcher) — closing the review's misattribution case where a publisher - `refresh()` inside the 2 s window would have been consumed by a stale TS - intent. TS intents that _may_ produce no request only in hindsight - (`refresh()` on a never-displayed adopted slot) expire at 2 s with - `intent_no_request`; **if any publisher intent is recorded for the slot - while such a TS intent is pending, the next `slotRequested` is ambiguous - and the slot quarantines** — a zero-request TS intent can never silently - win FIFO matching. (Exact test in §9.) -- **Cycles:** opened only by `slotRequested`, matched to the head of the - causal intent queue; SRA batching yields one `slotRequested` per slot per - batch. A cycle closes on its `slotRenderEnded`; `responseIdentifier` - deduplicates responses during drain. -- **Serialization:** at most one outstanding TS cycle per slot; one queued - TS replacement (later intents coalesce). -- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS - cycle is outstanding and no publisher/untracked request overlaps. - Overlap → quarantine (`cycle_unattributable`, fail closed). -- **Drain/re-arm (no timeout re-arm).** Physical cycle and drain state live - in the RuntimeSession slot record; **unissued intents are - NavigationSession children** and are cancelled by navigation disposal. A - quarantined or stale slot re-arms only on: count-based drain (every - outstanding request/render pair matched), safe TS-owned slot destruction - and redefinition, or page end. **A timeout emits a diagnostic and never - restores attribution** — the 60 s bound from revision 5 is removed - because an old `slotRenderEnded` arriving after re-arm would be - indistinguishable from a new cycle. Late stale events are matched and - discarded (`stale_navigation`). -- CI exercises the protocol on the deterministic harness; a release-gating - **real-GAM overlap test** (publisher refresh racing a TS cycle; - initial-load-disabled formation) validates it against actual GPT. - -**G4b — Acknowledgement protocol (on the baseline port transport).** Since -`248fe9558` the frame pair speaks MessageChannel (parent-postMessage with -`ports.length === 0`, or transferred port with `ports.length === 1`; -exact-key replies; one-shot `accepted` latch; port closed after reply — -`aps.rs:65-125`, `aps/render.ts:415-437`). Adopted as the contract of record -within the frame pair. The kernel-observability gap remains (the PUC-flow -reply resolves inside the creative frame; callbacks fire on send, -`gpt/index.ts:1632-1760`). Contract — **three authenticated messages per -attempt**, each carrying the per-attempt 128-bit CSPRNG nonce from the -bridge response: - -1. `renderer_document_loaded` — posted to the top window after the document - validates the descriptor and nonce (this is §6.6's first stage, which - revision 5's two-message protocol omitted); -2. the port reply to its frame-pair peer (baseline behavior, unchanged); -3. `render_accepted` / `render_failed{reason}` — posted to the top window. - -The kernel validates, in order: source ownership (§6.8 walk), nonce, token, -`nav_gen`, `refresh_gen` — before any state transition or notification. The -one-shot latch + port close mean a re-render is a fresh document instance -with a fresh nonce. Pinned for SSAT, client-Prebid, and nested SafeFrame, -including stale/replayed acks and acks after navigation disposal. - -**G4c — Honest observations.** Inline-adm frames are sandboxed `srcdoc` -without `allow-same-origin` (`gpt/index.ts:510`) — opaque; geometry proves -nothing (the shell dimensions are assigned by our own code). Observations: -`gam_nonempty`, `gam_empty`, **`gam_collapsed`** (nonempty render whose -shell computes ≤ 1px — the baseline's discovery), `renderer_document_loaded`, -`runner_loaded`, `runner_failed`, `adm_document_loaded`. Every path -terminates at `render_accepted`; **no observation claims paint**; there is -no `render_confirmed`. The baseline's `resizeCollapsedCreativeFrame` -(`gpt/index.ts:217`) is adopted as a **sanctioned, guarded exception** to -the no-foreign-DOM-mutation rule (authenticated source frame only; wrapper -only when both dimensions ≤ 1px; anchor-ad `ins[data-anchor-status]` and -fixed/sticky guards) and emits `gam_collapsed` when it acts. - -**G4d — Win/billing notifications.** APS carries neither `nurl` nor `burl` -by design (`aps.rs:839`; the AAX envelope excludes them; the integration -guide documents no generic APS beacons) — APS billing lives in the Amazon -runner lifecycle, unchanged, and **APS is excluded from everything below**. - -For carrying paths (PBS and other OpenRTB providers): bind is per flow and -never selection or targeting (`ad_init.test.ts:1824` pins that): - -- GAM/PUC: an owned, slot-and-ad-id-matched bridge claim. -- Direct `/auction`: validated render start. **This requires server and - client work the current code lacks**: `/auction` response conversion must - preserve `nurl`/`burl` with server-side macro expansion - (`formats.rs:423` omits them today) and the client parser must carry and - https-validate them (`core/auction.ts:43` drops them today). -- Fallback: attributed `gam_empty`, immediately before fallback render. - -`nurl` at bind; `burl` at `render_accepted`; attempt-scoped idempotency key -`(trace_id, nav_gen, slot, refresh_gen, hb_adid)`; `sendBeacon`/no-cors -fetch; no retries. Post-acceptance terminal failure emits the dedicated -**`billing_outcome{billed_then_failed}`** event (§5.1) — it is not a -`render_fail` reason. - -**G4e — Fallback trigger.** Opt-in -(`[auction].client_render_fallback = "renderer"`); renders only after a -terminal `gam_empty` unambiguously attributed to a TS cycle; ownership does -not gate it; publisher-initiated or unattributable cycles never trigger it; -timeouts never render. - -**G4f — Direct `/auction` lifecycle.** The non-GPT path -(`core/request.ts:52`) gets: a `RenderAttempt` keyed -`(trace_id, nav_gen, refresh_gen, slot)` with `refresh_gen` incremented per -`requestAds` invocation touching the slot; **per-slot serialization with -latest-wins cancellation** — concurrent calls for the same slot cancel the -older attempt, and every DOM or beacon side effect re-checks its attempt -generation first, so a reversed-arrival response can never replace a newer -creative or start a second economic lifecycle (`request.ts:31` currently -races); G4b acknowledgement validation; G4d direct-flow binds; disposal on -navigation; exactly-once terminal state; the full §5.1 event sequence. -`tsjs.requestAds(options)` returns -`Promise` where -`RequestAdsResult = { traceId, slots: Array<{ slot, outcome: "rendered" | -"no_bid" | "failed" | "cancelled", reason? }> }`, settling when every slot -attempt reaches a terminal state. Reversed-response tests required. +**G4a — Physical request cycles.** + +- Intents recorded for both classes (`ts | publisher`) in one causal queue. + **Any `display()` issued while initial load is disabled is retired at + issuance regardless of caller** (GPT's no-request behavior is + caller-independent, §3.8) — a stale publisher `display()` intent can no + more poison a later TS `refresh()` match than the reverse. Hindsight + zero-request intents (`refresh()` on a never-displayed slot) expire at + 2 s with `intent_no_request`; while one is pending, any opposite-class + intent makes the next `slotRequested` ambiguous → quarantine. The + ambiguity rule is symmetric. +- Cycles open only on `slotRequested`, matched to the causal queue head; + SRA yields one per slot per batch; cycles close on `slotRenderEnded`; + `responseIdentifier` dedups during drain. +- One outstanding TS cycle per slot; one queued replacement (coalescing). +- Attribution requires exactly one outstanding TS cycle and no overlap; + otherwise quarantine (`cycle_unattributable`), fail closed. +- **No timeout re-arm.** Re-arm only on count-based drain, safe TS-owned + destroy/redefine, or page end. Unissued intents are NavigationSession + children; physical cycle/drain state is RuntimeSession. +- Deterministic-harness CI plus the release-gating real-GAM suite (scope + enumerated in the gates table: per-flow topologies, expected sequences, + browsers, fixtures, commands, artifacts, approvals — success criterion 1 + refers to that enumeration). + +**G4b — Acknowledgement, specified per render path.** Four normative +sequences; each names its nonce/token producer, transport into the owned +frame, authenticated acceptance observation, cancellation, and separate +document/runner deadlines (document 3 s, runner 10 s, adm 5 s): + +1. **APS-PUC** (baseline transport): bridge mints the per-attempt 128-bit + nonce; MessageChannel into the renderer document (`ports.length` + checks, exact-key replies, one-shot latch, port close); the document + posts authenticated `renderer_document_loaded` then + `render_accepted | render_failed{reason}` **to the top window**; the + kernel validates source ownership, nonce, token, `nav_gen`, + `refresh_gen` before transitions or notifications. +2. **Generic ADM/cache-PUC**: the bridge's display renderer creates the + sandboxed adm frame with an injected reporter snippet; the reporter + posts authenticated `adm_document_loaded{nonce}` to the top window on + document load; acceptance = that message (baseline merely appends an + iframe with no observation). +3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent — + the baseline parent-postMessage handshake (`ports.length === 0` branch) + is already kernel-observed; same three messages, same validation. +4. **Direct ADM/cache**: as (2), with the kernel as parent. + +Cancellation for all four: navigation/supersession invalidates the nonce; +late acks are discarded with `stale_navigation`. + +**G4c — Honest observations; one terminal state.** Observations: +`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded, +reason}` (observation and remediation are separate — a guarded anchor/fixed +case is still observed), `renderer_document_loaded`, `runner_loaded`, +`runner_failed`, `adm_document_loaded`. **An attempt has exactly one +terminal state: `accepted | failed{reason} | no_bid | cancelled`.** +Post-acceptance runner failure is an observation plus the +`billing_outcome{billed_then_failed}` event — never a second terminal +transition. No observation claims paint. The baseline resize is a +sanctioned, guarded exception to the no-foreign-DOM-mutation rule. + +**G4d — Notifications.** APS carries neither `nurl` nor `burl` +(`aps.rs:839`); excluded entirely. For carrying paths: bind per flow — PUC: +owned, slot-and-ad-id-matched bridge claim; direct: validated render start +(server must preserve + macro-expand `nurl`/`burl` in `/auction` +responses — `formats.rs:423` omits them — and the client must parse and +https-validate them — `core/auction.ts:43` drops them); fallback: +attributed `gam_empty` immediately before render. `nurl` at bind, `burl` +at `accepted`. **Economic identity is the normalized pair +`(id_kind, id_value)`** (direct attempts without `hb_adid` use +`bid_id`), idempotency key +`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. **Every +dispatch emits `notification_sent{kind: nurl|burl, id_key_hash, result: +queued | failed}`** where `id_key_hash` is a 16-hex truncated HMAC of the +idempotency key — making the duplicate-`burl` invariant observable in +production (the gates table queries zero duplicates per key hash); external +billing reconciliation remains the authoritative backstop. No retries. + +**G4e — Fallback.** Opt-in; renders only on a terminal `gam_empty` +unambiguously attributed to a TS cycle; ownership does not gate; +publisher-initiated or unattributable never triggers; timeouts never +render. + +**G4f — Direct `/auction` lifecycle.** `RenderAttempt` keyed +`(trace_id, nav_gen, refresh_gen, slot)`; per-slot latest-wins with +cancellation; generation checks before every DOM/beacon effect +(`request.ts:31` races today); G4b sequence 3/4; G4d direct binds; +disposal on navigation; single terminal state; +`tsjs.requestAds(options): Promise` with +`RequestAdsResult = {traceId, slots: [{slot, outcome: "rendered" | +"no_bid" | "failed" | "cancelled", reason?}]}`. + +**G4g — Mid-attempt configuration.** An attempt **snapshots its +configuration at creation**. The in-pool emergency kill switch cancels +attempts that have not yet passed their commit point — defined as +`bridge_response_sent` (PUC flows) or first DOM insertion (direct/fallback +flows); attempts past commit run to their terminal state; dispatched +notifications are never recalled. ### G5 — Deployment contracts -- Config `format_version` exact-match; rollback by redeploy. -- Assets: hash-in-pathname; embedded only; unknown hash 410 `no-store`; - `Cache-Control: public, max-age=31536000, immutable` on exact matches - (`immutable` alone carries no lifetime). **Concatenation is materialized - and cached at application-state construction from the validated - configured module vector** — not per request (`bundle.rs:23` today), and - not a build-time-only set, since enabled vectors are runtime - configuration; an unlisted vector is a startup error. -- Internal route families (renderer, client-events, CSP reports): dispatch - before auth/EC/publisher/integration filters (Fastly today runs EC setup - and pre-route filters first, `app.rs:709`); all methods and version - prefixes reserved locally (405 + `Allow` + `no-store`; unknown version - 404 `no-store`; never the publisher fall-through in `adapter-spin -app.rs:804`); no body/cookie/authorization forwarding; origins compared - as normalized scheme+host+port. -- Ingest routes exist in all four adapters; Fastly has the real sink; the - others accept-count-drop (OQ5 drives their gates). +- Config `format_version`; release-time config (§0). +- **Assets pre-materialized at release publication:** because config ships + with the release, the validated module vectors are known when the release + is built — concatenated bytes + hashes are produced then and embedded; + serving is lookup-only on every adapter (Fastly's per-request state, + §3.13, makes construction-time caching meaningless there — the previous + revision's claim is corrected). The §7.10 server benchmark measures the + lookup path and guards against regression to per-request concatenation. + Unknown vector = release-build error; unknown hash = `410`, `no-store`; + exact match = `public, max-age=31536000, immutable`. +- **Internal route families — now four:** renderer, client-events, + CSP-report, **and `/_ts/trace-auth`** — dispatch before auth/EC/ + publisher/integration filters; all methods reserved locally (405 + + `Allow` + `no-store`; unknown versions 404 `no-store`; never publisher + fall-through); no body/cookie/authorization forwarding; normalized + scheme+host+port origin comparison; each family rate-limited (§5.4) and + covered by four-adapter parity tests. - §5.6 schemas deploy and validate before writers enable. ## 5. Observability -### 5.1 Wire payload and per-event field matrix +### 5.1 Wire payload and field matrix ``` -{ v: 1, traces: [ - { trace_id, auth, events: [ { nav_gen, refresh_gen, seq, t, ...fields } ] } -] } +{ v: 1, traces: [ { trace_id, auth, events: [ + { nav_gen, refresh_gen, seq, flow, auction_id?, t, ...fields } ] } ] } ``` -Event types and their fields (closed enums; a field absent from a row is -absent from the wire and NULL in storage): - -| `t` | fields | -| -------------------------- | ------------------------------------ | -| `bid_received` | slot, id_kind, source | -| `targeting_set` | slot, id_kind | -| `bridge_request` | slot, id_kind, matched | -| `bridge_response_sent` | slot, source | -| `render_attempt` | slot, source | -| `render_accepted` | slot, source | -| `render_fail` | slot, source, reason | -| `gam_nonempty` | slot | -| `gam_empty` | slot | -| `gam_collapsed` | slot | -| `renderer_document_loaded` | slot | -| `runner_loaded` | slot | -| `runner_failed` | slot, reason | -| `adm_document_loaded` | slot | -| `fallback_start` | slot | -| `billing_outcome` | slot, outcome (`billed_then_failed`) | -| `client_queue_overflow` | dropped (count) | - -`slot` is a configured slot id or `s`; `id_kind` ∈ -`cache_uuid | render_token | prebid_adid | bid_id | none`; `source` ∈ -`renderer | adm | pbs-cache | gam`. Reason enum: -`renderer_document_no_load`, `runner_no_load`, `runner_failed`, -`descriptor_invalid`, `invalid_dimensions`, `dimensions_out_of_range`, -`bridge_id_mismatch`, `cycle_unattributable`, `intent_no_request`, -`stale_navigation`, `bridge_claim_timeout`, `gam_empty`, -`no_render_source`, `slot_unresolved`, `gpt_absent`, `pbjs_absent`, -`bundle_partial`, `fallback_cancelled`, `abi_mismatch`, `registry_full`. -Queue overflow is its own event (`client_queue_overflow`), never a -`render_fail` — failure denominators stay clean. The payload carries **no -client timestamp**; the server stamps `received_at`, and ordering within a -trace is `seq`. - -### 5.2 Transport - -`fetch(..., {keepalive: true, credentials: "omit"})` primary; `pagehide` -fallback `navigator.sendBeacon(url, new Blob([json], {type: -"application/json"}))`. Flush every 5 s and on `visibilitychange`/ -`pagehide`. Client queue bound 256 events; overflow drops oldest and emits -`client_queue_overflow{dropped}`. +| `t` | fields (absent = absent on wire, NULL in storage) | +| -------------------------- | ------------------------------------------------- | +| `bid_received` | slot, id_kind, source | +| `targeting_set` | slot, id_kind | +| `bridge_request` | slot, id_kind, matched | +| `bridge_response_sent` | slot, source | +| `render_attempt` | slot, source | +| `render_accepted` | slot, source | +| `render_fail` | slot, reason, source? | +| `gam_nonempty` | slot | +| `gam_empty` | slot | +| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | +| `renderer_document_loaded` | slot | +| `runner_loaded` | slot | +| `runner_failed` | slot, reason | +| `adm_document_loaded` | slot | +| `fallback_start` | slot | +| `billing_outcome` | slot, outcome (`billed_then_failed`) | +| `notification_sent` | slot, kind (`nurl`\|`burl`), id_key_hash, result | +| `client_queue_overflow` | dropped (count) | +| `heartbeat` | probe_id, expected_seq | + +`source` is **nullable on `render_fail`**: absent for pre-source reasons +(`gpt_absent`, `pbjs_absent`, `slot_unresolved`, `intent_no_request`, +`abi_mismatch`, `registry_full`, `bundle_partial`); required for +source-specific reasons — the per-reason validity matrix is part of the +generated schema. Reason enum as revision 6 plus `currency_mismatch`. +**Heartbeats** are sent by identified probes under mode `probe` (§5.3): +excluded from every product metric by mode, never sampled out, and the +canonical freshness/loss query counts `expected_seq` gaps. + +### 5.2 Transport and overflow + +`fetch keepalive credentials:"omit"` primary; `sendBeacon(url, +new Blob([json], {type: "application/json"}))` on `pagehide`. Queue bound 256. **Overflow never enqueues into the full queue**: an out-of-band +saturating counter accumulates drops, and one coalesced +`client_queue_overflow{dropped}` is materialized into the **next flush**. ### 5.3 Signed trace authorization -Format `v1....` — **`auth` has its own ingest bound of -256 bytes** (it cannot fit the general 64-char string cap; every other -string keeps 64): - -- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform secret - store; keys ≥ 256-bit CSPRNG; **previous keys are retained at least - 24 hours** (≫ max token lifetime + skew); missing key at startup with the - beacon enabled = startup failure. -- `exp`: canonical decimal unix seconds (no sign, no leading zeros); ±60 s - skew; max future 15 min. -- `mode`: `sampled | unsampled | diagnostic`. **`unsampled` is the signed - discard decision** (the review's missing state): the client must not - enqueue or transmit events for an `unsampled` trace, and ingest rejects - any group whose token mode is `unsampled`; the decision is sticky for the - trace (renewals preserve mode). `diagnostic` is a distinct authenticated - mode — **not** gated by the tester cookie, which is explicitly - non-security (`tester_cookie.rs:3`); it requires a separate short-lived - **diagnostic credential** issued behind the existing operator/admin - authentication (`/_ts/admin` surface): HMAC-signed, bound to publisher - origin, expiry ≤ 60 min, revoked by key rotation, issuance - CSRF-protected; forgery/replay tests required. The tester cookie may - still gate cosmetic overlays; never telemetry volume. -- `sig`: base64url, unpadded, of HMAC-SHA-256 (43 chars) over the - domain-separated input - `"ts-trace-auth-v1" || u32be(len(origin)) || origin || -u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode || -u64be(exp)`, all strings UTF-8; constant-time comparison. -- **Renewal for long-lived pages:** before expiry the client calls - same-origin `GET /_ts/trace-auth` with `X-TSJS-Trace-Id`; the server - re-signs the **same trace id and mode** with a fresh `exp` (correlation is - the unchanged trace id). On renewal failure the client stops transmitting - and counts locally — silent rejection at ingest is thereby a bug, not a - policy. -- Ingest verifies per trace group; invalid/expired/unknown-kid → group - dropped-and-counted; other groups survive. - -### 5.4 Ingest contract - -- `POST /_ts/client-events`; `application/json` only; no - `Content-Encoding`; `204`, `no-store`; never echoes input. -- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars except - `auth` ≤ 256 bytes; `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`. -- Same-origin: `Sec-Fetch-Site: same-origin` when present, else normalized - `Origin` equality; absent both → drop-and-count. -- **Rate limiting via an adapter abstraction** (the review is right that a - cross-request in-memory token bucket cannot exist on Fastly, `app.rs:146`): - trait `ClientEventLimiter` with a declared per-adapter backing and - semantics — Fastly: the platform edge counter (`rate_limiter.rs:40`), - fixed 60 s window, limit 20/window (documented approximation of - 10 rpm + burst 20); Axum: real in-process token bucket (10 rpm, burst - 20), map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/per-instance - best-effort with the same parameters. **At capacity, unseen identities - are rejected (drop-and-count); active buckets are never evicted by - churn.** Limiter unavailable/errored → drop early with `204`. Trusted - client address per adapter: Fastly platform client IP; Axum rightmost - `X-Forwarded-For` beyond required `trusted_proxy_hops` (absent → socket - peer only); Cloudflare `CF-Connecting-IP`; Spin platform address. - -### 5.5 Sink, canonical views, and monitoring - -- Stable event key `(publisher_domain, trace_id, seq)`. -- **One named canonical dedup pipe/view per table** (`ts_client_events_v`: - latest `received_at` per key; `ts_render_attempts_v`: attempt-grain - aggregation keyed `(trace_id, nav_gen, refresh_gen, slot)`). **Joins run - at attempt/slot grain against the views, never raw-to-raw** (a raw join on - `(publisher_domain, trace_id)` multiplies rows). Dashboards and alerts - may query only canonical views. -- Field naming matches the existing auction rows: **`publisher_domain`**. -- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) and - cannot see downstream rejection — **datasource-side monitoring is - mandatory**, driven by **sequence-tagged synthetic heartbeats** from a - probe client (accepted rows cannot reveal rejected rows): heartbeat gaps - measure rejection; heartbeat lag measures freshness. Alert owner: the - release owner's on-call. +`v1....`; `auth` ingest bound 256 bytes; kid +`^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store, previous +keys retained ≥ 24 h; canonical decimal `exp`, ±60 s skew, ≤ 15 min future; +`sig` = unpadded base64url HMAC-SHA-256 over the domain-separated +length-prefixed input (revision 6's exact encoding); constant-time compare; +per-group rejection. + +- **Modes:** `sampled | unsampled | diagnostic | probe`. `unsampled` + transmits nothing and is rejected at ingest if carried. `probe` is + issued only to synthetic monitors (server-side issuance to the probe + runner) and marks heartbeat traffic. +- **Renewal preserves mode by verification, not trust:** `GET +/_ts/trace-auth` presents the **current signed authorization** in + `X-TSJS-Trace-Auth` (plus the trace header); the server verifies the + still-valid token and re-signs the **same trace_id and mode** with fresh + `exp`. The trace id itself carries no mode, and no adapter may rely on + cross-request state (§3.13) — the presented token is the state. Renewal + after expiry fails; the client stops transmitting and counts locally. +- **Diagnostic credential — complete protocol:** issuance `POST +/_ts/admin/diagnostic-credential` under the existing admin + authentication (CSRF: same-origin + custom header required), response + `{credential}` where credential = `d1....`, + absolute expiry ≤ 60 min, HMAC over the publisher origin + expiry, + **replayable short-lived bearer by design** (bounded by expiry and + origin binding; not one-time — stated, not implied). Transport to the + page: the operator opens the page with a `#tsdiag=` fragment + (never sent to any server in a URL); the client stores it in + `sessionStorage` and presents it in `X-TSJS-Diag` on trace-auth, + page-bids, and `/auction` requests. The server, seeing a valid + credential, issues/renews the trace authorization with + `mode = diagnostic` and **`exp = min(now + 15 min, +credential expiry)`** — a trace authorization never outlives the + credential. Initial HTML cannot see the fragment, so `nav_gen 0` starts + `sampled|unsampled` and the client immediately upgrades via trace-auth. + Validation is stateless HMAC — all four adapters support it. Forgery, + replay-past-expiry, and wrong-origin tests required. The tester cookie + remains content-only (§2.5). +- **Lazy cached initialization applies to every secret-backed component** + (trace-auth keys, diagnostic keys, sampling key, sinks): first-use + resolution with a cached result on request-bound platforms; resolution + failure with the feature enabled → the feature's startup/first-use error + path, never silent. + +### 5.4 Ingest and rate limiting + +As revision 6 (limits, same-origin, fail-closed drops), with the limiter +contract completed: key namespace per route family; portable maps hold +≤ 65,536 (Axum) / 4,096 (Cloudflare, Spin per-instance) entries with +10-minute entry TTL, cleanup on access plus periodic sweep — **capacity +pressure rejects unseen identities but expired entries are always +reclaimable, so saturation is bounded, not permanent**; missing client +address → a shared `unknown` bucket at 1 request/min; Fastly uses the +platform 60 s window counter at limit 20 with documented overshoot ≤ 2× +under concurrent bursts (`rate_limiter.rs:40` is read-then-increment); +Axum XFF selection = rightmost entry after skipping exactly +`trusted_proxy_hops`. `/_ts/trace-auth` and `/_ts/csp-reports/` +get their own buckets (10/min, burst 20 intent). + +### 5.5 Sink, canonical views, monitoring + +Stable event key `(publisher_domain, trace_id, seq)`; canonical views +`ts_client_events_v` (dedup) and `ts_render_attempts_v` (attempt grain, +joined on the G1 key including `auction_id`); dashboards/alerts query views +only. Datasource-side monitoring via `heartbeat` events from identified +probes (mode `probe`); freshness = heartbeat lag ≤ 5 min, loss = +`expected_seq` gaps < 0.1%; alert owner: release owner's on-call. ### 5.6 Physical schemas (deployed before writers) -- **`ts_client_events`**: `received_at DateTime64, publisher_domain -LowCardinality(String), release_id String, trace_id FixedString(32), -mode Enum(sampled|diagnostic), nav_gen UInt32, refresh_gen UInt32, -seq UInt32, event Enum(§5.1), slot Nullable(String), id_kind -Nullable(Enum), matched Nullable(UInt8), source Nullable(Enum), reason -Nullable(Enum), outcome Nullable(Enum), dropped Nullable(UInt32)`. - Sorting key `(publisher_domain, received_at, trace_id, seq)`; TTL - 30 days; own ingest token; sink batch cap 512 rows; startup validation of - dataset + token when enabled ("startup" on request-bound platforms such - as Cloudflare means first-request lazy initialization with a cached - result); sink-unavailable at runtime → accept-count-drop. -- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`): - add nullable `trace_id`, `mode`; add two bounded row types — **`bid_drop`** - `{provider, slot Nullable, reason Enum, width Nullable(UInt16), height -Nullable(UInt16), count UInt32}` (nullable slot/dimensions for - response-level failures; cap 32 rows/auction + overflow row) and - **`selection_summary`** per slot - `{slot, winner_source Enum(mediator|direct|none), winner_provider, -candidates_direct UInt16, candidates_mediator UInt16, dedup_hits, -currency_rejected, provenance_invalid, mediator_superseded}` (cap 8 - rows/auction + overflow) — §6.1's selection report now has a physical - home. -- **Settings schema (complete):** `[telemetry.client_events]` `enabled`, - `sample_rate` (0–1), `dataset`, `token_secret`; `[telemetry.trace_auth]` - `secret_store`, `active_kid`, `previous_kids = []`; the diagnostic - credential secret alongside. `RuntimeServices` (`platform/types.rs:158`) - gains the client-events sink handle next to the auction sink. -- APS parsing returns structured drop observations - `{reason, slot, width?, height?}` (`aps.rs:722` today loses slot and - values); >8192 → `dimensions_out_of_range`, dimensions omitted. +- **`ts_client_events`**: revision 6 columns plus `flow Enum`, + `auction_id Nullable(FixedString(36))`, `action Nullable(Enum)`, + `kind Nullable(Enum)`, `id_key_hash Nullable(FixedString(16))`, + `result Nullable(Enum)`, `probe_id Nullable(String)`, + `expected_seq Nullable(UInt32)`; `mode Enum(sampled|diagnostic|probe)`. +- **Auction rows**: nullable `trace_id`, `mode`, plus **`release_id`** + (the server-side cohort discriminator). Two added row types with full + physical definitions: + - `bid_drop {provider LowCardinality(String), slot Nullable(String), +reason Enum(AuctionDropReason), width Nullable(UInt16), height +Nullable(UInt16), count UInt32}` — cap 32 rows/auction **plus** one + overflow row (`reason = overflow`, `count` = dropped-row count; the + overflow row is not counted against the cap); + - `selection_summary {slot String, winner_source +Enum(mediator|direct|none), winner_provider Nullable(String) — NULL +exactly when winner_source = none, candidates_direct UInt16, +candidates_mediator UInt16, dedup_hits UInt16, currency_rejected +UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` — cap + 8 rows/auction plus one **auction-level totals row** (slot = + `_totals`) that always survives truncation, so gate denominators never + depend on per-slot rows. Counters are saturating UInt with + `0xFFFF`/`0xFFFFFFFF` as the saturation sentinel. + - **`AuctionDropReason` (closed, server-side):** `script_rendering_ +disabled, invalid_dimensions, dimensions_out_of_range, +missing_render_source, invalid_creative_url, unsupported_tagtype, +render_payload_too_large, unexpected_response_shape, +currency_mismatch, floor_rejected, provenance_invalid, +duplicate_demand, overflow`. +- **`ts_csp_reports`** (aggregate only): `{received_at, release_id, +policy_id, cohort, directive_bucket Enum, source_bucket Enum, count}`; + 30-day TTL. **CSP ingest contract:** pre-buffer body ≤ 8 KiB, ≤ 10 + reports/request, strings ≤ 256, nesting ≤ 4, no `Content-Encoding`, own + limiter bucket, fire-and-forget dispatch covered by probe heartbeats. +- **`ts_ops_counters`**: `{received_at, release_id, counter Enum, value +UInt64}` — the physical home for renderer-route counters (requests, + unknown-version, auth-blocked) and limiter/abuse counters. +- **Settings, constructible:** `[telemetry.client_events]` + `collection_enabled`, `sink_enabled` (collection without a sink = + accept-count-drop by configuration, not accident), `sample_rate`, + `api_host`, `dataset`, `token_secret`, `secret_store`, + `max_body_bytes`; `[telemetry.trace_auth]` `secret_store`, + `active_kid`, `previous_kids`, `sampling_key_secret`; + `[telemetry.diagnostic]` `secret_store`, `active_kid`. + `RuntimeServices` gains the client-events sink handle. +- APS parsing returns structured drop observations (slot + dimensions); + > 8192 → `dimensions_out_of_range`, dimensions omitted. ### 5.7 Modes and SLIs -- **Production (sink-backed only):** server-decided sampling - (`sample_rate`, default 0.10; `sampled` vs `unsampled` signed per trace). - Separated SLIs: **pipeline availability** (heartbeat freshness ≤ 5 min, - heartbeat loss < 0.1%; fails during sink outages, alarmed); **failure - detection** (a failure mode affecting ≥ 1% of sampled render attempts - visible within one hour, evaluated at ≥ 10,000 sampled attempts/hour). -- **Diagnostic:** credential-gated (§5.3), unsampled, full stream, console - mirroring, plus the `boot.debug` / response-`debug` envelopes (§2.5) — one - page load names the failing class for every §2 row. +Production (sink-backed): deterministic keyed sampling (default 0.10). +SLIs: pipeline availability (heartbeat freshness/loss); failure detection +(≥ 1% of sampled render attempts visible within one hour at ≥ 10,000 +sampled attempts/hour). Diagnostic: credential-gated, unsampled, full +stream + console mirroring + debug envelopes (§2.5). ### 5.8 Server-side drop surfacing -Bounded structured summary whenever any bid is dropped; `bid_drop` + -`selection_summary` rows; `ts-debug` comment carries the drop summary; -page-bids and `/auction` carry the gated structured `debug` field. Startup -warnings: APS + `allow_script_creatives = false`; mediator + direct -providers without an explicit `winner_selection` (§6.1 hard error). +As revision 6, with `selection_summary`/`bid_drop` physicalized above. ## 6. APS delivery fixes -### 6.1 Mediation: complete, arrival-independent algorithm - -Current code cannot merge: no forwarded candidate id; lossy -last-write-wins `(provider, slot, bidder)` restoration -(`adserver_mock.rs:95`); arrival-order ties (`orchestrator.rs:827`); Prebid -assumes USD (`prebid.rs:2433`) and APS stamps USD (`aps.rs:475`) with no -configured currency. Replacement, identical in the synchronous and split -dispatch/collect paths via one shared helper: - -1. **Currency.** New required field `[auction].currency` (ISO 4217). Every - provider parse validates its response currency against it (absent - declaration where the provider contract implies one — APS's USD — is - validated as that implied value); mismatch → `bid_drop{currency_mismatch}`. - No conversion. -2. **Candidates.** Every admitted bid — direct **and mediator-native** — - becomes a candidate. Identity is two-part: `source_candidate_id` = - the **intrinsic stable key** `(provider_name, upstream_bid_id)` (for - mediator-native bids: the mediator's provider name and its bid id), and - `candidate_id` = a server-minted opaque wire id (`c` + 11-char CSPRNG) - used **only** for the mediator echo — never for ordering, so response - arrival order cannot influence selection. -3. **Mediator exchange.** Forwarded candidates carry `candidate_id` in the - named wire extension **`ext.trusted_server.candidate_id`** (contract for - every mediator implementation, `adserver_mock` included); the mediator - echoes it on derived bids. Echoed id resolves → the bid is the forwarded - candidate with provenance `mediator`; **authoritative fields:** price - and deal fields come from the mediator (repricing is its job); - render-source fields (renderer, adm, cache coordinates, notification - URLs) come from the stored candidate — a mediator that returns its own - `adm` for an echoed candidate is treated as mediator-native demand - instead. Unresolvable echoed id → the slot **fails closed for merging** - (`mediation_provenance_invalid`; mediator-native bids for the slot still - compete; the claim is discarded and counted). -4. **Floors** filter both populations. **Dedup:** an echoed candidate - removes its direct twin. -5. **Selection order (total, intrinsic):** decoded CPM desc → provenance - rank (mediator first) → `source_candidate_id` asc. Winner fields are - read from the stored candidate per rule 3. -6. **Strategy.** `[auction].winner_selection` is **required** whenever a - mediator and direct providers coexist (startup error if absent): - `mediator_only` or `merge_highest_cpm`. **Timeout behavior is - strategy-specific:** under `merge_highest_cpm`, mediator timeout → - direct-only selection, reported; under `mediator_only`, mediator timeout - → **no winners** (direct bids stay signal-only) unless - `mediator_timeout_fallback = "direct"` is explicitly configured. -7. **Reporting:** the `selection_summary` row (§5.6) per slot. - -Deal priority stays out of scope (the `Bid` model carries no deal identity; -recorded follow-up). - -### 6.2 Dimensions - -Exact size membership stays (`aps.rs:675`). Fix is visibility -(`bid_drop{invalid_dimensions, w, h}`) plus documentation: request the -sizes you accept. - -### 6.3 Script creatives - -Default stays `false`; consequence loud (§5.8); enablement documented. - -### 6.4 Render identity - -As G2 (including tombstones). - -### 6.5 Fallback - -As G4e/G4a; awaitable renderer first; attribution-gated; timeouts never -render. +### 6.1 Mediation — total order, closed contradictions + +1. **Currency.** Required `[auction].currency` (ISO 4217). Providers + validate response currency at parse; providers with a contract-implied + currency validate that implication — **APS enabled with a non-USD + configured currency is a startup error** (`aps.rs:475` stamps USD), not + a silent all-drop. Prebid's parse must validate rather than assume USD + (`prebid.rs:2433`). Mismatch → `bid_drop{currency_mismatch}`. +2. **Candidate identity, total by construction.** `source_candidate_id` = + `(provider_name, upstream_bid_id)` where the upstream id is **required + bounded (≤ 64 chars) and unique per provider response at admission**; + a bid missing an id, or duplicating one, receives a deterministic + **intrinsic fingerprint**: `fp = hex(HMAC(auction_id, provider || slot +|| price_micros || render_source_digest))` — identical fingerprints are + the same demand and dedup to one candidate. `candidate_id` (wire echo) + is CSPRNG with in-auction collision retry and is **never** an ordering + key. +3. **Mediator exchange.** Forwarded candidates carry + `ext.trusted_server.candidate_id`; the mediator echoes it. Resolution + rules, stated exhaustively: an echoed id that resolves → the forwarded + candidate, provenance `mediator`; **price is authoritative from the + mediator; every render-source and notification field comes from the + stored candidate; deal fields are out of scope entirely** (no deal + identity exists in the model — revision 6's "deal fields from mediator" + is deleted). A mediator bid whose **any** render-source field differs + from the stored candidate is reclassified mediator-native. An echoed id + that does **not** resolve → that bid is discarded and counted + (`provenance_invalid`); **mediator-native bids and direct candidates + for the slot all remain eligible** — "fails closed" applies to the + invalid claim, not the slot. +4. Floors filter both populations; echoed candidates remove their direct + twins. +5. **Selection order (total):** decoded CPM desc → provenance rank + (mediator first) → `source_candidate_id` asc (fingerprints compare as + their hex strings). Arrival order can never matter. +6. **Strategy** required when mediator + direct providers coexist: + `mediator_only` (timeout → no winners unless + `mediator_timeout_fallback = "direct"`) or `merge_highest_cpm` + (timeout → direct-only, reported). +7. Reporting: `selection_summary` rows + `_totals` (§5.6). + +### 6.2–6.5 + +As revision 6: exact dimensions with structured drops; script creatives +default-off but loud — **DR-2's output is a deployment decision** (enable +with explicit sandbox/security approval, or accept a quantified maximum +excluded-demand share and gate Phase 3 on it), not a documentation +priority; G2 identity; G4e fallback. ### 6.6 Renderer endpoint -- Route registers unconditionally in every adapter (provider stays - config-gated); startup validation fails if an auth handler pattern covers - it; §G5 isolation rules apply. -- Path `/integrations/aps/renderer/v1`, embedded, served - `Cache-Control: public, max-age=31536000, immutable`; canary versions - are `no-store` (or bounded below the cohort lifetime); a **checked-in - header manifest per renderer version** freezes headers (CSP included) - with the bytes — a version's headers never change after publication, - resolving the immutable-caching/CSP conflict. Unknown versions → 404 - `no-store`. -- Three-message acknowledgement per G4b (`renderer_document_loaded` is the - first authenticated envelope). -- Server route counters are aggregate (the nonce rides the URL fragment). -- **CSP rollout:** discovery on the currently enforced policy with - reporting; tightening via report-only; relaxation via a small enforced - cohort on a short-lived canary version, gated on runner acceptance, CSP - violation rate, and render-failure rate, with a kill switch — and **CSP - reports are advisory**: for opaque-origin reports the body-supplied - document URL and policy version are forgeable, so **policy identity is - encoded in a server-selected report path** - (`POST /_ts/csp-reports/`, ids server-generated per version), - reports are bucketed into closed effective-directive buckets and - `https-host (allowlisted) | data | blob | inline | eval | other` source - buckets with global and per-cohort caps, unused fields discarded before - logging, and CSP data is **never a sole automatic rollback signal**. - Physical storage: aggregate counters only. Browser capture on Chromium, - Firefox, and WebKit (CI is Chromium-only today, - `playwright.config.ts:16`), both report media types - (`application/csp-report`, `application/reports+json`) with separate - validators. +As revision 6 (unconditional route, versioned immutable document with a +checked-in per-version header manifest, three-message ack, aggregate route +counters now physically in `ts_ops_counters`, CSP rollout with +server-selected `policy_id` report paths and closed buckets — ingest +bounds per §5.6). ### 6.7 One descriptor schema -Wire truth is the tagged `BidRenderer` envelope (`types.rs:188-211`). A -wire-schema crate/xtask (no `core → js` cycle; core already depends on the -js crate, `Cargo.toml:45`) generates the JSON-Schema artifact, the TS -structural parser, the ES5 inline validator fragment, and shared fixtures — -checked in, staleness-gated. Semantic checks (URL/origin policy, canonical -base64, bounds, exact one-bid AAX projection, cross-field equality) stay -handwritten. Outer-descriptor tolerance only; AAX projection exact. Shared -positive + adversarial corpus runs through all three validators in CI. +As revision 6 (generated JSON-Schema + TS parser + ES5 inline fragment + +fixtures; semantic validators handwritten; outer tolerance only; the +per-reason `source` validity matrix of §5.1 joins the generated artifacts). ### 6.8 Bridge hardening -Order (normative; preserves the baseline defense that suppresses -propagation before source validation, `gpt/index.ts:1584-1637`): parse → -identify TS-reserved id (live registry **or tombstone**, G2) → -`stopImmediatePropagation()` → validate source ownership via the bounded -walk (known slot-root `WindowProxy` map; sender's parent chain to depth 5; -never scanning the frame tree) → validate nonce/token/`nav_gen`/ -`refresh_gen` → respond or refuse (`bridge_id_mismatch`). Non-TS ids are -untouched. The stolen-token browser test asserts neither TS nor native -Prebid responds; listener-order has a real-browser assertion. Renderer -branches emit the full §5.1 sequence; notifications only on carrying paths. +As revision 6: parse → identify TS-reserved (live **or tombstoned**, G2) → +`stopImmediatePropagation` → validate source (bounded walk) → +nonce/token/`nav_gen`/`refresh_gen` → respond or refuse. Non-TS ids +untouched. Stolen-token test proves neither TS nor native Prebid responds. ## 7. TSJS target architecture ### 7.1 Layering -``` -kernel/ boot, config, queue, event bus, log, beacon, sessions -adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access -services/ slots (registry+handoff), auction client, render engine, consent -integrations/ gpt, prebid, aps, creative, datadome, … -``` +As revision 6, with G3's corrected lint mechanics and the adapter-scope +clarification (external ad-tech globals only). + +### 7.2 Adapters / 7.3 Slot registry + +As revision 6 (registry holds the G4a causal queue; cycle/drain state +RuntimeSession; unissued intents NavigationSession). + +### 7.4 Final global surface + +Revision 6's table **plus** the row the review found missing: + +| Legacy surface | Final shape | +| -------------- | ----------------------------------------------------- | +| (new, public) | `tsjs.definePlugin({id, release, install, dispose?})` | + +Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que ||= +[]; tsjs.boot ||= {}`; the ad-slot script's `window.tsjs = {}` at +`publisher.rs:3665` is fixed); **transactional ownership**: states +`unclaimed → installing → kernel | fallback`. The kernel installs wrappers +**inert** and flips them live at a single commit point; on a throw before +commit it unwinds its registered disposers (the §7.6 machinery applied to +kernel boot itself) and marks `failed`; the 10 s watchdog treats a stuck +`installing` as failed; **fallback claims ownership only from +`unclaimed | failed`** — never beside a committed kernel. A bundle +arriving after fallback committed defers for the page (`bundle_partial`). +Tests: throw injected after each boot checkpoint. + +### 7.5 Messaging / 7.6 Plugins and sessions / 7.7 Bootstrap / 7.8 GPT / 7.9 Decomposition + +As revision 6 (plugin API object form with `release`; transactional +install; sessions; generated no-bundle fallback; unconditional GPT +subscriptions; decomposition table). + +### 7.10 Performance (fully reproducible) -Enforced by the two G3 lint rules. Dissolves the audited inversions -(`core/auction.ts`/`core/request.ts` → `integrations/aps/render`; -`gpt`/`prebid` → `aps`; `prebid` owning the GPT refresh wrapper). - -### 7.2 Adapters - -`present | pending | timed_out` per external global; `timed_out` -non-terminal; queued operations carry their own timeouts and expire with -disposition reasons. - -### 7.3 Slot registry service - -Kernel-owned; `WeakMap` + div-id index; -ownership, adoption, handoff claims, responsive resolution, the G4a causal -intent queue + cycle/drain state (RuntimeSession) and unissued intents -(NavigationSession), targeting history. No expandos -(`__tsRenderGeneration`/`__tsRenderBid` deleted). - -### 7.4 Final global surface (hard cutover) - -| Legacy surface (removed at cutover) | Final shape | -| --------------------------------------- | --------------------------------------------------------------------- | -| `window.tsjs.que` | `window.tsjs.que` — unchanged | -| `globalThis.tscreative` | `tsjs.creative.*` | -| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | -| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) | -| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` | -| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | -| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | -| `tsjs._internal` | kernel registry (G3), frozen after boot | - -**Bootstrap correctness (closing the review's two holes):** every -server-injected initializer creates the container **idempotently and -field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}` — -never only-when-absent (today the first ad-slot script does -`window.tsjs = {}`, `publisher.rs:3665`, which would clobber or starve -later `boot` writes). Kernel boot claims an **atomic owner sentinel**; it -consumes `boot`, deep-freezes the retained copy, and deletes one-shot -secrets. The generated no-bundle fallback (§7.7) activates on bundle -`error` **or** a bounded hang watchdog (10 s without kernel boot); if the -fallback has activated and the bundle later arrives, the bundle **defers -for the rest of the page** (logs + `bundle_partial` disposition) — queue -ownership never changes hands mid-page. - -### 7.5 Messaging module - -All `postMessage` through one module: versioned envelopes, name constants, -G4b nonces, §6.8 validation. Minimal module lands in Phase 1; full call-site -migration in Phase 4. - -### 7.6 Plugin lifecycle — transactional — and sessions - -`tsjs.definePlugin({id, release, install, dispose?})` — object form; the -`release` field is the build-generated `release_id` constant (G3 needs it; -revision 5's positional API omitted it). `install(ctx): void | -Promise`: - -- `ctx.signal`; synchronous `ctx.onDispose(fn)`; reverse-order unwind on - throw/reject/abort; per-disposer isolation; disposer registered after - disposal → invoked immediately; pending late registrations capacity 16, - bound 10 s → `bundle_partial`; release mismatch quarantines before - `install`. -- Sessions: `RuntimeSession` (page-lifetime: bridge listener + tombstones, - history hook, pbjs subscriptions, adapters, beacon queue, physical slot - cycle/drain state); `NavigationSession` (trace + authorization + renewal - timer, render attempts, slot aliases, unissued intents, targeting - history); `RenderAttempt` (per G4a cycle / G4f attempt). Enumerable - disposal inventories; navigation disposes NavigationSession children - only. -- No empty `catch`; auction fetch gains timeout + `AbortController`. -- **Console logging retained**: every issue-surfacing condition keeps or - gains a `log.warn` with the beacon's reason code; `debug`-level - delivery/security failures promoted to `warn`. - -### 7.7 Bootstrap - -`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays -recorded calls on install (browser specs cover replay timing); the -no-bundle fallback is **generated from the same TypeScript source**, with -the §7.4 activation/arbitration rules. - -### 7.8 GPT correctness carried with the restructure - -Unconditional early `slotRequested`/`slotRenderEnded` subscription -(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent -recording); restore #922/#997 attribution and orphan recovery; -`changeCorrelator: false` on TS refreshes (configurable); -`enableSingleRequest()` only when services are not already enabled; -ambiguous responsive resolution emits `render_fail{slot_unresolved}`. - -### 7.9 Decomposition targets - -| Today | Target | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | -| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | -| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | -| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` | -| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | - -### 7.10 Performance (reproducible) - -- **Dedicated workflow** on a fixed runner (`runs-on: ubuntu-24.04` - explicitly — browser CI is `ubuntu-latest` today, - `integration-tests.yml:155` — inside a pinned container image digest); - browser = the lockfile-resolved `@playwright/test` build with its browser - revision recorded in the baseline artifact (the manifest is a caret - range today, `browser/package.json:10` — the lockfile + recorded - revision are authoritative); compressors pinned by version in the - container (`gzip -9 -n` for determinism, `brotli -q 11`). -- Bundle budgets: raw/gzip/Brotli for three vectors (minimal, reference, - maximal) vs checked-in baselines (`perf/baselines/*.json`, updates are - reviewed diffs recording image/browser/tool versions); +5% bytes. -- Browser timing: 5 warm-ups discarded, 50 samples, p90 ≤ baseline × 1.10. -- **Server benchmark harness (complete):** workload = concatenation + - hash of the reference vector; 100 warm-up iterations, 1,000 measured; - statistic = median and p90; one-sided gates ≤ baseline × 1.10; variance - policy: 3 consecutive runs must agree within 5% or the result is - inconclusive (rerun, never pass). +Revision 6's pinned workflow, plus the missing definitions: **module +vectors enumerated** — minimal = `[core]`; reference = `[core, creative, +gpt, prebid, datadome]`; maximal = all 13 discovered modules. **Browser +timing marks**: `performance.mark("tsjs:bids-script")` emitted by the +injected bids script and `performance.mark("tsjs:first-display")` emitted +by the adapter wrapper at the first `display()`/`refresh()` dispatch; +metric = duration between marks on the reference fixture page (the +integration-tests reference page), warm HTTP cache, all resources served +locally (no external network); p90 = nearest-rank over 50 samples; +inconclusive (3-run agreement worse than 5%) → one rerun, then fail. +**Maximal-vector peak JS heap ≤ baseline × 1.10.** Server benchmark: the +G5 lookup path (not concatenation), 100 warm-ups, 1,000 iterations, median +and p90, one-sided ≤ baseline × 1.10. ### 7.11 Toolchain -TypeScript floor to the resolved 5.9 line; strictness flags on; dev +TypeScript floor to the resolved 5.9 line. **Release-gating flags, +enumerated:** `strict`, `noUncheckedIndexedAccess`, +`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, +`noImplicitOverride`, `useUnknownInCatchVariables`; CI command: +`npx tsc -p crates/trusted-server-js/lib/tsconfig.json --noEmit`. Dev toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from casual bumps; monthly review. -## 8. Rollout: phases, decision records, and executable gates - -Phases are build milestones inside the §0 single-release model (dark pool → -probe gates → router-weight canary of coherent requests → full weight). - -**Phase 0 decision records** (promoted from open questions; each has an -owner, evidence, a deadline, and an explicit go/no-go): DR-1 mediator -presence in the affected deployment (OQ1 — decides whether §6.1 gates -Phase 3 entry); DR-2 script-creative share (OQ2 — decides the §6.3 -guidance priority); DR-3 #922 vs #997 (OQ4 — decides the Phase 3 work -item); DR-4 mediator candidate-id echo owner and timeline (OQ7 — -`merge_highest_cpm` is config-blocked until delivered); DR-5 non-Fastly -sink decision (OQ5 — splits Phase 2's gates below). - -**Gates are a checked-in table** (`docs/superpowers/specs/rollout-gates.md`, -created in Phase 0) with columns: query/test command, assignment key, -expected positive count, denominator, sample floor, threshold, window, -owner, hold/rollback action. The real-GAM suite's row includes its workflow -name, fixture account and credentials owner, invocation command, artifact -location, retry policy, and required approval evidence. Prose below is the -summary; the table is normative. - -- **Phase 0 — Identity, schemas, toolchain, decisions.** Path-hashed - assets + 410 semantics + construction-time concatenation cache; - `format_version`; §5.6 schemas deployed writer-off; toolchain floors; - dead expando writes deleted; §5.8 drop surfacing; the five decision - records; the gates table itself. -- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry.** G3 - registry; sessions; install manifest; minimal messaging; G4a intent/cycle - records; unconditional GPT subscriptions. -- **Phase 2 — Trace + beacon.** G1 issuance on all three paths (boot, - page-bids, `/auction` extension); §5.3 authorization incl. renewal and - the diagnostic credential; four-adapter ingest; `ts_client_events` - writers on. Gates split per DR-5: **HTTP parity** (all adapters: - routing, limits, 204s, method reservations) vs **persistence** - (sink-backed only: acceptance, dedup-exactly-once, heartbeat freshness). -- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required - `winner_selection` and `[auction].currency`; render token + tombstones; - renderer route + three-message ack + CSP report route; §6.8; G4a–G4f - incl. direct-flow `nurl`/`burl` plumbing; fallback; DR-3's attribution - restoration; correlator + SRA fixes. - _Gate (sticky randomized canary/control cohorts, 24 h, ≥ 10,000 sampled - attempts each; missing telemetry counts as failure):_ **denominator = - all server-observed eligible APS wins**; per-stage rates gated - separately — targeting_set/eligible, bridge_request/targeting_set, - render_accepted/bridge_response_sent, and `cycle_unattributable` rate - < 0.5% (survivorship is thereby visible, not excluded); GAM fill and p95 - latency as **one-sided non-inferiority** (canary not worse than control - by > 2%; improvements pass); billing normalized per attempt and per - thousand attempts vs control; a separate **duplicate-billing invariant** - (zero double `burl` per idempotency key); real-GAM overlap suite green - per its table row. -- **Phase 4 — Structure.** Full layering + both lint rules; plugin - lifecycle; adapters; full slot registry; full messaging migration; final - namespace. - _Gate:_ lints zero exceptions; disposal-inventory leak tests; four-flow - behavioral parity (SSAT, client-Prebid, page-bids, direct). -- **Phase 5 — Decomposition + cutover.** File splits; script-guard - consolidation; bootstrap stub + generated fallback (with error/hang/ - fallback-arbitration tests); **four-flow parity reruns here** (it changed - bootstrap behavior after Phase 4's parity — the review's ordering - point); then the §0 runbook: weight-up, purge, 24 h monitored window, - weight-back rollback. +## 8. Rollout + +Single-release state machine per §0 (sticky-cohort affinity). **The +normative gates table ships with this design as Appendix A**, including +initial thresholds, queries, commands, cohort keys, sample floors, owners, +hold/rollback actions, and the real-GAM suite's operational row. Threshold +changes require reviewed decision records. + +Phases (build milestones inside the one release): + +- **Phase 0 — Identity, schemas, toolchain, decisions.** Release-time + asset materialization; `format_version`; §5.6 schemas writer-off; + toolchain floors; dead expando deletion; drop surfacing; decision + records DR-1..DR-5 (DR-2 now a deployment decision, §6.2–6.5; DR-4 + gates `merge_highest_cpm`; DR-5 splits Phase-2 gates). +- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry, + transactional bootstrap ownership.** +- **Phase 2 — Trace + beacon.** All three issuance paths + renewal + + diagnostic credential + probe mode; four-adapter ingest incl. + `/_ts/trace-auth` in the isolation family; heartbeats. Gates split: + HTTP parity (all adapters) vs persistence (sink-backed). +- **Phase 3 — APS delivery.** As revision 6, plus `notification_sent` + observability and per-flow funnel gating (the `flow` field): expected- + stage tables per flow live in the gates file; denominator = server- + observed eligible APS wins **within sampled/diagnostic traces**; + `cycle_unattributable` gated; one-sided non-inferiority for fill/p95; + duplicate-`burl` invariant via `notification_sent` key hashes. +- **Phase 4 — Structure.** Layering, plugins, adapters, registry, + messaging, namespace; four-flow behavioral parity. +- **Phase 5 — Decomposition + cutover.** File splits; bootstrap stub + + generated fallback (error/hang/arbitration tests); four-flow parity + rerun; **then the full Phase-3 statistical canary/control gate and the + real-GAM suite are repeated on the exact immutable release candidate** + before router weight rises beyond the low-weight canary; then weight-up, + purge, 24 h monitored window. ## 9. Test acceptance matrix -Hermetic CI blocks PRs; the real-GAM suite is release-gating per its gates -row. New/changed rows this revision are marked •. - -| Area | Must cover | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Request cycles | intent-vs-request; • disabled-initial-load `display()` retired at issuance, publisher `refresh()` inside 2 s window attributed to publisher (the exact review case); SRA; `intent_no_request`; overlap quarantine; • no timeout re-arm (drain/destroy/page-end only); stale discard; real-GAM overlap | -| Ack protocol | three-message sequence (• `renderer_document_loaded` envelope); five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed; after-disposal acks | -| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; • prior-navigation ids suppressed via RuntimeSession tombstones after NavigationSession disposal; bounded walk; listener order | -| Render semantics | binds per flow; `burl` at accepted; attempt idempotency; • `billing_outcome{billed_then_failed}` as its own event; accepted-but-blank; • `gam_collapsed` emission + guarded resize (authenticated source only; 1×1 wrapper; anchor/fixed guards) | -| Direct `/auction` | • trace header + response `ext.trusted_server.trace`; • per-slot latest-wins with reversed responses; • generation check before each DOM/beacon effect; • `RequestAdsResult` settlement; • server preserves + expands `nurl`/`burl`, client validates | -| Fallback | attributed `gam_empty` only; publisher-initiated never; timeout never renders; SPA cancellation; flag change mid-attempt | -| Mediation | • `[auction].currency` required + per-provider validation (Prebid parse, APS implied USD); • `ext.trusted_server.candidate_id` echo; • mediator-native candidates ordered by intrinsic key (arrival-order shuffle test); • authoritative-field rules (repricing kept, adm-swap → native); provenance fail-closed; • strategy-specific timeouts; both lifecycles | -| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; capacity → `registry_full`; • tombstone retention to original TTL, cap 256 | -| Trace auth | • auth ≤ 256 B bound accepted, 64-char cap for others; • encoding vectors (kid charset, canonical exp, u32be/u64be length prefixes, unpadded base64url); expiry/skew/max-future; • renewal preserves trace + mode; • previous-key retention ≥ 24 h; rotation; per-group rejection | -| Sampling modes | • signed `unsampled`: client transmits nothing, ingest rejects carried events, stickiness across renewal; • diagnostic requires the operator credential — tester cookie alone must fail; forgery/replay of the credential | -| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; • `client_queue_overflow` not in failure denominators; ingest abuse; sendBeacon Blob | -| Ingest/limits | • per-adapter limiter semantics as declared (Fastly fixed-window approximation, Axum token bucket); • at-capacity rejects unseen identities, never evicts active; fail-closed 204 | -| Internal routes | wrong-method 405 + Allow + no-store on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding | -| CSP | both media types; opaque/null-origin admission; • policy identity from server-selected report path (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; • header manifest per version (immutable headers frozen with bytes) | -| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX | -| Runtime ABI | one kernel; exact-release verdicts; late registration; failure isolation; • object-form `definePlugin` release check | -| Plugins | partial-install unwind; async rejection; abort pending; disposer-after-disposal; isolation | -| Lifecycle | `timed_out → present`; session inventories; • unissued intents cancelled by navigation disposal; boot container idempotent field-wise init (• ad-slot script no longer clobbers); • fallback error/hang activation + late-bundle deferral; final-namespace smoke | -| Delivery | unknown hash 410 no-store; exact-match immutable with full directive; • construction-time vector cache (unlisted vector = startup error); cutover rehearsal | -| Sink/monitoring | • sequence-tagged synthetic heartbeats measure rejection + freshness; • canonical views only (raw-join multiplication test); `publisher_domain` naming | -| Failure injection | Amazon runner redirect/hang/CSP/script error → distinct outcomes; EC/filter failure before renderer dispatch | -| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | -| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; • `boot.debug` + response `debug` gating; diagnostic completeness per §2.5 | - -## 10. Alternatives considered - -1. Patching APS point-failures without telemetry — rejected (four correct - fixes, still no reliable ads). -2. Always direct-render APS — rejected; kept as the attributed-`gam_empty` - fallback. -3. Single module graph now — rejected for this release; successor option. -4. Big-bang rewrite without phases — rejected (thin safety net). -5. Dropping the ES5 bootstrap — rejected (loses the no-bundle guarantee); - generated fallback keeps it. -6. Timeout-triggered fallback rendering — rejected (uncancelable GPT - requests race late fills). -7. Timeout-based quarantine re-arm (revision 5) — removed: it recreated - the stale-event bug it claimed to fix. -8. N/N−1 compatibility machinery (revisions 3–4) — removed by the §0 - policy. - -## 11. Risks - -- Hard-cutover blast radius (accepted; bounded by the §0 runbook). -- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates - `merge_highest_cpm`. -- Notification triggers become a published contract for PBS-path demand. -- Beacon abuse — capped, origin-checked, fail-closed limited, signed - modes, credentialed diagnostics. -- Registry/limiter memory — explicit capacities, TTLs, reject-at-capacity. -- CSP data is advisory — never a sole rollback signal. -- Sink blindness — heartbeat-based datasource monitoring. -- `[auction].currency` and `winner_selection` are new required config in - mediated deployments — a deliberate startup-error class under §0. +Revision 6's matrix stands, with these added/changed rows: + +| Area | Added coverage | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Request cycles | publisher `display()` under disabled initial load retired at issuance; publisher-display → TS-refresh attribution test; symmetric ambiguity | +| Trace auth | renewal presents current token and preserves mode; renewal-after-expiry fails closed; deterministic sampling (same trace → same mode across concurrent requests) | +| Diagnostic | credential issuance under admin auth + CSRF; fragment→sessionStorage transport; upgrade of an initial sampled trace; auth `exp` capped at credential expiry; forgery/wrong-origin | +| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket | +| Affinity | sticky-cohort coherence: new-pool HTML never fetches control-pool assets/APIs (cache-key + routing test); rollback binding prevalidation | +| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; `release_id` cohort attribution | +| Funnels | `flow` field set per path; per-flow expected-stage conformance (SSAT, prebid, page-bids, direct, fallback) | +| Tombstones | union capacity 320; unexpired never evicted; >320 registrations then late oldest-id request suppressed; `registry_full` on refusal | +| Ack per path | all four G4b sequences incl. adm reporter snippet; per-path document/runner deadlines; cancellation on navigation | +| Terminal states | exactly one terminal per attempt; post-accept runner failure emits observation + `billing_outcome`, no second terminal | +| Notifications | `notification_sent` emitted per dispatch; duplicate-key-hash query returns zero in canary; direct-flow `(bid_id)` identity; server preserves/expands `nurl`/`burl`; client validates | +| Bootstrap | inert-install + commit flip; throw after each checkpoint unwinds; watchdog claims only from failed/stuck; fallback-then-late-bundle deferral | +| Overflow | out-of-band counter; coalesced overflow event in next flush; no recursion at full queue | +| Heartbeats | probe mode not sampled out; excluded from product denominators; freshness/loss queries from `expected_seq` | +| CSP ingest | pre-buffer caps (8 KiB / 10 reports / 256 chars / depth 4); both media types; opaque origin; policy-id path identity; aggregate schema rows | +| Mediation | required upstream id bounds; fingerprint fallback determinism under arrival shuffle; duplicate-id dedup; adm-swap reclassification; APS + non-USD startup error | +| Limiter | TTL reclaim under saturation; unknown-address bucket; Fastly overshoot bound; XFF hop selection | +| Perf | marks present; vector contents; heap budget; inconclusive-rerun policy | +| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters | +| Kill switch | pre-commit attempts cancelled; post-commit attempts run to terminal; snapshot semantics | + +## 10. Alternatives / 11. Risks + +As revision 6, plus: **rejected** — per-request Fastly concatenation +(replaced by release-time materialization); trusting client-asserted +sampling on renewal (replaced by token-presentation renewal); FIFO +tombstone eviction (replaced by union capacity with refusal). Risk added: +sticky-cohort routing is new infrastructure the cutover depends on — it is +Phase 0 work and its coherence test is release-gating. ## 12. Success criteria -1. APS creatives render in each configured flow (SSAT, client-Prebid, - page-bids, direct), hermetically and in the release-gating real-GAM - suite. -2. Every §2 failure maps to its §2.5 signal; **diagnostic mode names the - failing class from one page load including A1–A4 via the `boot.debug` / - response-`debug` envelopes**; §5.7 SLIs hold on sink-backed - deployments. -3. Lints (both rules) zero exceptions; stateful sharing only via the - registry; exact-release mismatches quarantine loudly. -4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or - generated. -5. Attempt counts are keyed `(trace_id, nav_gen, refresh_gen, slot)` - (traces stay navigation-scoped); no double counting; orphan recovery - has a non-vacuous test; G4a holds including the no-timeout-re-arm rule. -6. The only TSJS-owned global is `window.tsjs` (§7.4 final shape); no - expandos; legacy names gone at cutover. -7. §7.10 budgets hold on the dedicated pinned workflow. -8. No existing warning lost; issue-surfacing conditions log `warn`+ with - the beacon reason code. -9. TypeScript floor matches resolved 5.9; `prebid.js` pin documented with - the deployed bundle. -10. `nurl`/`burl` only on carrying paths at their G4d binds, idempotent - per attempt; APS fires neither; duplicate-billing invariant holds. -11. Trace-bearing responses are `private, no-store`; authorizations are - per-trace, signed, mode-carrying (`sampled|unsampled|diagnostic`), - renewal-capable; unsampled traces transmit nothing. -12. The cutover runbook rehearsed (weight switch, purge, rollback). +Revision 6's criteria with these corrections: (2) diagnostic completeness +is achieved via the §2.5 gate split (content vs volume); (5) attempt +counts keyed `(trace_id, nav_gen, refresh_gen, slot)`; (10) the +duplicate-`burl` invariant is measured via `notification_sent` key hashes +in production and by hermetic tests, with external billing reconciliation +as backstop; (add 13) the Phase-3 statistical and real-GAM gates pass on +the exact immutable Phase-5 release candidate before weight-up; (add 14) +the Appendix A gates table shipped with this design and every later change +carries a reviewed decision record; (add 15) the baseline APS fix behaviors +are re-implemented in the target architecture with the baseline browser +tests passing unmodified as the conformance pin. ## 13. Open questions -Promoted to Phase 0 decision records: mediator presence (DR-1), -script-creative share (DR-2), #922 vs #997 (DR-3), candidate-id echo owner -(DR-4), non-Fastly sinks (DR-5). Remaining open: does Amazon expose any -creative-completion acknowledgement that could add a post-`render_accepted` -state under a new name (future enhancement)? +Only one remains outside the decision records: does Amazon expose any +creative-completion acknowledgement that could add a +post-`render_accepted` state under a new name (future enhancement)? + +--- + +## Appendix A — Normative rollout gates (initial values) + +Owners are roles: **RO** = release owner, **QA** = QA owner, **OPS** = +release owner's on-call. Assignment key for canary/control = +sticky cohort (`ts-rel`), randomized at HTML request, per §0. All +production queries run against canonical views only (§5.5). "Hold" = +router weight frozen; "Rollback" = weight to previous release + re-purge. +Changing any row requires a reviewed decision record. + +### A.1 Phase gates + +| Phase | Gate | Query / test command | Denominator | Floor | Threshold | Window | Owner | Action | +| ----- | -------------------------- | --------------------------------------------------------------------- | ---------------------------------------------- | ------------- | --------------------------------- | ------ | ----- | -------- | +| 0 | Dark-pool health | probe suite vs dark pool (all four adapters) | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold | +| 0 | Schema validation | synthetic writes to `ts_client_events` + auction rows | synthetic rows | 10,000 | rejection < 0.1% | 24 h | RO | Hold | +| 0 | Asset identity | probe: every manifest hash 200-immutable; unknown hash 410 `no-store` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold | +| 1 | ABI cleanliness | probe pages: `abi_mismatch` + `bundle_partial` counters | probe page loads | 1,000 | 0 | 24 h | QA | Hold | +| 1 | Bootstrap ownership | hermetic: throw-after-each-checkpoint suite | checkpoints | all | 100% unwind-to-`failed` | CI | QA | Hold | +| 2 | Ingest HTTP parity | parity suite vs all four adapters (routes, 405s, limits, 204s) | parity cases | all | 100% | CI | QA | Hold | +| 2 | Persistence (sink-backed) | acceptance ≥ 99%; dedup exactly-once per `(trace, seq)` | probe batches | 10,000 evts | as stated | 24 h | OPS | Hold | +| 2 | Heartbeat pipeline | freshness lag; `expected_seq` loss | probe heartbeats | 1,000 | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | +| 3 | APS funnel (per flow) | per-`flow` stage rates from `ts_render_attempts_v` (table A.2) | eligible APS wins in sampled+diagnostic traces | 10,000/cohort | per A.2 | 24 h | RO | Rollback | +| 3 | Attribution soundness | `cycle_unattributable` rate | attributable-candidate cycles | 10,000 | < 0.5% | 24 h | RO | Rollback | +| 3 | GAM fill (non-inferiority) | canary fill vs control | cohort ad requests | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback | +| 3 | Latency (non-inferiority) | canary p95 bids-to-display vs control | cohort attempts | 10,000 | canary ≤ control × 1.02 | 24 h | RO | Rollback | +| 3 | Billing | GAM/server-side revenue per 1,000 attempts, canary vs control | cohort attempts | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback | +| 3 | Duplicate `burl` | duplicate `notification_sent{burl}` per `id_key_hash` | burl dispatches | 1,000 | 0 | 24 h | RO | Rollback | +| 4 | Layering | both lint rules; disposal-inventory leak suite | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | +| 4 | Four-flow parity | hermetic parity: SSAT, prebid, page-bids, direct | parity cases | all | 100% | CI | QA | Hold | +| 5 | Parity rerun + budgets | four-flow parity; §7.10 budgets on pinned workflow | — | — | 100% / within tolerance | CI | QA | Hold | +| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback | +| 5 | Cutover monitor | §5.7 SLIs post-weight-up | production traffic | — | SLIs green | 24 h | OPS | Rollback | + +Low-volume handling: a production gate that cannot reach its floor within +its window is **inconclusive** — extend the window once; a second +inconclusive result is a Hold, never a pass. + +### A.2 Expected stages per flow (Phase 3 funnel) + +| Flow | Expected sequence | Stage thresholds | +| --------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each stage ≥ 95% of prior; document-load ≥ 99%; runner fail+timeout ≤ 1% | +| prebid | same as ssat (keyed by Prebid `adId`) | same | +| page_bids | same as ssat (after SPA navigation) | same | +| direct | `render_attempt → renderer_document_loaded → render_accepted` (no bridge stages) | document-load ≥ 99%; accepted ≥ 95% of attempts | +| fallback | `gam_empty → fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of fallback starts | + +### A.3 Real-GAM suite (operational row) + +| Field | Value | +| ------------------ | -------------------------------------------------------------------------------------- | +| Workflow | `real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | +| Topologies | one per flow in A.2, plus publisher-overlap and disabled-initial-load formation (G4a) | +| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) | +| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | +| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | +| Command | `npx playwright test --config real-gam.config.ts` from the browser test package | +| Artifact | Playwright HTML report + trace zips, uploaded as workflow artifacts, retained 90 days | +| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | +| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO | From 66f2a976fcb5837bfee1f80841fc3f64f9e89dd5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:51:58 -0700 Subject: [PATCH 549/844] Rewrite design spec self-contained for the seventh review round Revision 8 inlines every normative contract (no revision references remain) and resolves the seventh round: beacon and CSP transports move to credentials same-origin so authenticated sticky-cohort affinity holds for every request class, the ts-rel cookie becomes an opaque HMAC token with attribute/TTL/reassignment rules, config blobs are hash-verified against the deployment manifest at startup, auction_id becomes the cross-tier equality key with generations client-side only, canary/control attribution moves to pool-specific datasource tokens, statistical gates use sampled traces only with one-sided 95 percent confidence bounds on relative differences and per-flow floors (rare flows gate hermetically), Appendix A rows name checked-in pipes, scripts, and workflows with a concrete cutover-monitor threshold and zero-rejection deterministic schema writes, the duplicate-burl invariant becomes hermetic proof plus billing reconciliation with telemetry as a detection alarm, the browser stops computing hashes in favor of a server-minted notif_id, every datasource gets its own probe and freshness query, CSP and ops sinks get complete schemas, settings, and a multi-target sink handle, origin policy becomes per route family, same-class zero-request intents are superseded by any later request-capable intent, the auction client returns a discriminated error result, the bootstrap watchdog aborts and unwinds under an owner generation before fallback claims, flow gains a system value with a generated validity matrix, fallback becomes a child attempt with parent_flow, id-less or duplicate bids are rejected instead of fingerprinted, the drop enum is exhaustive over baseline producers with a compile-time mapping test, the Fastly overshoot claim is withdrawn in favor of documented burst behavior, diagnostic upgrade becomes the sole authenticated mode transition with fragment clearing and in-memory-only credential storage plus pre-upgrade buffering, sampling gets an exact u64 threshold algorithm, totals and overflow rows gain row_kind with nullable fields, the kill-switch commit point moves before the first irreversible action including nurl, billing_outcome is removed for lack of an honest producer, the plugin-level dispose hook is removed in favor of ctx.onDispose, and the TypeScript gate uses a checked-in npm script with --no-install. --- ...s-render-fix-and-tsjs-resilience-design.md | 1690 +++++++++++------ 1 file changed, 1090 insertions(+), 600 deletions(-) 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 001011d91..74e08c867 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 @@ -1,60 +1,83 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 7 — reworked after the sixth review round. +- **Status:** revision 8 — reworked after the seventh review round and made + fully self-contained: no contract in this document is defined by reference + to an earlier revision. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed GAM shells"). All file:line citations refer to this commit. -- **Inputs:** three code audits; design reviews of revisions 1–6; open issues +- **Inputs:** three code audits; design reviews of revisions 1–7; open issues #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997. -- **Normative gates:** the initial rollout-gates table ships as - **Appendix A of this document** (one file, one review surface); changes to - it require reviewed decision records so thresholds cannot be chosen after - observing results. +- **Normative gates:** the initial rollout-gates table is **Appendix A**; + changes require reviewed decision records so thresholds cannot be chosen + after observing results. - **Adoption stance for the baseline APS fixes (`248fe9558`):** this design adopts their **contracts** — the MessageChannel handshake semantics, the collapsed-shell remediation behavior, the consolidated bridge branch — and - **re-implements them inside the target architecture** (the messaging - module owns the channel protocol, the render engine owns the resize, the - rebuilt `render_bridge` module owns the branch). The patch code itself is - not carried forward through the refactor; the baseline's browser tests are - retained as the conformance suite that pins the adopted behavior while the - implementation is replaced. + **re-implements them inside the target architecture** (the messaging module + owns the channel protocol, the render engine owns the resize, the rebuilt + `render_bridge` module owns the branch). The patch code is not carried + forward; the baseline's browser tests are retained unmodified as the + conformance suite pinning the adopted behavior. ## 0. Release policy: coordinated hard cutover One coordinated release: -- Server, TSJS bundles, config, and HTML ship under one **`release_id`**. - **No N/N−1**; in-flight clients may fail at cutover — accepted and stated. -- **Exact release matching**; config `format_version` exact-match; rollback = - redeploy the previous release with its own config. -- **Config is a release-time input** under this policy: the config blob and - binary publish together, so the enabled module vectors are known at - release publication (this powers §G5 asset materialization). -- Assets: embedded only; hashed pathnames for cache identity; unknown hash - → `410`, `no-store`. -- **Rollout state machine with release affinity.** A deployment manifest - binds each pool to immutable `{release_id, config_store, config_key, -config_hash}` (rollback binding prevalidated). The new pool comes up fully - enabled, reachable only by probes. Canarying uses a **sticky cohort - token**: the router assigns `ts-rel=` on the HTML response, - routes every subsequent request (assets, APIs, beacons) by it, and cache - keys include it — router weights alone apply per request and would mix - pools, so affinity is what makes a canary request **coherent** end to end. - Router weight over sticky cohorts is the sole activation primitive; flags - are in-pool emergency kill switches only. Cutover = weight 100% + CDN - purge; rollback = weight back + re-purge. +- Server, TSJS bundles, config, and HTML ship under one **`release_id`** + (git tag / build hash). **No N/N−1**; in-flight clients may fail at + cutover — accepted and stated, not mitigated. +- **Exact release matching**: kernel, services, plugins, and the install + manifest carry the same `release_id`; mismatch is a refusal. +- **Config is a release-time, content-verified input.** The config blob gains + a top-level `format_version` (exact match required). Publish order: blob + first, deployment manifest second. The manifest binds each pool to + immutable `{release_id, config_store, config_key, config_hash}`, and the + binary **verifies the loaded blob's hash against the manifest at + startup** — a mismatch is a startup failure, so a config overwrite cannot + mutate a supposedly immutable release or invalidate pre-materialized asset + vectors. Rollback = redeploy the previous release with its own verified + config; the rollback binding is prevalidated. +- **Assets:** binaries embed only their release's artifacts; hashed pathnames + exist for cache identity; unknown hash → `410 Gone`, `no-store`. +- **Rollout state machine with authenticated release affinity.** The new + pool comes up fully enabled, reachable only by probes. Canarying uses a + **sticky, opaque, authenticated cohort token**: the router sets `ts-rel` + on the HTML response — an HMAC-signed opaque value binding + `{publisher_host, release_id, cohort, exp}` (attributes: `Secure; +HttpOnly; SameSite=Lax; Path=/`; TTL 24 h) — and routes every subsequent + request by the **validated** token; invalid, expired, forged, or + non-allowlisted tokens route to control and are reissued; tokens for + retired releases are reassigned on next HTML response after rollback. + Cache keys use the post-validation release label (bounded cardinality; + raw cookie values never key caches). A plain readable release id would + let any visitor opt into the dark pool and would hand cache-key + cardinality to attackers — hence opaque and authenticated. Because + affinity rides a cookie, **the beacon and CSP-report transports use + `credentials: "same-origin"`** (not `omit`): the cookie exists for the + routing layer only; application handlers still derive no identity from + it. Router weight over sticky cohorts is the sole activation primitive; + flags are in-pool emergency kill switches. Cutover = weight 100% + CDN + purge; rollback = weight back + re-purge. The affinity acceptance test + covers HTML, assets, APIs, **beacons, and CSP reports**. +- **Canary/control discrimination is infrastructure-attributed:** each pool + writes telemetry with **pool-specific datasource tokens**, so cohort + attribution comes from the write identity, not from in-row fields the + control binary (the baseline) does not emit; in-row `release_id` from the + new pool is secondary confirmation. ## 1. Problem statement -APS demand is fully integrated server-side, yet APS creatives do not appear -reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim, the -`hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived -review; the pattern is the finding: **multiple independent failure points, -most failing silently**, with no client→server signal about which fired. -The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths, -duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches) -is the same problem structurally. +APS demand is fully integrated server-side — the edge runs the APS OpenRTB +auction, wins bids, and ships a typed renderer descriptor — yet APS +creatives do not appear reliably. Four serial fixes (the `bid.meta` +carrier, the decoupled shim, the `hb_adid` fallback, the baseline +PUC/collapsed-shell fix) each survived review; the pattern is the finding: +**multiple independent failure points, most failing silently**, with no +client→server signal about which fired. The TSJS library (56 files, +~11,900 lines, two ~1,800-line monoliths, duplicated ES5/TS logic, +inverted layering, ~100 error-swallowing catches) is the same problem +structurally. ### Non-goals @@ -66,8 +89,8 @@ is the same problem structurally. ## 2. Why APS does not render — evidence Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer` -adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d) renders -an APS descriptor without GAM. +Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d) +renders an APS descriptor without GAM. ### 2.1 Admission @@ -94,7 +117,7 @@ an APS descriptor without GAM. | C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` | | C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` | | C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated. | `gpt/index.ts:1729` | -| C6 | The renderer CSP can kill creatives after "ready". | `aps.rs:49` | +| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | | C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` | ### 2.4 Observability @@ -104,13 +127,13 @@ server-side to one that painted. ### 2.5 Failure → signal mapping (normative) -Two distinct gates, precisely separated: the **tester cookie** (explicitly +Two gates, precisely separated: the **tester cookie** (explicitly non-security, `tester_cookie.rs:3`) gates **debug content** — the `tsjs.boot.debug` envelope and the page-bids/`/auction` `ext.trusted_server.debug` fields, the same sensitivity class as the -existing tester-gated `ts-debug` HTML comment. The **diagnostic credential** -(§5.3) gates **telemetry volume** (unsampled mode). The tester cookie never -affects sampling; the credential never gates mere content. +existing tester-gated `ts-debug` comment. The **diagnostic credential** +(§5.3) gates **telemetry volume**. The cookie never affects sampling; the +credential never gates mere content. | Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | | ------- | ----------------------------------------- | ------------------------------------- | ------------------------------- | @@ -118,9 +141,9 @@ affects sampling; the credential never gates mere content. | A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | | A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary | | A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | -| B1/B2 | `bridge_request{matched:false}` | join via trace | console warn | +| B1/B2 | `bridge_request{matched:false}` | join via trace (§G1) | console warn | | C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | -| C2 | `render_fail{renderer_document_no_load}` | route counters (`ts_ops_counters`) | console warn | +| C2 | `render_fail{renderer_document_no_load}` | `ts_ops_counters` | console warn | | C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn | | C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn | | C5 | — (fixed at baseline) | — | — | @@ -129,8 +152,8 @@ affects sampling; the credential never gates mere content. ## 3. The GPT and baseline reality -1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead in - production. +1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead + in production. 2. #922 merge loss (`0dc9b19a9`); PR #997 is the apparent replacement. 3. TS refreshes never pass `changeCorrelator: false`. 4. `enableSingleRequest()` called blind after publisher `enableServices()`. @@ -140,606 +163,1064 @@ affects sampling; the credential never gates mere content. guarantee**; `slotRenderEnded` = code injected, not resources loaded; `responseIdentifier` identifies responses only. 8. **`display()` under disabled initial load creates no request — for any - caller.** GPT's behavior is caller-independent (`gpt/index.ts:1175`, - `ad_init.test.ts:1201-1263`); the G4a rules therefore apply to publisher - `display()` exactly as to TS `display()`. + caller** (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`); GPT's + behavior is caller-independent. 9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled` (`gpt/index.ts:1091`); G4a needs unconditional early subscription. 10. Baseline fix `248fe9558`: MessageChannel APS-PUC handshake - (`aps.rs:65-125`, `aps/render.ts:415-437`; reply still terminates inside - the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5 + (`aps.rs:65-125`, `aps/render.ts:415-437`; the reply still terminates + inside the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5 consolidated, real-PUC browser test added. 11. The bridge keeps consumed-id tombstones for security - (`gpt/index.ts:1527`); G2 preserves that across navigations. + (`gpt/index.ts:1527`). 12. The tester cookie is not a security control (`tester_cookie.rs:3`). -13. **Fastly constructs application state per request** (`app.rs:146`) — no - cross-request in-memory state may be assumed on that adapter. +13. **Fastly constructs application state per request** (`app.rs:146`); + its platform rate counter is a 60 s fixed window with separate + lookup/increment (`rate_limiter.rs:40`). +14. The baseline auction client collapses every failure into an empty + array (`core/auction.ts:185-224`): absent fetch, timeout, network + error, non-2xx, wrong content type, malformed body, and a genuine + zero-bid auction are indistinguishable to callers. ## 4. Design gates -### G1 — Trace identity, sampling, and correlation - -- Client-visible auction id (EC-derived, `publisher.rs:3237`) is never - ingested. Initial-HTML telemetry precedes page JS, so correlation is - minted by whoever acts first: server for `nav_gen 0` (trace + signed - authorization in `tsjs.boot`); client afterwards, via `X-TSJS-Trace-Id` - on page-bids (GET) and on the `/auction` POST, echoed back with the - signed authorization (`ext.trusted_server.trace = {trace_id, auth, -auction_id}` on JSON/OpenRTB responses). -- **Sampling is a deterministic keyed function**, not a coin flip: - `mode = sampled iff HMAC(sampling_key, trace_id) < sample_rate` — so - concurrent requests presenting the same trace always derive the same - mode, and first issuance needs no shared state (Fastly is per-request, - §3.13). -- **Cross-tier join key (closing the row-multiplication gap):** the server - echoes its telemetry `auction_id` to the client (boot / response - extensions); the client stamps `auction_id` on every event of attempts - born from that auction. Server rows additionally carry `release_id`. - Canonical joins run on `(publisher_domain, trace_id, auction_id, slot, -refresh_gen)` at attempt grain; canary/control comparison uses the - server-side `release_id`. -- Cache-privacy invariant: traces/authorizations only in per-request - auction-bearing responses; such HTML is `private, no-store`. +### G1 — Trace identity, sampling, correlation + +- The client-visible auction id is EC-derived (`publisher.rs:3237`) and + never ingested. Initial-HTML telemetry precedes page JS + (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by + whoever acts first: the **server** for `nav_gen 0` (trace + signed + authorization in `tsjs.boot`); the **client** afterwards via + `X-TSJS-Trace-Id` on page-bids (GET) and the `/auction` POST, echoed + back with the authorization and the server's telemetry auction id: + `ext.trusted_server.trace = {trace_id, auth, auction_id}`. +- **Deterministic keyed sampling, numerically exact:** take the first + 8 bytes of `HMAC-SHA-256(sampling_key, trace_id)` as a big-endian u64; + `mode = sampled` iff `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` + must be finite and in `[0, 1]` (validated at load; 0 → nothing sampled, + 1 → everything). Concurrent requests for one trace always derive the + same mode with no shared state (§3.13). +- **Cross-tier join:** the equality key is the globally unique + **`auction_id`** (server-minted telemetry UUID), echoed to the client + and stamped on every event of attempts born from that auction. + Generations (`nav_gen`, `refresh_gen`) exist **client-side only**, for + attempt aggregation — auction rows do not carry them. Canonical join: + `(publisher_domain, trace_id, auction_id)`, attempt grain added from + client events. +- **Cache-privacy invariant:** traces/authorizations only in per-request + auction-bearing responses; such HTML is `private, no-store`, no + validators; by construction and by test. - Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry - `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow` is a closed - field** `ssat | prebid | page_bids | direct | fallback` set by the - attempt owner — the per-flow funnels in the gates table depend on it. + `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow`** is closed: + `ssat | prebid | page_bids | direct | fallback | system` — `system` for + heartbeat and overflow events, which have no render flow; the generated + per-event validity matrix (§5.1) says which events may carry which + flows. - Traces are navigation-scoped; attempt counts key on `(trace_id, nav_gen, refresh_gen, slot)`. ### G2 — Render identity -- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte. Markup bids: - existing fallback chain. Renderer-only bids: server-minted token - `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min, - one-time consumption. -- **Registry + tombstones, one capacity, no unexpired eviction:** the - bridge-reservation store holds live registrations and tombstones - (consumed / stale / navigation-disposed ids) in one bounded structure — - **capacity 320 for the union**; expired entries are pruned; **unexpired - entries are never evicted**; when the union is at capacity, **new - registration is refused** with `registry_full`. A late prior-navigation - bridge request therefore always meets suppression until its id's original - TTL passes (preserving `gpt/index.ts:1527`). Test: >320 registrations, - then a late request for the oldest unexpired id. -- Client-Prebid keeps Prebid's `adId`; one store serves both paths. - -### G3 — Runtime ABI (exact-release) - -- Kernel only in `tsjs-core`; publishes - `tsjs._internal = {release_id, registry}`; frozen after boot; core - services constructed at boot; plugins register via the object-form API - (§7.6) whose `release` is a build-generated constant; `registry.get` - succeeds only on `release_id` equality; mismatch quarantines - (`abi_mismatch`/`bundle_partial`) with a console error. -- Boundary enforcement: `import/no-restricted-paths` for layering plus - **`no-restricted-properties`/`no-restricted-syntax`** rules covering - `window`, `globalThis`, `self`, and local aliases for `googletag`/`pbjs` - access outside `adapters/` (`no-restricted-globals` cannot catch member - expressions). Adapters are the only access to **external ad-tech - globals**; the kernel and messaging module necessarily touch - `window.tsjs`, listeners, and `postMessage`. +- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte + (`publisher.rs:3355`; the PUC fetches `?uuid=`, `gpt/index.ts:1772`). + Markup bids: existing fallback chain. Renderer-only bids: server-minted + token `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, + cross-auction uniqueness probabilistic (36¹² ≈ 4.7×10¹⁸) and harmless + via scoping; TTL 15 min; one-time consumption. +- **Reservation store — one capacity, no unexpired eviction:** live + registrations and tombstones (consumed / stale / navigation-disposed + ids) share one bounded structure, **union capacity 320**; expired + entries are pruned; **unexpired entries are never evicted**; at + capacity, new registration is refused with `registry_full`. A late + prior-navigation bridge request always meets suppression until its id's + original TTL passes (preserving `gpt/index.ts:1527`). Test: >320 + registrations, then a late request for the oldest unexpired id. +- The client-Prebid path keeps Prebid's generated `adId`; one store + serves both paths. Non-APS cache-path byte-identity regression tests. + +### G3 — Runtime ABI under the IIFE build (exact-release) + +Every entry point is a self-contained IIFE with inlined imports +(`build-all.mjs:46`, `bundle.rs:23`) — imports never share state across +bundles (live defect: `core/context.ts:11` vs `permutive/index.ts:102`). + +- The kernel ships only in `tsjs-core`, publishes + `tsjs._internal = {release_id, registry}` once (window sentinel), + freezes `_internal` after boot, and constructs/registers core services + (event bus, beacon queue, sessions, slot registry, render engine) + during boot; integrations register integration-scoped services during + `install()`. +- **Exact release matching:** every registration carries `release_id` + (plugins via the §7.6 object API whose `release` is a build-generated + constant); `registry.get(name)` succeeds only on equality; mismatch + quarantines (`abi_mismatch` service / `bundle_partial` plugin) with a + console error. +- Stateful access only via the registry at call time; stateless helpers + may inline. +- **Boundary enforcement:** `import/no-restricted-paths` for layering + **plus** `no-restricted-properties`/`no-restricted-syntax` rules + catching member-expression access to `googletag`/`pbjs` through + `window`, `globalThis`, `self`, and local aliases outside `adapters/` + (`no-restricted-globals` cannot catch member expressions). Adapters are + the only access to **external ad-tech globals**; kernel and messaging + necessarily touch `window.tsjs`, listeners, and `postMessage`. ### G4 — Render lifecycle **G4a — Physical request cycles.** -- Intents recorded for both classes (`ts | publisher`) in one causal queue. - **Any `display()` issued while initial load is disabled is retired at - issuance regardless of caller** (GPT's no-request behavior is - caller-independent, §3.8) — a stale publisher `display()` intent can no - more poison a later TS `refresh()` match than the reverse. Hindsight - zero-request intents (`refresh()` on a never-displayed slot) expire at - 2 s with `intent_no_request`; while one is pending, any opposite-class - intent makes the next `slotRequested` ambiguous → quarantine. The - ambiguity rule is symmetric. -- Cycles open only on `slotRequested`, matched to the causal queue head; - SRA yields one per slot per batch; cycles close on `slotRenderEnded`; - `responseIdentifier` dedups during drain. -- One outstanding TS cycle per slot; one queued replacement (coalescing). -- Attribution requires exactly one outstanding TS cycle and no overlap; +- **Intents, both classes, one causal queue.** Every observable + initiation — TS and wrapped publisher `display()`/`refresh()` — records + an intent in causal order, classified `ts | publisher`. Any + `display()` issued while initial load is disabled is **retired at + issuance regardless of caller** (GPT is caller-independent, §3.8) — it + never enters the matcher. Hindsight zero-request intents (`refresh()` + on a never-displayed slot) expire at 2 s with `intent_no_request`; + **any later request-capable intent — same class or opposite — + supersedes a pending uncertain intent immediately**, and if the + uncertain intent's request could still legitimately be in flight + (within its 2 s bound), the next `slotRequested` is ambiguous and the + slot quarantines. A stale no-op `refresh()` can therefore never steal + a later `display()`'s request in either direction, TS→TS, + publisher→publisher, or across classes. +- **Cycles** open only on `slotRequested`, matched to the causal queue + head; SRA batching yields one per slot per batch; cycles close on + `slotRenderEnded`; `responseIdentifier` deduplicates responses during + drain (it never attributes initiation). +- **Serialization:** at most one outstanding TS cycle per slot; one + queued TS replacement (later intents coalesce). +- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS + cycle is outstanding and no publisher/untracked request overlaps; otherwise quarantine (`cycle_unattributable`), fail closed. -- **No timeout re-arm.** Re-arm only on count-based drain, safe TS-owned - destroy/redefine, or page end. Unissued intents are NavigationSession - children; physical cycle/drain state is RuntimeSession. -- Deterministic-harness CI plus the release-gating real-GAM suite (scope - enumerated in the gates table: per-flow topologies, expected sequences, - browsers, fixtures, commands, artifacts, approvals — success criterion 1 - refers to that enumeration). - -**G4b — Acknowledgement, specified per render path.** Four normative -sequences; each names its nonce/token producer, transport into the owned -frame, authenticated acceptance observation, cancellation, and separate -document/runner deadlines (document 3 s, runner 10 s, adm 5 s): - -1. **APS-PUC** (baseline transport): bridge mints the per-attempt 128-bit - nonce; MessageChannel into the renderer document (`ports.length` - checks, exact-key replies, one-shot latch, port close); the document - posts authenticated `renderer_document_loaded` then - `render_accepted | render_failed{reason}` **to the top window**; the - kernel validates source ownership, nonce, token, `nav_gen`, - `refresh_gen` before transitions or notifications. -2. **Generic ADM/cache-PUC**: the bridge's display renderer creates the - sandboxed adm frame with an injected reporter snippet; the reporter - posts authenticated `adm_document_loaded{nonce}` to the top window on - document load; acceptance = that message (baseline merely appends an - iframe with no observation). -3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent — - the baseline parent-postMessage handshake (`ports.length === 0` branch) - is already kernel-observed; same three messages, same validation. -4. **Direct ADM/cache**: as (2), with the kernel as parent. - -Cancellation for all four: navigation/supersession invalidates the nonce; -late acks are discarded with `stale_navigation`. - -**G4c — Honest observations; one terminal state.** Observations: -`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded, -reason}` (observation and remediation are separate — a guarded anchor/fixed -case is still observed), `renderer_document_loaded`, `runner_loaded`, -`runner_failed`, `adm_document_loaded`. **An attempt has exactly one -terminal state: `accepted | failed{reason} | no_bid | cancelled`.** -Post-acceptance runner failure is an observation plus the -`billing_outcome{billed_then_failed}` event — never a second terminal -transition. No observation claims paint. The baseline resize is a -sanctioned, guarded exception to the no-foreign-DOM-mutation rule. +- **No timeout re-arm.** Physical cycle/drain state lives in the + RuntimeSession slot record; unissued intents are NavigationSession + children (cancelled by navigation disposal). A quarantined or stale + slot re-arms only on count-based drain, safe TS-owned + destroy/redefine, or page end. Timeouts emit diagnostics and never + restore attribution. Late stale events are matched and discarded + (`stale_navigation`). +- Deterministic-harness CI plus the release-gating real-GAM suite + (topologies enumerated in Appendix A.3). + +**G4b — Acknowledgement, per render path.** Four normative sequences; +each names its nonce producer, transport, authenticated acceptance +observation, cancellation, and deadlines (document 3 s, runner 10 s, +adm 5 s). All nonces are per-attempt 128-bit CSPRNG values minted by the +attempt owner; the kernel validates, in order: source ownership (§6.8 +walk), nonce, token, `nav_gen`, `refresh_gen` — before any transition or +notification. Navigation/supersession invalidates the nonce; late acks → +`stale_navigation`. + +1. **APS-PUC** (baseline transport): bridge mints the nonce; + MessageChannel into the renderer document (`ports.length` checks, + exact-key replies, one-shot `accepted` latch, port close — + `aps.rs:65-125`, `aps/render.ts:415-437`); the document posts + authenticated `renderer_document_loaded` then + `render_accepted | render_failed{reason}` to the top window. +2. **Generic ADM/cache-PUC:** the display renderer creates the sandboxed + adm frame with an injected reporter snippet that posts authenticated + `adm_document_loaded{nonce}` on document load; acceptance = that + message (the baseline merely appends an iframe with no observation). +3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent; + the baseline parent-postMessage branch (`ports.length === 0`) is + already kernel-observed; same three messages, same validation. +4. **Direct ADM/cache:** as (2) with the kernel as parent. + +**G4c — Honest observations; one terminal state.** Inline-adm frames are +sandboxed `srcdoc` without `allow-same-origin` (`gpt/index.ts:510`) — +opaque; geometry proves nothing. Observations: `gam_nonempty`, +`gam_empty`, `gam_collapsed{action: resized | guarded, reason?}` +(observation and remediation separate), `renderer_document_loaded`, +`runner_loaded`, `runner_failed`, `adm_document_loaded`. **An attempt has +exactly one terminal state: `accepted | failed{reason} | no_bid | +cancelled`.** Post-acceptance runner failure is an observation only — +there is **no** `billing_outcome` event: no path has an honest producer +for a post-accept billing-failure claim (APS is excluded from +notifications and opaque frames offer no authenticated post-accept +signal), so the design does not pretend otherwise. No observation claims +paint; there is no `render_confirmed`. The baseline resize +(`gpt/index.ts:217`) is a sanctioned, guarded exception to the +no-foreign-DOM-mutation rule (authenticated source frame only; wrapper +only when both dimensions ≤ 1 px; anchor-ad and fixed/sticky guards). **G4d — Notifications.** APS carries neither `nurl` nor `burl` -(`aps.rs:839`); excluded entirely. For carrying paths: bind per flow — PUC: -owned, slot-and-ad-id-matched bridge claim; direct: validated render start -(server must preserve + macro-expand `nurl`/`burl` in `/auction` -responses — `formats.rs:423` omits them — and the client must parse and -https-validate them — `core/auction.ts:43` drops them); fallback: -attributed `gam_empty` immediately before render. `nurl` at bind, `burl` -at `accepted`. **Economic identity is the normalized pair -`(id_kind, id_value)`** (direct attempts without `hb_adid` use -`bid_id`), idempotency key -`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. **Every -dispatch emits `notification_sent{kind: nurl|burl, id_key_hash, result: -queued | failed}`** where `id_key_hash` is a 16-hex truncated HMAC of the -idempotency key — making the duplicate-`burl` invariant observable in -production (the gates table queries zero duplicates per key hash); external -billing reconciliation remains the authoritative backstop. No retries. - -**G4e — Fallback.** Opt-in; renders only on a terminal `gam_empty` -unambiguously attributed to a TS cycle; ownership does not gate; -publisher-initiated or unattributable never triggers; timeouts never -render. +(`aps.rs:839`; the AAX envelope excludes them; the integration guide +documents no generic APS beacons) — excluded entirely; the Amazon runner +lifecycle is unchanged. For carrying paths (PBS and other OpenRTB +providers): + +- Bind per flow, never selection or targeting (`ad_init.test.ts:1824`): + PUC — an owned, slot-and-ad-id-matched bridge claim; direct — + validated render start (the server must preserve and macro-expand + `nurl`/`burl` in `/auction` responses, `formats.rs:423` omits them; + the client must parse and https-validate them, `core/auction.ts:43` + drops them); fallback — attributed `gam_empty` immediately before + fallback render. +- `nurl` at bind; `burl` at `accepted`; **no retries**; idempotency key + `(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)` with the + normalized economic identity `(id_kind, id_value)` (direct attempts + without `hb_adid` use `bid_id`). +- **Observability without client cryptography:** the server mints an + opaque **`notif_id`** (12-char token, same generator as G2) per + notification-carrying bid and delivers it with the bid; every dispatch + emits `notification_sent{kind: nurl | burl, notif_id, result: +queued | failed}`. The browser computes no hashes (a client-held HMAC + key would break the pseudonymization boundary; a rotating token would + break stability across a gate window). +- **The duplicate-`burl` invariant is proven hermetically and + reconciled externally, not "proven" by lossy telemetry:** hermetic + tests pin exactly-once dispatch logic; production + `notification_sent` duplicates are a **detection alarm** (any + observed duplicate is a red gate); absence-of-duplicates is + established by billing reconciliation (GAM/SSP reports vs server-side + win counts) because sampled, best-effort telemetry cannot prove a + zero. + +**G4e — Fallback.** Opt-in +(`[auction].client_render_fallback = "renderer"`); renders only after a +terminal `gam_empty` unambiguously attributed to a TS cycle; ownership +does not gate it; publisher-initiated or unattributable cycles never +trigger it; timeouts never render. **G4f — Direct `/auction` lifecycle.** `RenderAttempt` keyed -`(trace_id, nav_gen, refresh_gen, slot)`; per-slot latest-wins with -cancellation; generation checks before every DOM/beacon effect -(`request.ts:31` races today); G4b sequence 3/4; G4d direct binds; -disposal on navigation; single terminal state; -`tsjs.requestAds(options): Promise` with +`(trace_id, nav_gen, refresh_gen, slot)`; per-slot **latest-wins with +cancellation** (concurrent calls cancel the older attempt; +`request.ts:31` races today); generation checks before every DOM/beacon +effect; G4b sequences 3/4; G4d direct binds; navigation disposal; one +terminal state. **The auction client returns a discriminated result** — +`{ok: bids[]} | {error: "auction_timeout" | "network_error" | +"http_error" | "invalid_response"}` — replacing the baseline's +everything-is-an-empty-array collapse (§3.14); only a successfully +parsed response with no winner maps to `no_bid`. Public API: +`tsjs.requestAds(options): Promise`, `RequestAdsResult = {traceId, slots: [{slot, outcome: "rendered" | -"no_bid" | "failed" | "cancelled", reason?}]}`. - -**G4g — Mid-attempt configuration.** An attempt **snapshots its -configuration at creation**. The in-pool emergency kill switch cancels -attempts that have not yet passed their commit point — defined as -`bridge_response_sent` (PUC flows) or first DOM insertion (direct/fallback -flows); attempts past commit run to their terminal state; dispatched -notifications are never recalled. +"no_bid" | "failed" | "cancelled", reason?}]}`, settling when every slot +attempt is terminal. Reversed-response tests required. +**Fallback identity:** fallback is a **child attempt** — new +`RenderAttempt`, `flow = fallback`, carrying `parent_flow` (the +originating flow); the terminal `gam_empty` belongs to the parent +attempt under the parent's flow; the canonical view links parent and +child on `(trace_id, nav_gen, slot, refresh_gen)`. + +**G4g — Mid-attempt configuration and the commit point.** An attempt +snapshots configuration at creation. **Commit = the earliest +irreversible action** — the first of: notification dispatch (`nurl` at +bind), `bridge_response_sent`, or first DOM insertion. The +generation/kill-switch check runs **immediately before each** of those; +an attempt past commit runs to its terminal state; dispatched +notifications are never recalled. (Revision 7 put commit after the +`nurl` side effect; that ordering error is corrected.) ### G5 — Deployment contracts -- Config `format_version`; release-time config (§0). -- **Assets pre-materialized at release publication:** because config ships - with the release, the validated module vectors are known when the release - is built — concatenated bytes + hashes are produced then and embedded; - serving is lookup-only on every adapter (Fastly's per-request state, - §3.13, makes construction-time caching meaningless there — the previous - revision's claim is corrected). The §7.10 server benchmark measures the - lookup path and guards against regression to per-request concatenation. - Unknown vector = release-build error; unknown hash = `410`, `no-store`; - exact match = `public, max-age=31536000, immutable`. -- **Internal route families — now four:** renderer, client-events, - CSP-report, **and `/_ts/trace-auth`** — dispatch before auth/EC/ - publisher/integration filters; all methods reserved locally (405 + - `Allow` + `no-store`; unknown versions 404 `no-store`; never publisher - fall-through); no body/cookie/authorization forwarding; normalized - scheme+host+port origin comparison; each family rate-limited (§5.4) and - covered by four-adapter parity tests. +- Config `format_version` + manifest hash verification (§0). +- **Assets pre-materialized at release publication:** config is a + release-time input, so validated module vectors are known when the + release is built; concatenated bytes + hashes are produced then and + embedded; serving is lookup-only on every adapter (Fastly is + per-request, §3.13, so construction-time caching would be + meaningless). Unknown vector = release-build error; unknown hash = + `410 no-store`; exact match = `public, max-age=31536000, immutable`. +- **Internal route families — four:** renderer, client-events, + CSP-report, `/_ts/trace-auth`. All dispatch before auth/EC/publisher/ + integration filters (Fastly today runs EC setup and pre-route filters + first, `app.rs:709`); all methods and version prefixes reserved + locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`; + never the publisher fall-through of `adapter-spin app.rs:804`); no + body/cookie/authorization forwarding. **Origin policy is per family**, + not universal: client-events and trace-auth require strict normalized + same-origin (scheme+host+port); the CSP route admits opaque/`null` + origins and authenticates by server-selected path identity plus abuse + limits; the renderer document is a public GET validated by + version/path only (it is loaded from sandboxed opaque contexts — + browser-origin authentication is impossible there by design). +- Ingest routes exist in all four adapters; Fastly has real sinks; + others accept-count-drop by contract (DR-5). - §5.6 schemas deploy and validate before writers enable. ## 5. Observability -### 5.1 Wire payload and field matrix +### 5.1 Wire payload and per-event field matrix ``` { v: 1, traces: [ { trace_id, auth, events: [ { nav_gen, refresh_gen, seq, flow, auction_id?, t, ...fields } ] } ] } ``` -| `t` | fields (absent = absent on wire, NULL in storage) | -| -------------------------- | ------------------------------------------------- | -| `bid_received` | slot, id_kind, source | -| `targeting_set` | slot, id_kind | -| `bridge_request` | slot, id_kind, matched | -| `bridge_response_sent` | slot, source | -| `render_attempt` | slot, source | -| `render_accepted` | slot, source | -| `render_fail` | slot, reason, source? | -| `gam_nonempty` | slot | -| `gam_empty` | slot | -| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | -| `renderer_document_loaded` | slot | -| `runner_loaded` | slot | -| `runner_failed` | slot, reason | -| `adm_document_loaded` | slot | -| `fallback_start` | slot | -| `billing_outcome` | slot, outcome (`billed_then_failed`) | -| `notification_sent` | slot, kind (`nurl`\|`burl`), id_key_hash, result | -| `client_queue_overflow` | dropped (count) | -| `heartbeat` | probe_id, expected_seq | - -`source` is **nullable on `render_fail`**: absent for pre-source reasons +| `t` | fields | allowed `flow` | +| -------------------------- | --------------------------------------------- | ----------------------- | +| `bid_received` | slot, id_kind, source | render flows | +| `targeting_set` | slot, id_kind | render flows | +| `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids | +| `bridge_response_sent` | slot, source | ssat, prebid, page_bids | +| `render_attempt` | slot, source | render flows | +| `render_accepted` | slot, source | render flows | +| `render_fail` | slot, reason, source? | render flows | +| `gam_nonempty` | slot | ssat, prebid, page_bids | +| `gam_empty` | slot | ssat, prebid, page_bids | +| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | ssat, prebid, page_bids | +| `renderer_document_loaded` | slot | render flows | +| `runner_loaded` | slot | render flows | +| `runner_failed` | slot, reason | render flows | +| `adm_document_loaded` | slot | render flows | +| `fallback_start` | slot, parent_flow | fallback | +| `notification_sent` | slot, kind (`nurl`\|`burl`), notif_id, result | render flows | +| `client_queue_overflow` | dropped (count) | system | +| `heartbeat` | probe_id, expected_seq | system | + +"Render flows" = `ssat | prebid | page_bids | direct | fallback`. +`source` on `render_fail` is **nullable**: absent for pre-source reasons (`gpt_absent`, `pbjs_absent`, `slot_unresolved`, `intent_no_request`, -`abi_mismatch`, `registry_full`, `bundle_partial`); required for -source-specific reasons — the per-reason validity matrix is part of the -generated schema. Reason enum as revision 6 plus `currency_mismatch`. -**Heartbeats** are sent by identified probes under mode `probe` (§5.3): -excluded from every product metric by mode, never sampled out, and the -canonical freshness/loss query counts `expected_seq` gaps. +`abi_mismatch`, `registry_full`, `bundle_partial`); required otherwise. +The per-event/per-reason validity matrix is a generated artifact (§6.7). +Reason enum (closed): `renderer_document_no_load`, `runner_no_load`, +`runner_failed`, `descriptor_invalid`, `invalid_dimensions`, +`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`, +`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`, +`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, +`pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`, +`registry_full`, `currency_mismatch`, `auction_timeout`, +`network_error`, `http_error`, `invalid_response`, +`adm_document_no_load`. No client timestamp; the server stamps +`received_at`; ordering within a trace is `seq`. ### 5.2 Transport and overflow -`fetch keepalive credentials:"omit"` primary; `sendBeacon(url, -new Blob([json], {type: "application/json"}))` on `pagehide`. Queue bound 256. **Overflow never enqueues into the full queue**: an out-of-band -saturating counter accumulates drops, and one coalesced -`client_queue_overflow{dropped}` is materialized into the **next flush**. +`fetch(..., {keepalive: true, credentials: "same-origin"})` primary (§0 +affinity; the handler still derives no identity from cookies); +`pagehide` fallback `navigator.sendBeacon(url, new Blob([json], {type: +"application/json"}))`. Flush every 5 s and on +`visibilitychange`/`pagehide`. Queue bound 256 events. **Overflow never +enqueues into the full queue:** an out-of-band saturating counter +accumulates drops and one coalesced `client_queue_overflow{dropped}` is +materialized into the next flush. ### 5.3 Signed trace authorization -`v1....`; `auth` ingest bound 256 bytes; kid -`^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store, previous -keys retained ≥ 24 h; canonical decimal `exp`, ±60 s skew, ≤ 15 min future; -`sig` = unpadded base64url HMAC-SHA-256 over the domain-separated -length-prefixed input (revision 6's exact encoding); constant-time compare; -per-group rejection. - -- **Modes:** `sampled | unsampled | diagnostic | probe`. `unsampled` - transmits nothing and is rejected at ingest if carried. `probe` is - issued only to synthetic monitors (server-side issuance to the probe - runner) and marks heartbeat traffic. -- **Renewal preserves mode by verification, not trust:** `GET -/_ts/trace-auth` presents the **current signed authorization** in - `X-TSJS-Trace-Auth` (plus the trace header); the server verifies the - still-valid token and re-signs the **same trace_id and mode** with fresh - `exp`. The trace id itself carries no mode, and no adapter may rely on - cross-request state (§3.13) — the presented token is the state. Renewal - after expiry fails; the client stops transmitting and counts locally. -- **Diagnostic credential — complete protocol:** issuance `POST +Format `v1....`; the `auth` field has its own +ingest bound of **256 bytes** (every other string keeps the 64-char +cap — a 43-char unpadded-base64url signature cannot fit 64 with its +prefix fields). + +- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform + secret store; keys ≥ 256-bit CSPRNG; previous keys retained ≥ 24 h + (≫ max token lifetime + skew). Missing key with the feature enabled → + startup/first-use failure, never silent. +- `exp`: canonical decimal unix seconds (no sign, no leading zeros); + ±60 s skew; ≤ 15 min future. +- `mode`: `sampled | unsampled | diagnostic | probe`. **`unsampled` is + the signed discard decision:** the client neither enqueues nor + transmits for it, and ingest rejects any group carrying it. `probe` + marks synthetic monitors (server-issued to probe runners); probe + traffic is never sampled out and is excluded from product metrics by + mode. +- `sig`: unpadded base64url of HMAC-SHA-256 (43 chars) over the + domain-separated, length-prefixed input `"ts-trace-auth-v1" || +u32be(len(origin)) || origin || u32be(len(trace_id)) || trace_id || +u32be(len(mode)) || mode || u64be(exp)`, strings UTF-8, `origin` = + externally visible scheme+host+port. Constant-time comparison. +- **Renewal preserves mode by verification, not trust:** + `GET /_ts/trace-auth` presents the current still-valid token in + `X-TSJS-Trace-Auth` (plus the trace header); the server verifies and + re-signs the same `trace_id` and `mode` with fresh `exp`. **The only + mode transition that exists is the diagnostic upgrade, a distinct + operation:** `POST /_ts/trace-auth/upgrade` presenting the current + token **and** a valid diagnostic credential; it re-signs with + `mode = diagnostic` and `exp = min(now + 15 min, credential expiry)`. + Plain renewal never changes mode. Renewal after expiry fails; the + client stops transmitting and counts locally. +- **Diagnostic credential:** issued `POST /_ts/admin/diagnostic-credential` under the existing admin - authentication (CSRF: same-origin + custom header required), response - `{credential}` where credential = `d1....`, - absolute expiry ≤ 60 min, HMAC over the publisher origin + expiry, - **replayable short-lived bearer by design** (bounded by expiry and - origin binding; not one-time — stated, not implied). Transport to the - page: the operator opens the page with a `#tsdiag=` fragment - (never sent to any server in a URL); the client stores it in - `sessionStorage` and presents it in `X-TSJS-Diag` on trace-auth, - page-bids, and `/auction` requests. The server, seeing a valid - credential, issues/renews the trace authorization with - `mode = diagnostic` and **`exp = min(now + 15 min, -credential expiry)`** — a trace authorization never outlives the - credential. Initial HTML cannot see the fragment, so `nav_gen 0` starts - `sampled|unsampled` and the client immediately upgrades via trace-auth. - Validation is stateless HMAC — all four adapters support it. Forgery, - replay-past-expiry, and wrong-origin tests required. The tester cookie - remains content-only (§2.5). -- **Lazy cached initialization applies to every secret-backed component** - (trace-auth keys, diagnostic keys, sampling key, sinks): first-use - resolution with a cached result on request-bound platforms; resolution - failure with the feature enabled → the feature's startup/first-use error - path, never silent. + authentication (CSRF: same-origin + custom header), format + `d1....`, absolute expiry ≤ 60 min, + origin-bound, **replayable short-lived bearer by design** (bounded by + expiry + origin binding; stated, not implied). **Exposure-minimized + transport:** the operator opens the page with `#tsdiag=`; + the synchronous bootstrap reads it, **immediately clears the fragment + via `history.replaceState`**, holds the credential **in memory only** + (RuntimeSession — never `sessionStorage`, which page scripts can + read), and exchanges it via the upgrade operation as soon as the trace + exists. Because initial HTML cannot see the fragment, `nav_gen 0` + starts `sampled | unsampled`; **when a pending `#tsdiag` fragment is + detected, the client buffers events locally without transmission + (bounded 256) until the upgrade resolves**, then flushes under + diagnostic mode — one-page-load diagnostic completeness holds without + delaying rendering. Forgery, wrong-origin, and replay-past-expiry + tests required. Validation is stateless HMAC — all four adapters. +- **Lazy cached initialization** applies to every secret-backed + component (trace-auth keys, diagnostic keys, sampling key, sinks): + first-use resolution with a cached result on request-bound platforms; + failure with the feature enabled is that feature's loud error path. ### 5.4 Ingest and rate limiting -As revision 6 (limits, same-origin, fail-closed drops), with the limiter -contract completed: key namespace per route family; portable maps hold -≤ 65,536 (Axum) / 4,096 (Cloudflare, Spin per-instance) entries with -10-minute entry TTL, cleanup on access plus periodic sweep — **capacity -pressure rejects unseen identities but expired entries are always -reclaimable, so saturation is bounded, not permanent**; missing client -address → a shared `unknown` bucket at 1 request/min; Fastly uses the -platform 60 s window counter at limit 20 with documented overshoot ≤ 2× -under concurrent bursts (`rate_limiter.rs:40` is read-then-increment); -Axum XFF selection = rightmost entry after skipping exactly -`trusted_proxy_hops`. `/_ts/trace-auth` and `/_ts/csp-reports/` -get their own buckets (10/min, burst 20 intent). - -### 5.5 Sink, canonical views, monitoring - -Stable event key `(publisher_domain, trace_id, seq)`; canonical views -`ts_client_events_v` (dedup) and `ts_render_attempts_v` (attempt grain, -joined on the G1 key including `auction_id`); dashboards/alerts query views -only. Datasource-side monitoring via `heartbeat` events from identified -probes (mode `probe`); freshness = heartbeat lag ≤ 5 min, loss = -`expected_seq` gaps < 0.1%; alert owner: release owner's on-call. +- `POST /_ts/client-events`: `application/json` only; no + `Content-Encoding`; responds `204`, `no-store`; never echoes input. + Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars + (`auth` ≤ 256 bytes); `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`; + width/height `[0, 8192]`. Violations → drop-and-count with `204`. +- Same-origin (client-events, trace-auth): `Sec-Fetch-Site: +same-origin` when present, else normalized `Origin` equality; absent + both → drop-and-count. +- **Rate limiting — adapter abstraction with declared semantics:** + trait `ClientEventLimiter`, key namespace per route family; intent + 10 req/min, burst 20 per client address. Axum: real in-process token + bucket, map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/instance + best-effort, ≤ 4,096 entries; entry TTL 10 min with cleanup on access + plus periodic sweep — **capacity pressure rejects unseen identities, + but expired entries are always reclaimable, so saturation is bounded, + not permanent**; missing client address → shared `unknown` bucket at + 1 req/min. Fastly: the platform 60 s fixed-window counter at limit 20 + as a documented approximation; because its lookup and increment are + separate operations (§3.13), **overshoot under a synchronized burst + is bounded only by in-flight concurrency, and no numeric multiple is + claimed** — the synchronized-burst test (> 40 concurrent) documents + observed behavior, and a penalty-box follow-up is recorded if + observed overshoot is operationally unacceptable. Limiter + unavailable/errored → drop early with `204`. Trusted client address: + Fastly platform client IP; Axum rightmost `X-Forwarded-For` entry + after skipping exactly `trusted_proxy_hops` (absent config → socket + peer only); Cloudflare `CF-Connecting-IP`; Spin platform address. + `/_ts/trace-auth` and `/_ts/csp-reports/` carry their own + buckets with the same intent. + +### 5.5 Sinks, canonical views, per-sink monitoring + +- Stable event key `(publisher_domain, trace_id, seq)`. Canonical views: + `ts_client_events_v` (dedup: latest `received_at` per key) and + `ts_render_attempts_v` (attempt grain per G1). Dashboards and alerts + query canonical views only; raw-to-raw joins are forbidden (row + multiplication). +- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) + and cannot see downstream rejection — **each datasource gets its own + synthetic probe and freshness/loss query, per adapter write path**: + client-events via `heartbeat` events (mode `probe`, + `expected_seq` gaps = loss, lag = freshness); CSP via probe reports + to a reserved `policy_id = probe`; ops via a probe counter. A green + client-events heartbeat says nothing about the CSP or ops + credentials — hence three probes. Alert owner: release owner's + on-call. ### 5.6 Physical schemas (deployed before writers) -- **`ts_client_events`**: revision 6 columns plus `flow Enum`, - `auction_id Nullable(FixedString(36))`, `action Nullable(Enum)`, - `kind Nullable(Enum)`, `id_key_hash Nullable(FixedString(16))`, - `result Nullable(Enum)`, `probe_id Nullable(String)`, - `expected_seq Nullable(UInt32)`; `mode Enum(sampled|diagnostic|probe)`. -- **Auction rows**: nullable `trace_id`, `mode`, plus **`release_id`** - (the server-side cohort discriminator). Two added row types with full - physical definitions: - - `bid_drop {provider LowCardinality(String), slot Nullable(String), -reason Enum(AuctionDropReason), width Nullable(UInt16), height -Nullable(UInt16), count UInt32}` — cap 32 rows/auction **plus** one - overflow row (`reason = overflow`, `count` = dropped-row count; the - overflow row is not counted against the cap); - - `selection_summary {slot String, winner_source -Enum(mediator|direct|none), winner_provider Nullable(String) — NULL -exactly when winner_source = none, candidates_direct UInt16, -candidates_mediator UInt16, dedup_hits UInt16, currency_rejected -UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` — cap - 8 rows/auction plus one **auction-level totals row** (slot = - `_totals`) that always survives truncation, so gate denominators never - depend on per-slot rows. Counters are saturating UInt with - `0xFFFF`/`0xFFFFFFFF` as the saturation sentinel. - - **`AuctionDropReason` (closed, server-side):** `script_rendering_ -disabled, invalid_dimensions, dimensions_out_of_range, -missing_render_source, invalid_creative_url, unsupported_tagtype, +- **`ts_client_events`**: `received_at DateTime64, publisher_domain +LowCardinality(String), release_id String, trace_id FixedString(32), +mode Enum(sampled|diagnostic|probe), nav_gen UInt32, refresh_gen +UInt32, seq UInt32, flow Enum(ssat|prebid|page_bids|direct|fallback| +system), auction_id Nullable(FixedString(36)), event Enum(§5.1), slot +Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8), +source Nullable(Enum), reason Nullable(Enum), action Nullable(Enum), +parent_flow Nullable(Enum), kind Nullable(Enum), notif_id +Nullable(FixedString(12)), result Nullable(Enum), dropped +Nullable(UInt32), probe_id Nullable(String), expected_seq +Nullable(UInt32)`. Sorting key `(publisher_domain, received_at, +trace_id, seq)`; TTL 30 days; own ingest token; sink batch cap 512; + startup validation of dataset + token when enabled; + sink-unavailable → accept-count-drop. +- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`): + add nullable `trace_id`, `mode`, `release_id`. Two added row types + with an explicit **`row_kind Enum(slot | totals | overflow)`** so + totals/overflow rows are valid instances (no publisher-controlled + sentinel strings; inapplicable fields nullable): + - `bid_drop {row_kind, provider Nullable(LowCardinality(String)) — +NULL on overflow, slot Nullable(String), reason +Enum(AuctionDropReason), width Nullable(UInt16), height +Nullable(UInt16), count UInt32}` — cap 32 slot-rows/auction plus one + overflow row whose `count` = **actual dropped bids**, not compacted + rows; + - `selection_summary {row_kind, slot Nullable(String) — NULL on +totals, winner_source Nullable(Enum(mediator|direct|none)) — NULL on +totals, winner_provider Nullable(String) — NULL when winner_source +≠ a winner, candidates_direct UInt16, candidates_mediator UInt16, +dedup_hits UInt16, currency_rejected UInt16, provenance_invalid +UInt16, mediator_superseded UInt16}` — cap 8 slot-rows plus one + totals row that always survives truncation. Counters saturate at + `0xFFFF`/`0xFFFFFFFF`. + - **`AuctionDropReason` (closed, exhaustive over baseline + producers):** `script_rendering_disabled, invalid_dimensions, +dimensions_out_of_range, missing_render_source, +invalid_creative_url, unsupported_tagtype, render_payload_too_large, unexpected_response_shape, currency_mismatch, floor_rejected, provenance_invalid, -duplicate_demand, overflow`. -- **`ts_csp_reports`** (aggregate only): `{received_at, release_id, -policy_id, cohort, directive_bucket Enum, source_bucket Enum, count}`; - 30-day TTL. **CSP ingest contract:** pre-buffer body ≤ 8 KiB, ≤ 10 - reports/request, strings ≤ 256, nesting ≤ 4, no `Content-Encoding`, own - limiter bucket, fire-and-forget dispatch covered by probe heartbeats. -- **`ts_ops_counters`**: `{received_at, release_id, counter Enum, value -UInt64}` — the physical home for renderer-route counters (requests, - unknown-version, auth-blocked) and limiter/abuse counters. -- **Settings, constructible:** `[telemetry.client_events]` - `collection_enabled`, `sink_enabled` (collection without a sink = - accept-count-drop by configuration, not accident), `sample_rate`, - `api_host`, `dataset`, `token_secret`, `secret_store`, - `max_body_bytes`; `[telemetry.trace_auth]` `secret_store`, - `active_kid`, `previous_kids`, `sampling_key_secret`; - `[telemetry.diagnostic]` `secret_store`, `active_kid`. - `RuntimeServices` gains the client-events sink handle. -- APS parsing returns structured drop observations (slot + dimensions); - > 8192 → `dimensions_out_of_range`, dimensions omitted. +duplicate_demand, missing_bid_id, duplicate_bid_id, unknown_impid, +invalid_price, unsupported_media_type, creative_id_too_large, +empty_seatbid, renderer_extension_serialization_failed, +no_render_source, lost_to_higher_bid, overflow` — covering the + outcomes emitted at `aps.rs:740-929` and `formats.rs:408-419`; a + **compile-time exhaustiveness test maps every producer to the + enum**. +- **`ts_csp_reports`**: `received_at DateTime64, publisher_domain +LowCardinality(String), release_id String, policy_id +LowCardinality(String), cohort LowCardinality(String), +directive_bucket Enum(script|style|frame|img|connect|font|media| +worker|other), source_bucket Enum(https_host_allowlisted|data|blob| +inline|eval|other), count UInt32`; sorting key `(publisher_domain, +received_at, policy_id)`; TTL 30 days; settings + `[telemetry.csp_reports] enabled, api_host, dataset, token_secret, +secret_store`. Ingest: pre-buffer body ≤ 8 KiB, ≤ 10 reports/request, + strings ≤ 256, nesting ≤ 4, both media types + (`application/csp-report`, `application/reports+json`) with separate + validators, unused fields discarded before logging, own limiter + bucket. +- **`ts_ops_counters`**: `received_at DateTime64, publisher_domain +LowCardinality(String), release_id String, counter +Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked| +ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged| +probe), value UInt64`; sorting key `(publisher_domain, received_at, +counter)`; TTL 90 days; settings `[telemetry.ops_counters]` (same + shape). +- **Sink plumbing:** one generic multi-target Tinybird sink trait; the + `RuntimeServices` (`platform/types.rs:158`) gains handles for + client-events, CSP, and ops targets beside the auction sink; each + target has its own dataset + token settings as above. +- **Settings:** `[telemetry.client_events] collection_enabled, +sink_enabled, sample_rate, api_host, dataset, token_secret, +secret_store, max_body_bytes`; `[telemetry.trace_auth] secret_store, +active_kid, previous_kids, sampling_key_secret`; + `[telemetry.diagnostic] secret_store, active_kid`. +- APS parsing returns structured drop observations + `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values + today); >8192 → `dimensions_out_of_range` with dimensions omitted. ### 5.7 Modes and SLIs Production (sink-backed): deterministic keyed sampling (default 0.10). -SLIs: pipeline availability (heartbeat freshness/loss); failure detection -(≥ 1% of sampled render attempts visible within one hour at ≥ 10,000 -sampled attempts/hour). Diagnostic: credential-gated, unsampled, full -stream + console mirroring + debug envelopes (§2.5). +SLIs: **pipeline availability** — per-sink probe freshness ≤ 5 min and +probe loss < 0.1% (fails during sink outages, alarmed); **failure +detection** — a failure mode affecting ≥ 1% of sampled render attempts +visible within one hour, at ≥ 10,000 sampled attempts/hour. Diagnostic: +credential-gated, unsampled, full stream + console mirroring + debug +envelopes (§2.5). ### 5.8 Server-side drop surfacing -As revision 6, with `selection_summary`/`bid_drop` physicalized above. +Bounded structured summary whenever any bid is dropped; `bid_drop` and +`selection_summary` rows (§5.6); the initial-HTML `ts-debug` comment +carries the drop summary; page-bids and `/auction` carry the +tester-gated structured `debug` field. Startup warnings: APS + +`allow_script_creatives = false`; mediator + direct providers without +an explicit `winner_selection` (§6.1 hard error). ## 6. APS delivery fixes -### 6.1 Mediation — total order, closed contradictions - -1. **Currency.** Required `[auction].currency` (ISO 4217). Providers - validate response currency at parse; providers with a contract-implied - currency validate that implication — **APS enabled with a non-USD - configured currency is a startup error** (`aps.rs:475` stamps USD), not - a silent all-drop. Prebid's parse must validate rather than assume USD - (`prebid.rs:2433`). Mismatch → `bid_drop{currency_mismatch}`. -2. **Candidate identity, total by construction.** `source_candidate_id` = - `(provider_name, upstream_bid_id)` where the upstream id is **required - bounded (≤ 64 chars) and unique per provider response at admission**; - a bid missing an id, or duplicating one, receives a deterministic - **intrinsic fingerprint**: `fp = hex(HMAC(auction_id, provider || slot -|| price_micros || render_source_digest))` — identical fingerprints are - the same demand and dedup to one candidate. `candidate_id` (wire echo) - is CSPRNG with in-auction collision retry and is **never** an ordering - key. +### 6.1 Mediation — total order, no fictional identities + +Baseline defects: no forwarded candidate id; lossy last-write-wins +`(provider, slot, bidder)` field restoration (`adserver_mock.rs:95`); +arrival-order equal-price ties (`orchestrator.rs:827`); Prebid assumes +USD (`prebid.rs:2433`); APS stamps USD (`aps.rs:475`); no configured +currency. Replacement, identical in the synchronous and split +dispatch/collect paths via one shared helper: + +1. **Currency.** Required `[auction].currency` (ISO 4217). Every + provider parse validates its response currency; contract-implied + currencies are validated as implied — **APS enabled with a non-USD + configured currency is a startup error**, not a silent all-drop. + Mismatch → `bid_drop{currency_mismatch}`. +2. **Candidate identity.** `source_candidate_id` = + `(provider_name, upstream_bid_id)`; the upstream id is **required, + ≤ 64 chars, and unique per provider response — bids missing an id or + duplicating one are rejected** (`bid_drop{missing_bid_id | +duplicate_bid_id}`). No fingerprint fallback: a fingerprint over a + partial field set can merge economically distinct demand, and + OpenRTB requires bid ids — rejection is honest. `candidate_id` (wire + echo) is CSPRNG with in-auction collision retry and is never an + ordering key. Mediator-native bids get identities the same way from + the mediator's response. 3. **Mediator exchange.** Forwarded candidates carry - `ext.trusted_server.candidate_id`; the mediator echoes it. Resolution - rules, stated exhaustively: an echoed id that resolves → the forwarded - candidate, provenance `mediator`; **price is authoritative from the - mediator; every render-source and notification field comes from the - stored candidate; deal fields are out of scope entirely** (no deal - identity exists in the model — revision 6's "deal fields from mediator" - is deleted). A mediator bid whose **any** render-source field differs - from the stored candidate is reclassified mediator-native. An echoed id - that does **not** resolve → that bid is discarded and counted - (`provenance_invalid`); **mediator-native bids and direct candidates - for the slot all remain eligible** — "fails closed" applies to the - invalid claim, not the slot. -4. Floors filter both populations; echoed candidates remove their direct - twins. -5. **Selection order (total):** decoded CPM desc → provenance rank - (mediator first) → `source_candidate_id` asc (fingerprints compare as - their hex strings). Arrival order can never matter. -6. **Strategy** required when mediator + direct providers coexist: - `mediator_only` (timeout → no winners unless - `mediator_timeout_fallback = "direct"`) or `merge_highest_cpm` - (timeout → direct-only, reported). -7. Reporting: `selection_summary` rows + `_totals` (§5.6). - -### 6.2–6.5 - -As revision 6: exact dimensions with structured drops; script creatives -default-off but loud — **DR-2's output is a deployment decision** (enable -with explicit sandbox/security approval, or accept a quantified maximum -excluded-demand share and gate Phase 3 on it), not a documentation -priority; G2 identity; G4e fallback. + `ext.trusted_server.candidate_id`; the mediator echoes it (contract + for every mediator, `adserver_mock` included). Echoed id resolves → + the forwarded candidate, provenance `mediator`: **price is + authoritative from the mediator; every render-source and + notification field comes from the stored candidate; deal fields are + out of scope entirely** (no deal identity exists in the model). A + mediator bid whose any render-source field differs from the stored + candidate is reclassified mediator-native. An unresolvable echoed id + → that bid is discarded and counted (`provenance_invalid`); + mediator-native bids **and direct candidates remain eligible** — + fail-closed applies to the invalid claim, not the slot. +4. Floors filter both populations; echoed candidates remove their + direct twins (`dedup_hits`). +5. **Selection order (total, intrinsic):** decoded CPM desc → + provenance rank (mediator first) → `source_candidate_id` asc. + Arrival order can never matter. +6. **Strategy** required when mediator + direct providers coexist + (startup error if absent): `mediator_only` (timeout → no winners + unless `mediator_timeout_fallback = "direct"`) or + `merge_highest_cpm` (timeout → direct-only, reported). +7. Reporting: `selection_summary` slot rows + totals row (§5.6). + +### 6.2 Dimensions + +Exact size membership stays (`aps.rs:675`). The fix is visibility +(`bid_drop{invalid_dimensions, w, h}`) plus documentation ("request the +sizes you accept"); accepting unrequested sizes would conceal an +upstream protocol violation. + +### 6.3 Script creatives + +`allow_script_creatives` stays default-`false`; the consequence is loud +(§5.8). **DR-2's output is a deployment decision:** enable with explicit +sandbox/security approval, or accept a quantified maximum +excluded-demand share and gate Phase 3 on it. + +### 6.4 Render identity + +As specified in G2 (token format, registry-union capacity, tombstones, +cache-path byte identity). + +### 6.5 Fallback + +As specified in G4e/G4a/G4f (attribution-gated child attempt; awaitable +renderer conversion precedes it; timeouts never render). ### 6.6 Renderer endpoint -As revision 6 (unconditional route, versioned immutable document with a -checked-in per-version header manifest, three-message ack, aggregate route -counters now physically in `ts_ops_counters`, CSP rollout with -server-selected `policy_id` report paths and closed buckets — ingest -bounds per §5.6). +- The static renderer document route registers **unconditionally in + every adapter** (the APS provider stays config-gated); startup + validation fails if an auth handler pattern covers it; §G5 isolation + rules apply. +- Path `/integrations/aps/renderer/v1`, embedded, served + `Cache-Control: public, max-age=31536000, immutable`; canary versions + `no-store` (or bounded below cohort lifetime); a **checked-in header + manifest per renderer version** freezes headers (CSP included) with + the bytes — a version's headers never change after publication. + Unknown versions → 404 `no-store`. +- Three-message acknowledgement per G4b sequence 1. +- Server route counters are aggregate (`ts_ops_counters`) — the nonce + rides the URL fragment and never reaches the server. +- **CSP rollout, three instruments:** discovery on the **currently + enforced** policy with reporting attached (report-only alone cannot + reveal what the enforced policy already blocks); **tightening** via + report-only; **relaxation** via a small enforced cohort on a + short-lived canary version, gated on runner acceptance rate, CSP + violation rate, and render-failure rate, with a kill switch; once + frozen, a new immutable `/v2` ships. **CSP reports are advisory** — + for opaque-origin reports the body-supplied document URL and policy + version are forgeable, so policy identity is encoded in the + **server-selected report path** (`/_ts/csp-reports/`); + bucketed aggregation only (§5.6 buckets, global and per-cohort caps); + never a sole automatic rollback signal. Browser capture on Chromium, + Firefox, and WebKit (CI is Chromium-only today, + `playwright.config.ts:16`; the matrix extends for this suite). ### 6.7 One descriptor schema -As revision 6 (generated JSON-Schema + TS parser + ES5 inline fragment + -fixtures; semantic validators handwritten; outer tolerance only; the -per-reason `source` validity matrix of §5.1 joins the generated artifacts). +Wire truth is the tagged `BidRenderer` envelope (discriminator on the +enum, `types.rs:188-211`). A wire-schema crate/xtask (separate from +`trusted-server-js` — core already depends on it, `Cargo.toml:45`, so +the reverse edge would cycle) generates: the JSON-Schema artifact, the +TS structural parser, the ES5 inline validator fragment, the §5.1 +per-event/per-reason validity matrix, and shared fixtures — checked in, +staleness-gated. Semantic checks stay handwritten on both sides +(URL/origin policy, canonical base64, length bounds, the exact one-bid +AAX projection, cross-field equality). Unknown-field tolerance applies +only to the outer versioned descriptor; the decoded AAX envelope +remains an exact projection. A shared positive + adversarial corpus +(extra fields, wrong versions, oversized payloads, URL smuggling, +non-canonical base64) runs through the Rust validator, the generated TS +parser, and the generated inline fragment in CI. ### 6.8 Bridge hardening -As revision 6: parse → identify TS-reserved (live **or tombstoned**, G2) → -`stopImmediatePropagation` → validate source (bounded walk) → -nonce/token/`nav_gen`/`refresh_gen` → respond or refuse. Non-TS ids -untouched. Stolen-token test proves neither TS nor native Prebid responds. +Processing order (normative; preserves the baseline defense that stops +propagation before source validation, `gpt/index.ts:1584-1637`): + +1. parse `e.data` (bare catch → return); +2. identify a TS-reserved ad id — **live registry or tombstone** (G2); +3. if TS-reserved: `stopImmediatePropagation()` before any validation — + a rejected foreign frame must not be answerable by Prebid's native + handler either; +4. validate source ownership via the bounded walk: known slot-root + `WindowProxy` map, the sender's own parent chain + (`event.source.parent`, …) to depth 5 — never scanning an + attacker-controllable frame tree; +5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); +6. respond, or refuse with `bridge_id_mismatch`. + +Non-TS ad ids are untouched. The stolen-token browser test asserts +**neither TS nor the native Prebid listener responds**; listener +registration order has a real-browser assertion. Renderer branches emit +the full §5.1 sequence with G4d notifications only on carrying paths. ## 7. TSJS target architecture ### 7.1 Layering -As revision 6, with G3's corrected lint mechanics and the adapter-scope -clarification (external ad-tech globals only). - -### 7.2 Adapters / 7.3 Slot registry - -As revision 6 (registry holds the G4a causal queue; cycle/drain state -RuntimeSession; unissued intents NavigationSession). - -### 7.4 Final global surface - -Revision 6's table **plus** the row the review found missing: - -| Legacy surface | Final shape | -| -------------- | ----------------------------------------------------- | -| (new, public) | `tsjs.definePlugin({id, release, install, dispose?})` | - -Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que ||= -[]; tsjs.boot ||= {}`; the ad-slot script's `window.tsjs = {}` at -`publisher.rs:3665` is fixed); **transactional ownership**: states -`unclaimed → installing → kernel | fallback`. The kernel installs wrappers -**inert** and flips them live at a single commit point; on a throw before -commit it unwinds its registered disposers (the §7.6 machinery applied to -kernel boot itself) and marks `failed`; the 10 s watchdog treats a stuck -`installing` as failed; **fallback claims ownership only from -`unclaimed | failed`** — never beside a committed kernel. A bundle -arriving after fallback committed defers for the page (`bundle_partial`). -Tests: throw injected after each boot checkpoint. - -### 7.5 Messaging / 7.6 Plugins and sessions / 7.7 Bootstrap / 7.8 GPT / 7.9 Decomposition +``` +kernel/ boot, config, queue, event bus, log, beacon, sessions +adapters/ googletag.ts, pbjs.ts, messaging.ts ← only access to external ad-tech globals +services/ slots (registry+handoff), auction client, render engine, consent +integrations/ gpt, prebid, aps, creative, datadome, … +``` -As revision 6 (plugin API object form with `release`; transactional -install; sessions; generated no-bundle fallback; unconditional GPT -subscriptions; decomposition table). +Enforced by the two G3 lint rule families. Dissolves the audited +inversions (`core/auction.ts`/`core/request.ts` → +`integrations/aps/render`; `gpt`/`prebid` → `aps`; `prebid` owning the +GPT refresh wrapper). Kernel imports nothing above it; adapters import +kernel only; services import kernel + adapters; integrations import +kernel + services, never each other; stateful services via the G3 +registry only. + +### 7.2 Adapters + +Per external global: `present | pending | timed_out`; `timed_out` is +non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and +drains what is still valid); queued operations carry their own timeouts +and expire with disposition reasons. + +### 7.3 Slot registry service + +Kernel-owned; `WeakMap` + div-id index; +ownership (ts/publisher/adopted), adoption, handoff claims, responsive +resolution, the G4a causal intent queue (NavigationSession for unissued +intents) and cycle/drain state (RuntimeSession), targeting-key history. +No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid` +deleted). + +### 7.4 Final global surface (hard cutover) + +| Legacy surface (removed at cutover) | Final shape | +| --------------------------------------- | --------------------------------------------------------------------- | +| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue | +| `globalThis.tscreative` | `tsjs.creative.*` | +| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | +| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) | +| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` | +| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | +| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | +| `tsjs._internal` | kernel registry (G3), frozen after boot | +| (new, public) | `tsjs.definePlugin({id, release, install})` | + +**Bootstrap correctness and transactional ownership:** every +server-injected initializer creates the container **idempotently, +field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}` +(the ad-slot script's `window.tsjs = {}` at `publisher.rs:3665` is +fixed). Ownership states: `unclaimed → installing → kernel | fallback`, +with an **owner generation** counter. The kernel installs wrappers +**inert** and flips them live at a single commit point; a throw before +commit runs the shared unwind inventory and marks `failed`. **The +watchdog path is race-free:** on a 10 s stuck `installing`, the +watchdog aborts the owner-generation-scoped `AbortController`, runs the +same shared unwind inventory to completion, and only then atomically +transitions `failed → fallback`; **every late kernel continuation and +disposer validates the owner generation** and self-discards on +mismatch — a resumed async installation can neither overwrite fallback +wrappers nor perform a stale commit. A bundle arriving after fallback +committed defers for the page (`bundle_partial`). Tests: throws +injected after each boot checkpoint **and** hung checkpoints that +resume after fallback claims ownership. + +### 7.5 Messaging module + +All `postMessage` through one module: versioned envelopes, name +constants (the `'Prebid Request'` literal appears at six sites today; +the APS handshake existed in three copies), G4b nonces, §6.8 +validation. The minimal module (envelope + constants + validators used +by the bridge) lands in Phase 1; full call-site migration in Phase 4. + +### 7.6 Plugin lifecycle — transactional — and sessions + +`tsjs.definePlugin({id, release, install})` — object form; `release` is +the build-generated `release_id` constant; **there is no plugin-level +`dispose` hook** — disposal is exclusively `ctx.onDispose` +registrations, which have exactly-once reverse-order semantics +(revision 7's optional `dispose?` had no defined ordering and is +removed). `install(ctx): void | Promise`: + +- `ctx.signal` (aborted on quarantine/disposal); synchronous + `ctx.onDispose(fn)`; effects registered as they are made; + reverse-order unwind on throw/reject/abort; per-disposer exception + isolation; a disposer registered after disposal is invoked + immediately; pending late registrations capacity 16, bound 10 s → + `bundle_partial`; release mismatch quarantines before `install`. +- Sessions: `RuntimeSession` (page lifetime: bridge listener + + reservation store, history hook, pbjs subscriptions, adapters, beacon + queue, physical slot cycle/drain state, in-memory diagnostic + credential); `NavigationSession` (per navigation: trace + + authorization + renewal timer, render attempts, slot aliases, + unissued intents, targeting history); `RenderAttempt` (per G4a cycle + / G4f attempt). Each owns an enumerable disposal inventory; + navigation disposes NavigationSession children only. +- Error policy: no empty `catch` — handle, log with context, or emit a + disposition. The auction fetch gains timeout + `AbortController` and + the G4f discriminated result. +- **Console logging retained, not replaced:** every issue-surfacing + condition keeps or gains a `log.warn` carrying the same reason code + as its beacon event; `debug`-level delivery/security failures are + promoted to `warn`. + +### 7.7 Bootstrap + +`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/ +hydration logic, with the live `servicesEnabled` divergence) shrinks to +a queue-and-flags stub; the bundle replays recorded early calls on +install (browser specs cover replay timing); the no-bundle fallback +("ads render if the bundle fails", pinned by `gpt.rs:1174-1179`) is +**generated from the same TypeScript source** at build time, activated +per §7.4's transactional rules. + +### 7.8 GPT correctness carried with the restructure + +Unconditional early `slotRequested`/`slotRenderEnded` subscription +(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent +recording); restore the #922 orphan-slot recovery and `updateRender` +enrichment (DR-3 decides #997 vs re-merge); `changeCorrelator: false` +on TS-initiated refreshes (configurable); `enableSingleRequest()` only +when GPT services are not already enabled; ambiguous responsive +resolution emits `render_fail{slot_unresolved}` alongside its console +warning. + +### 7.9 Decomposition targets + +| Today | Target | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | +| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | +| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | +| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | +| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | + +### 7.10 Performance (reproducible) + +- **Dedicated workflow** pinned `runs-on: ubuntu-24.04` (browser CI is + `ubuntu-latest` today, `integration-tests.yml:155`) inside a pinned + container image digest; browser = the lockfile-resolved + `@playwright/test` build with its browser revision recorded in the + baseline artifact (the manifest is a caret range, + `browser/package.json:10` — lockfile + recorded revision are + authoritative); compressors pinned by version in the container; + deterministic flags (`gzip -9 -n`, `brotli -q 11`). +- **Module vectors enumerated:** minimal = `[core]`; reference = + `[core, creative, gpt, prebid, datadome]`; maximal = all 13 + discovered modules. Budgets: raw/gzip/Brotli per bundle per vector vs + checked-in baselines (`perf/baselines/*.json`; updates are reviewed + diffs recording image/browser/tool versions; a baseline update is + invalid if any pinned component differs); +5% bytes. +- **Browser timing:** marks `performance.mark("tsjs:bids-script")` + (emitted by the injected bids script) to + `performance.mark("tsjs:first-display")` (emitted by the adapter + wrapper at the first `display()`/`refresh()` dispatch); reference + fixture page; warm HTTP cache; all resources local; 5 warm-ups + discarded, 50 samples; p90 = nearest-rank; gate p90 ≤ baseline × + 1.10; inconclusive (3-run agreement worse than 5%) → one rerun, then + fail. **Maximal-vector peak JS heap ≤ baseline × 1.10.** +- **Server benchmark:** the G5 lookup path; 100 warm-ups, 1,000 + iterations; median and p90; one-sided ≤ baseline × 1.10; 3 + consecutive runs within 5% or inconclusive (rerun, never pass). -### 7.10 Performance (fully reproducible) +### 7.11 Toolchain -Revision 6's pinned workflow, plus the missing definitions: **module -vectors enumerated** — minimal = `[core]`; reference = `[core, creative, -gpt, prebid, datadome]`; maximal = all 13 discovered modules. **Browser -timing marks**: `performance.mark("tsjs:bids-script")` emitted by the -injected bids script and `performance.mark("tsjs:first-display")` emitted -by the adapter wrapper at the first `display()`/`refresh()` dispatch; -metric = duration between marks on the reference fixture page (the -integration-tests reference page), warm HTTP cache, all resources served -locally (no external network); p90 = nearest-rank over 50 samples; -inconclusive (3-run agreement worse than 5%) → one rerun, then fail. -**Maximal-vector peak JS heap ≤ baseline × 1.10.** Server benchmark: the -G5 lookup path (not concatenation), 100 warm-ups, 1,000 iterations, median -and p90, one-sided ≤ baseline × 1.10. +TypeScript floor to the resolved 5.9 line (lockfile resolves 5.9.3 +under the stale `^5.5.4` manifest). **Release-gating flags:** `strict`, +`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, +`verbatimModuleSyntax`, `noImplicitOverride`, +`useUnknownInCatchVariables`. **Gate command (checked-in npm script +`typecheck`):** -### 7.11 Toolchain +``` +cd crates/trusted-server-js/lib && npx --no-install tsc -p tsconfig.json --noEmit +``` -TypeScript floor to the resolved 5.9 line. **Release-gating flags, -enumerated:** `strict`, `noUncheckedIndexedAccess`, -`exactOptionalPropertyTypes`, `verbatimModuleSyntax`, -`noImplicitOverride`, `useUnknownInCatchVariables`; CI command: -`npx tsc -p crates/trusted-server-js/lib/tsconfig.json --noEmit`. Dev -toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from -casual bumps; monthly review. +(runs where the pinned compiler is installed; `--no-install` guarantees +the lockfile-resolved binary). Dev toolchain bumps as individual +CI-gated PRs with changelog review (this library monkeypatches +`fetch`/`sendBeacon`/DOM prototypes); `prebid.js` excluded from casual +bumps (runtime Prebid is the manifest-locked external bundle; npm pin +and deployed bundle version documented together); monthly review. ## 8. Rollout -Single-release state machine per §0 (sticky-cohort affinity). **The -normative gates table ships with this design as Appendix A**, including -initial thresholds, queries, commands, cohort keys, sample floors, owners, -hold/rollback actions, and the real-GAM suite's operational row. Threshold -changes require reviewed decision records. - -Phases (build milestones inside the one release): +Single-release state machine per §0 (authenticated sticky-cohort +affinity; infrastructure-attributed cohorts). The normative gates table +is Appendix A; every gate names a **checked-in artifact** (versioned +Tinybird pipe under `tinybird/pipes/`, script under `scripts/gates/`, +or workflow under `.github/workflows/`) — prose never substitutes for +an executable reference. Threshold changes require reviewed decision +records. + +**Phase 0 decision records** (owner, evidence, deadline, explicit +go/no-go): DR-1 mediator presence (gates §6.1's Phase-3 scope); DR-2 +script creatives — a **deployment decision** (§6.3); DR-3 #997 vs +re-merge (#922 restoration path); DR-4 mediator candidate-id echo owner +and timeline (`merge_highest_cpm` is config-blocked until delivered); +DR-5 non-Fastly sinks (splits Phase-2 gates). - **Phase 0 — Identity, schemas, toolchain, decisions.** Release-time - asset materialization; `format_version`; §5.6 schemas writer-off; - toolchain floors; dead expando deletion; drop surfacing; decision - records DR-1..DR-5 (DR-2 now a deployment decision, §6.2–6.5; DR-4 - gates `merge_highest_cpm`; DR-5 splits Phase-2 gates). + asset materialization; `format_version` + config-hash verification; + §5.6 schemas deployed writer-off; toolchain floors; dead expando + deletion; §5.8 drop surfacing; the five DRs; the gate artifacts + themselves (pipes/scripts/workflows). - **Phase 1 — Kernel, sessions, minimal messaging, cycle registry, transactional bootstrap ownership.** -- **Phase 2 — Trace + beacon.** All three issuance paths + renewal + - diagnostic credential + probe mode; four-adapter ingest incl. - `/_ts/trace-auth` in the isolation family; heartbeats. Gates split: - HTTP parity (all adapters) vs persistence (sink-backed). -- **Phase 3 — APS delivery.** As revision 6, plus `notification_sent` - observability and per-flow funnel gating (the `flow` field): expected- - stage tables per flow live in the gates file; denominator = server- - observed eligible APS wins **within sampled/diagnostic traces**; - `cycle_unattributable` gated; one-sided non-inferiority for fill/p95; - duplicate-`burl` invariant via `notification_sent` key hashes. -- **Phase 4 — Structure.** Layering, plugins, adapters, registry, - messaging, namespace; four-flow behavioral parity. -- **Phase 5 — Decomposition + cutover.** File splits; bootstrap stub + - generated fallback (error/hang/arbitration tests); four-flow parity - rerun; **then the full Phase-3 statistical canary/control gate and the - real-GAM suite are repeated on the exact immutable release candidate** - before router weight rises beyond the low-weight canary; then weight-up, - purge, 24 h monitored window. +- **Phase 2 — Trace + beacon.** Issuance on all three paths + renewal + + diagnostic upgrade + probe mode; four-adapter ingest incl. + `/_ts/trace-auth`; per-sink probes. Gates split per DR-5: HTTP parity + (all adapters) vs persistence (sink-backed). +- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required + `winner_selection` + `[auction].currency`; render token + reservation + store; renderer route + three-message ack + CSP report route; §6.8; + G4a–G4g; `notification_sent` with server-minted `notif_id`; fallback; + DR-3 restoration; correlator + SRA fixes. +- **Phase 4 — Structure.** Full layering + both lint families; plugin + lifecycle; adapters; full slot registry; full messaging migration; + final namespace; four-flow behavioral parity. +- **Phase 5 — Decomposition + cutover.** File splits; script-guard + consolidation; bootstrap stub + generated fallback (error/hang/ + arbitration tests); four-flow parity rerun; **the full Phase-3 + statistical canary/control gates and the real-GAM suite repeat on the + exact immutable release candidate** before router weight rises beyond + the low-weight canary; then weight-up, purge, 24 h monitored window. + +**Statistical method (normative for A.1 production gates):** +populations are **sampled traces only** — diagnostic traffic is +operator-selected and failure-enriched, so it is reported separately +and never enters a statistical gate. Assignment unit = the sticky +cohort token (browser session); cohorts randomized at HTML request; +stratification by publisher and slot. Non-inferiority gates use +**one-sided 95% confidence bounds on the relative difference** +(canary/control − 1 ≥ −2% for fill and billing-per-1,000-attempts; +canary/control − 1 ≤ +2% for p95 latency); improvements always pass. +Floors are **per flow per arm** (Appendix A); flows that cannot reach +their floor in the window (direct, fallback at low adoption) are gated +hermetically and by the real-GAM suite instead of statistically — a +rare flow never permanently blocks rollout, and a statistical gate that +cannot reach its floor is **inconclusive** (extend once, then Hold). +`cycle_unattributable` is divided by **all TS request cycles that were +candidates for attribution** — the failures live in their own +denominator. Missing telemetry counts as failure. Billing reconciliation +(§G4d) runs alongside as the authoritative duplicate check. ## 9. Test acceptance matrix -Revision 6's matrix stands, with these added/changed rows: - -| Area | Added coverage | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Request cycles | publisher `display()` under disabled initial load retired at issuance; publisher-display → TS-refresh attribution test; symmetric ambiguity | -| Trace auth | renewal presents current token and preserves mode; renewal-after-expiry fails closed; deterministic sampling (same trace → same mode across concurrent requests) | -| Diagnostic | credential issuance under admin auth + CSRF; fragment→sessionStorage transport; upgrade of an initial sampled trace; auth `exp` capped at credential expiry; forgery/wrong-origin | -| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket | -| Affinity | sticky-cohort coherence: new-pool HTML never fetches control-pool assets/APIs (cache-key + routing test); rollback binding prevalidation | -| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; `release_id` cohort attribution | -| Funnels | `flow` field set per path; per-flow expected-stage conformance (SSAT, prebid, page-bids, direct, fallback) | -| Tombstones | union capacity 320; unexpired never evicted; >320 registrations then late oldest-id request suppressed; `registry_full` on refusal | -| Ack per path | all four G4b sequences incl. adm reporter snippet; per-path document/runner deadlines; cancellation on navigation | -| Terminal states | exactly one terminal per attempt; post-accept runner failure emits observation + `billing_outcome`, no second terminal | -| Notifications | `notification_sent` emitted per dispatch; duplicate-key-hash query returns zero in canary; direct-flow `(bid_id)` identity; server preserves/expands `nurl`/`burl`; client validates | -| Bootstrap | inert-install + commit flip; throw after each checkpoint unwinds; watchdog claims only from failed/stuck; fallback-then-late-bundle deferral | -| Overflow | out-of-band counter; coalesced overflow event in next flush; no recursion at full queue | -| Heartbeats | probe mode not sampled out; excluded from product denominators; freshness/loss queries from `expected_seq` | -| CSP ingest | pre-buffer caps (8 KiB / 10 reports / 256 chars / depth 4); both media types; opaque origin; policy-id path identity; aggregate schema rows | -| Mediation | required upstream id bounds; fingerprint fallback determinism under arrival shuffle; duplicate-id dedup; adm-swap reclassification; APS + non-USD startup error | -| Limiter | TTL reclaim under saturation; unknown-address bucket; Fastly overshoot bound; XFF hop selection | -| Perf | marks present; vector contents; heap budget; inconclusive-rerun policy | -| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters | -| Kill switch | pre-commit attempts cancelled; post-commit attempts run to terminal; snapshot semantics | - -## 10. Alternatives / 11. Risks - -As revision 6, plus: **rejected** — per-request Fastly concatenation -(replaced by release-time materialization); trusting client-asserted -sampling on renewal (replaced by token-presentation renewal); FIFO -tombstone eviction (replaced by union capacity with refusal). Risk added: -sticky-cohort routing is new infrastructure the cutover depends on — it is -Phase 0 work and its coherence test is release-gating. +Hermetic CI blocks PRs; the real-GAM suite is release-gating per +Appendix A.3. + +| Area | Must cover | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request cycles | intent-vs-request; disabled-initial-load `display()` retired for **any caller**; publisher-display → TS-refresh and TS-noop-refresh → TS-display supersession (same-class stealing); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap | +| Ack protocol | all four G4b sequences incl. the adm reporter; three-message APS sequence; five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed acks; acks after navigation disposal; per-path deadlines | +| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; prior-navigation ids suppressed via the reservation store after disposal; bounded parent-chain walk; listener order (real browser) | +| Render semantics | binds per flow (never targeting); `burl` at accepted; attempt idempotency; `notification_sent{notif_id}` per dispatch; duplicate-detection alarm; accepted-but-blank honesty; `gam_collapsed{action}` emission + guarded resize | +| Direct `/auction` | trace header + `ext.trusted_server.trace` echo; discriminated auction-client errors (timeout/network/http/invalid vs `no_bid`); per-slot latest-wins with reversed responses; generation checks; `RequestAdsResult` settlement; server preserves + expands `nurl`/`burl`, client validates | +| Fallback | child-attempt identity with `parent_flow`; renders only on attributed parent `gam_empty`; publisher-initiated never; timeout never renders; SPA cancellation; kill-switch pre-commit cancellation (commit = earliest irreversible action) | +| Mediation | required-unique upstream ids (`missing_bid_id`/`duplicate_bid_id` rejection — no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules (repricing kept; any render-source difference → native); provenance fail-closed scope; strategy-specific timeouts; both lifecycles; APS + non-USD startup error | +| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed | +| Trace auth | auth ≤ 256 B; encoding vectors (kid charset, canonical exp, u32/u64 BE prefixes, unpadded base64url); expiry/skew/max-future; renewal preserves mode via token presentation; renewal-after-expiry fails closed; previous-key retention; deterministic sampling (same trace → same mode concurrently; exact u64 threshold algorithm) | +| Diagnostic | credential issuance under admin auth + CSRF; fragment cleared via `replaceState`; in-memory-only storage; upgrade as the sole mode transition; auth `exp` capped at credential expiry; pre-upgrade local buffering then diagnostic flush; forgery/wrong-origin/replay-past-expiry | +| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket | +| Affinity | opaque token validation (forged/expired/retired → control + reissue); coherence for HTML, assets, APIs, **beacons, CSP reports**; cache-key normalization; rollback reassignment | +| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; infrastructure cohort attribution (per-pool tokens) | +| Funnels | `flow` set per path incl. `system`; per-flow expected-stage conformance; heartbeat/overflow excluded from render denominators | +| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; overflow coalescing without recursion; ingest abuse incl. absent Origin; sendBeacon Blob type; `credentials: same-origin` with identity-free handling | +| Ingest/limits | per-adapter limiter semantics as declared; TTL reclaim under saturation; unknown-address bucket; Fastly synchronized-burst behavior documented (> 40 concurrent); XFF hop selection; fail-closed 204 | +| Internal routes | wrong-method 405 + `Allow` + `no-store` on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding; per-family origin policies | +| CSP | both media types with separate validators; opaque/null-origin admission; policy-id path identity (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; per-version frozen header manifest | +| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection; generated validity matrix; **compile-time exhaustiveness of `AuctionDropReason` over all producers** | +| Runtime ABI | one kernel under concatenation; exact-release verdicts; late registration; failure isolation; object-form `definePlugin` release check | +| Plugins | partial-install unwind; async rejection; abort while pending; disposer-after-disposal; per-disposer isolation | +| Bootstrap | field-wise idempotent init (ad-slot script no longer clobbers); inert-install + commit flip; throw after each checkpoint unwinds; **hung checkpoint resuming after fallback self-discards via owner generation**; fallback-then-late-bundle deferral | +| Lifecycle | `timed_out → present`; session disposal inventories; unissued intents cancelled by navigation disposal; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`, `definePlugin`) | +| Delivery | unknown hash 410 `no-store`; exact-match immutable with full directive; release-time vector materialization (unlisted vector = build error); config-hash verification failure; cutover rehearsal (weight, purge, rollback) | +| Sinks/monitoring | per-datasource probes (client-events heartbeat, CSP probe policy-id, ops probe counter) per adapter write path; canonical-views-only enforcement (raw-join multiplication test); `publisher_domain` naming | +| Failure injection | Amazon runner redirect/hang/CSP block/script error → distinct §5.1 outcomes; EC/filter failure before renderer dispatch | +| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | +| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness per §2.5; kill-switch snapshot semantics | +| Perf | marks present; three vector contents; heap budget; inconclusive-rerun policy; pinned-environment baseline validity | +| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters | + +## 10. Alternatives considered + +1. Patching APS point-failures without telemetry — rejected: four + correct fixes have not produced reliable ads. +2. Always direct-render APS (skip GAM/PUC) — rejected: changes GAM + reporting/pacing unilaterally; kept as the attributed-`gam_empty` + fallback. +3. Single module graph / shared chunks now — rejected for this release: + changes the delivery pipeline while everything else changes; + successor option behind the same registry surface. +4. Full rewrite in one branch without phases — rejected: the + browser-spec safety net is thinnest exactly where behavior changes. +5. Dropping the ES5 bootstrap — rejected: loses the pinned no-bundle + guarantee; the generated fallback keeps it. +6. Timeout-triggered fallback rendering — rejected: GPT requests cannot + be cancelled; timeout racing a late fill can double-render and + double-bill. +7. Timeout-based quarantine re-arm — rejected (recreates the stale-event + bug). +8. N/N−1 compatibility machinery — removed by the §0 policy decision. +9. Client-computed notification hashes — rejected (no key without + breaking the pseudonymization boundary); server-minted `notif_id`. +10. Fingerprint identities for id-less bids — rejected (can merge + distinct demand); rejection with closed reasons instead. +11. Plain readable cohort cookie — rejected (dark-pool opt-in + + cache-cardinality abuse); opaque authenticated token. +12. `billing_outcome` event — removed (no honest producer exists). + +## 11. Risks + +- Hard-cutover blast radius — accepted by policy; bounded by the §0 + runbook (probes, low-weight canary, 24 h window, weight-back + rollback). +- Sticky-cohort routing is new infrastructure the cutover depends on — + Phase 0 work; its coherence test is release-gating. +- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates + `merge_highest_cpm`; config validation enforces the block. +- Notification triggers become a published contract for PBS-path + demand — changing them later is a breaking change for SSP reporting. +- Required `[auction].currency` and `winner_selection` (mediated + deployments) are a deliberate startup-error class under §0. +- Beacon abuse — pre-parse caps, per-family origin policies, fail-closed + numeric limits, signed modes, credentialed diagnostics. +- Registry/limiter memory — explicit capacities, TTL reclamation, + reject-at-capacity; Fastly overshoot documented, not claimed. +- CSP data is advisory — never a sole rollback signal. +- Sink blindness — per-datasource probes with datasource-side queries. +- ABI freeze — `tsjs._internal.registry` is load-bearing; exact-release + verdicts are the contract. +- Schema generation — checked-in artifacts + staleness CI. ## 12. Success criteria -Revision 6's criteria with these corrections: (2) diagnostic completeness -is achieved via the §2.5 gate split (content vs volume); (5) attempt -counts keyed `(trace_id, nav_gen, refresh_gen, slot)`; (10) the -duplicate-`burl` invariant is measured via `notification_sent` key hashes -in production and by hermetic tests, with external billing reconciliation -as backstop; (add 13) the Phase-3 statistical and real-GAM gates pass on -the exact immutable Phase-5 release candidate before weight-up; (add 14) -the Appendix A gates table shipped with this design and every later change -carries a reviewed decision record; (add 15) the baseline APS fix behaviors -are re-implemented in the target architecture with the baseline browser -tests passing unmodified as the conformance pin. +1. APS creatives render in each configured flow (SSAT, client-Prebid, + page-bids, direct), hermetically and in the release-gating real-GAM + suite per Appendix A.3's enumerated topologies. +2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the + failing class from one page load (including A1–A4 via the debug + envelopes, and including an initially-unsampled page via pre-upgrade + buffering); §5.7 SLIs hold on sink-backed deployments. +3. Both lint families pass with zero exceptions; stateful sharing only + via the registry; exact-release mismatches quarantine loudly. +4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or + generated. +5. Attempt counts key on `(trace_id, nav_gen, refresh_gen, slot)`; + traces stay navigation-scoped; no double counting; orphan recovery + has a non-vacuous test; G4a holds including caller-independent + retirement and same-class supersession. +6. The only TSJS-owned global is `window.tsjs` with the §7.4 final + shape; no expandos; legacy names gone at cutover. +7. §7.10 budgets hold on the dedicated pinned workflow. +8. No existing warning lost; issue-surfacing conditions log `warn`+ + with the beacon's reason code. +9. TypeScript floor matches resolved 5.9 with the §7.11 flags via the + checked-in `typecheck` script; `prebid.js` pin documented with the + deployed bundle. +10. `nurl`/`burl` fire only on carrying paths at their G4d binds, + attempt-scoped and idempotent; APS fires neither; hermetic + exactly-once tests pass; production duplicates alarm via + `notification_sent` and reconcile to zero via billing reports. +11. Trace-bearing responses are `private, no-store`; authorizations + are per-trace, signed, mode-carrying, renewal-preserving, with + diagnostic upgrade as the sole authenticated mode transition; + unsampled traces transmit nothing. +12. The cutover runbook rehearsed (weight switch, purge, rollback); + config-hash verification enforced. +13. The Phase-3 statistical and real-GAM gates pass on the exact + immutable Phase-5 release candidate before weight-up. +14. The Appendix A gates table shipped with this design; every change + carries a reviewed decision record. +15. The baseline APS fix behaviors are re-implemented in the target + architecture with the baseline browser tests passing unmodified. ## 13. Open questions @@ -752,60 +1233,69 @@ post-`render_accepted` state under a new name (future enhancement)? ## Appendix A — Normative rollout gates (initial values) Owners are roles: **RO** = release owner, **QA** = QA owner, **OPS** = -release owner's on-call. Assignment key for canary/control = -sticky cohort (`ts-rel`), randomized at HTML request, per §0. All -production queries run against canonical views only (§5.5). "Hold" = -router weight frozen; "Rollback" = weight to previous release + re-purge. +release owner's on-call. Assignment unit = the authenticated sticky +cohort token (§0), randomized at HTML request, stratified by publisher +and slot. Statistical gates use **sampled traces only** (§8 method); +diagnostic traffic is reported separately. All production queries run +against canonical views via **checked-in versioned pipes** +(`tinybird/pipes/gate_.pipe`); probe/parity suites are checked-in +scripts (`scripts/gates/.sh`) or workflows. "Hold" = router +weight frozen; "Rollback" = weight to previous release + re-purge. Changing any row requires a reviewed decision record. ### A.1 Phase gates -| Phase | Gate | Query / test command | Denominator | Floor | Threshold | Window | Owner | Action | -| ----- | -------------------------- | --------------------------------------------------------------------- | ---------------------------------------------- | ------------- | --------------------------------- | ------ | ----- | -------- | -| 0 | Dark-pool health | probe suite vs dark pool (all four adapters) | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold | -| 0 | Schema validation | synthetic writes to `ts_client_events` + auction rows | synthetic rows | 10,000 | rejection < 0.1% | 24 h | RO | Hold | -| 0 | Asset identity | probe: every manifest hash 200-immutable; unknown hash 410 `no-store` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold | -| 1 | ABI cleanliness | probe pages: `abi_mismatch` + `bundle_partial` counters | probe page loads | 1,000 | 0 | 24 h | QA | Hold | -| 1 | Bootstrap ownership | hermetic: throw-after-each-checkpoint suite | checkpoints | all | 100% unwind-to-`failed` | CI | QA | Hold | -| 2 | Ingest HTTP parity | parity suite vs all four adapters (routes, 405s, limits, 204s) | parity cases | all | 100% | CI | QA | Hold | -| 2 | Persistence (sink-backed) | acceptance ≥ 99%; dedup exactly-once per `(trace, seq)` | probe batches | 10,000 evts | as stated | 24 h | OPS | Hold | -| 2 | Heartbeat pipeline | freshness lag; `expected_seq` loss | probe heartbeats | 1,000 | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | -| 3 | APS funnel (per flow) | per-`flow` stage rates from `ts_render_attempts_v` (table A.2) | eligible APS wins in sampled+diagnostic traces | 10,000/cohort | per A.2 | 24 h | RO | Rollback | -| 3 | Attribution soundness | `cycle_unattributable` rate | attributable-candidate cycles | 10,000 | < 0.5% | 24 h | RO | Rollback | -| 3 | GAM fill (non-inferiority) | canary fill vs control | cohort ad requests | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback | -| 3 | Latency (non-inferiority) | canary p95 bids-to-display vs control | cohort attempts | 10,000 | canary ≤ control × 1.02 | 24 h | RO | Rollback | -| 3 | Billing | GAM/server-side revenue per 1,000 attempts, canary vs control | cohort attempts | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback | -| 3 | Duplicate `burl` | duplicate `notification_sent{burl}` per `id_key_hash` | burl dispatches | 1,000 | 0 | 24 h | RO | Rollback | -| 4 | Layering | both lint rules; disposal-inventory leak suite | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | -| 4 | Four-flow parity | hermetic parity: SSAT, prebid, page-bids, direct | parity cases | all | 100% | CI | QA | Hold | -| 5 | Parity rerun + budgets | four-flow parity; §7.10 budgets on pinned workflow | — | — | 100% / within tolerance | CI | QA | Hold | -| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback | -| 5 | Cutover monitor | §5.7 SLIs post-weight-up | production traffic | — | SLIs green | 24 h | OPS | Rollback | - -Low-volume handling: a production gate that cannot reach its floor within -its window is **inconclusive** — extend the window once; a second -inconclusive result is a Hold, never a pass. +| Phase | Gate | Artifact (checked in) | Denominator | Floor | Threshold | Window | Owner | Action | +| ----- | ----------------------------- | ---------------------------------------------------------- | --------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------- | ------ | ----- | -------- | +| 0 | Dark-pool health | `scripts/gates/probe-pool.sh` | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold | +| 0 | Schema validation | `scripts/gates/schema-writes.sh` (deterministic writes) | synthetic rows | 10,000 | **0 rejections** (writes are deterministic) | 24 h | RO | Hold | +| 0 | Asset identity | `scripts/gates/asset-probe.sh` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold | +| 0 | Config binding | `scripts/gates/config-hash.sh` | pools | all | manifest hash verified on every pool | once | OPS | Hold | +| 1 | ABI cleanliness | `gate_abi.pipe` (probe pages) | probe page loads | 1,000 | 0 `abi_mismatch`/`bundle_partial` | 24 h | QA | Hold | +| 1 | Bootstrap ownership | hermetic suite `bootstrap-ownership.spec` | checkpoints incl. hung-resume | all | 100% unwind/self-discard | CI | QA | Hold | +| 2 | Ingest HTTP parity | `scripts/gates/ingest-parity.sh` (4 adapters, 4 families) | parity cases | all | 100% | CI | QA | Hold | +| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | probe batches | 10,000 events | acceptance ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | +| 2 | Per-sink probes | `gate_probes.pipe` (3 datasources × adapters) | probe writes per sink | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | +| 3 | Funnel: ssat/prebid/page_bids | `gate_funnel.pipe` per flow | eligible APS wins (sampled traces), per flow-arm | 10,000 each | per A.2 | 24 h | RO | Rollback | +| 3 | Funnel: direct/fallback | hermetic + real-GAM rows (A.3) — not statistical | suite cases | all | 100% | CI+RG | QA | Hold | +| 3 | Attribution soundness | `gate_cycles.pipe` | **all TS request cycles candidate for attribution** | 10,000 | `cycle_unattributable` < 0.5% | 24 h | RO | Rollback | +| 3 | GAM fill | `gate_fill.pipe` | cohort ad requests per arm | 10,000 | one-sided 95% CB: rel. diff ≥ −2% | 24 h | RO | Rollback | +| 3 | Latency | `gate_latency.pipe` | cohort attempts per arm | 10,000 | one-sided 95% CB: p95 rel. diff ≤ +2% | 24 h | RO | Rollback | +| 3 | Billing | `gate_billing.pipe` + GAM report reconciliation | attempts per arm (per-1,000 normalization) | 100,000 or 7 d | one-sided 95% CB: rel. diff ≥ −2% | window | RO | Rollback | +| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` (detection) + billing reconciliation | burl dispatches | 1,000 | 0 observed duplicates; reconciliation clean | 24 h | RO | Rollback | +| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | +| 4 | Four-flow parity | `flow-parity.spec` (hermetic) | parity cases | all | 100% | CI | QA | Hold | +| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / within §7.10 tolerances | CI | QA | Hold | +| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback | +| 5 | Cutover monitor | `gate_slis.pipe` | production traffic | — | probe lag ≤ 5 min; probe loss < 0.1%; `render_fail` rate ≤ pre-cutover canary + 0.5 pt | 24 h | OPS | Rollback | + +Low-volume handling: a statistical gate that cannot reach its floor in +its window is inconclusive — extend once; a second inconclusive is a +Hold, never a pass. ### A.2 Expected stages per flow (Phase 3 funnel) -| Flow | Expected sequence | Stage thresholds | -| --------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each stage ≥ 95% of prior; document-load ≥ 99%; runner fail+timeout ≤ 1% | -| prebid | same as ssat (keyed by Prebid `adId`) | same | -| page_bids | same as ssat (after SPA navigation) | same | -| direct | `render_attempt → renderer_document_loaded → render_accepted` (no bridge stages) | document-load ≥ 99%; accepted ≥ 95% of attempts | -| fallback | `gam_empty → fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of fallback starts | +Denominators are named per stage; document/runner rates apply wherever +the renderer document participates. + +| Flow | Expected sequence | Stage thresholds (each vs its named denominator) | +| --------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each ≥ 95% of prior; `renderer_document_loaded`/`bridge_response_sent` ≥ 99%; `runner_failed`+timeouts ≤ 1% of `renderer_document_loaded` | +| prebid | same as ssat (keyed by Prebid `adId`) | same | +| page_bids | same as ssat (after SPA navigation) | same | +| direct | `render_attempt → renderer_document_loaded → render_accepted` | document ≥ 99% of attempts; accepted ≥ 95% of attempts (hermetic/real-GAM gate, not statistical) | +| fallback | parent `gam_empty` → child `fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of `fallback_start` (hermetic/real-GAM gate, not statistical) | ### A.3 Real-GAM suite (operational row) -| Field | Value | -| ------------------ | -------------------------------------------------------------------------------------- | -| Workflow | `real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | -| Topologies | one per flow in A.2, plus publisher-overlap and disabled-initial-load formation (G4a) | -| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) | -| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | -| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | -| Command | `npx playwright test --config real-gam.config.ts` from the browser test package | -| Artifact | Playwright HTML report + trace zips, uploaded as workflow artifacts, retained 90 days | -| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | -| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO | +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | +| Topologies | one per A.2 flow, plus publisher-overlap and disabled-initial-load formation (G4a), plus the same-class supersession case | +| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) | +| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | +| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | +| Command | `npx playwright test --config real-gam.config.ts` from the browser test package | +| Artifact | Playwright HTML report + trace zips as workflow artifacts, retained 90 days | +| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | +| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO | From 9503c9bf167ef5a4d400ea863a65817441a0c1bf Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:40:13 -0700 Subject: [PATCH 550/844] Revise design spec for the eighth review round Revision 9 closes the mandatory set: distinct attempt_id with nullable parent_attempt_id (fallback children are separate rows; attempt views key on attempt_id, the tuple is grouping only); one discriminated render_terminal event replacing the unrepresentable accepted/fail pair; diagnostic renewal bounded forever by a signed dexp ceiling; telemetry batching capped by both events and bytes with ingest budgets derived from worst-case honest traffic so the limiter cannot reject steady state; CSP report affinity routed by server-selected policy_id path instead of the cookie the opaque renderer cannot send; an observation-only control build plus arm-specific datasources and a stamped deployment_pool so the control arm can populate sampled gates; assignment_id as the clustered randomization unit with a checked-in bootstrap estimator and named numerator/denominator/source per gate; t_rel_ms as the monotonic latency basis; the ADM acknowledgement moved into the trusted owner so the nonce never enters the bidder realm; an AuctionBatch owning multi-slot fetch cancellation; a read-only bridge lookup that emits the altered-id matched:false signal without suppressing native Prebid; per-datasource authenticated probes with an injected-failure alert drill; complete CSP and ops schemas, settings, and sink handles; Nullable(UUID) join typing with explicit grains; AuctionDropReason exhaustive over baseline producers via one shared typed enum with a compile-time test; the withdrawn Fastly overshoot multiple; a page-bound kill switch honestly scoped; config_hash SHA-256 verification and a fully specified affinity token with vectors; state-dependent post-cutover routing defaults; the removed plugin dispose hook; normative notification dispatch mechanics; a reproducible CDP heap procedure; and RC attestation binding the real-GAM gate to the exact immutable build. --- ...s-render-fix-and-tsjs-resilience-design.md | 1854 ++++++++--------- 1 file changed, 814 insertions(+), 1040 deletions(-) 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 74e08c867..b988e6286 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 @@ -1,83 +1,85 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 8 — reworked after the seventh review round and made - fully self-contained: no contract in this document is defined by reference - to an earlier revision. +- **Status:** revision 9 — closes the eighth review round's mandatory set: + attempt schema, diagnostic renewal, telemetry/gate model, CSP routing, and + rollout measurement. Fully self-contained. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed GAM shells"). All file:line citations refer to this commit. -- **Inputs:** three code audits; design reviews of revisions 1–7; open issues +- **Inputs:** three code audits; design reviews of revisions 1–8; open issues #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997. -- **Normative gates:** the initial rollout-gates table is **Appendix A**; - changes require reviewed decision records so thresholds cannot be chosen - after observing results. -- **Adoption stance for the baseline APS fixes (`248fe9558`):** this design - adopts their **contracts** — the MessageChannel handshake semantics, the - collapsed-shell remediation behavior, the consolidated bridge branch — and - **re-implements them inside the target architecture** (the messaging module - owns the channel protocol, the render engine owns the resize, the rebuilt - `render_bridge` module owns the branch). The patch code is not carried - forward; the baseline's browser tests are retained unmodified as the - conformance suite pinning the adopted behavior. +- **Normative gates:** Appendix A ships with this design; changes require + reviewed decision records. +- **Adoption stance for the baseline APS fixes (`248fe9558`):** contracts + adopted (MessageChannel handshake semantics, collapsed-shell remediation, + consolidated bridge branch), implementations rebuilt inside the target + architecture; the baseline browser tests pass unmodified as the + conformance pin. ## 0. Release policy: coordinated hard cutover -One coordinated release: - -- Server, TSJS bundles, config, and HTML ship under one **`release_id`** - (git tag / build hash). **No N/N−1**; in-flight clients may fail at - cutover — accepted and stated, not mitigated. -- **Exact release matching**: kernel, services, plugins, and the install - manifest carry the same `release_id`; mismatch is a refusal. -- **Config is a release-time, content-verified input.** The config blob gains - a top-level `format_version` (exact match required). Publish order: blob - first, deployment manifest second. The manifest binds each pool to - immutable `{release_id, config_store, config_key, config_hash}`, and the - binary **verifies the loaded blob's hash against the manifest at - startup** — a mismatch is a startup failure, so a config overwrite cannot - mutate a supposedly immutable release or invalidate pre-materialized asset - vectors. Rollback = redeploy the previous release with its own verified - config; the rollback binding is prevalidated. -- **Assets:** binaries embed only their release's artifacts; hashed pathnames - exist for cache identity; unknown hash → `410 Gone`, `no-store`. -- **Rollout state machine with authenticated release affinity.** The new - pool comes up fully enabled, reachable only by probes. Canarying uses a - **sticky, opaque, authenticated cohort token**: the router sets `ts-rel` - on the HTML response — an HMAC-signed opaque value binding - `{publisher_host, release_id, cohort, exp}` (attributes: `Secure; -HttpOnly; SameSite=Lax; Path=/`; TTL 24 h) — and routes every subsequent - request by the **validated** token; invalid, expired, forged, or - non-allowlisted tokens route to control and are reissued; tokens for - retired releases are reassigned on next HTML response after rollback. - Cache keys use the post-validation release label (bounded cardinality; - raw cookie values never key caches). A plain readable release id would - let any visitor opt into the dark pool and would hand cache-key - cardinality to attackers — hence opaque and authenticated. Because - affinity rides a cookie, **the beacon and CSP-report transports use - `credentials: "same-origin"`** (not `omit`): the cookie exists for the - routing layer only; application handlers still derive no identity from - it. Router weight over sticky cohorts is the sole activation primitive; - flags are in-pool emergency kill switches. Cutover = weight 100% + CDN - purge; rollback = weight back + re-purge. The affinity acceptance test - covers HTML, assets, APIs, **beacons, and CSP reports**. -- **Canary/control discrimination is infrastructure-attributed:** each pool - writes telemetry with **pool-specific datasource tokens**, so cohort - attribution comes from the write identity, not from in-row fields the - control binary (the baseline) does not emit; in-row `release_id` from the - new pool is secondary confirmation. +- One release: server, TSJS bundles, config, HTML under one **`release_id`**. + No N/N−1; in-flight clients may fail at cutover — accepted and stated. +- **Exact release matching**; mismatch is a refusal. +- **Config is a release-time, content-verified input.** `format_version` + exact-match. **`config_hash` = SHA-256 over the exact fetched envelope + bytes**, bound through compiled release metadata (or a signed manifest + with an embedded verification key); the binary verifies at startup and + fails loudly on mismatch. Publish order: blob, then manifest. Rollback = + redeploy the previous release with its own verified config; binding + prevalidated. +- Assets: embedded only; hashed pathnames for cache identity; unknown hash → + `410`, `no-store`. +- **Authenticated sticky affinity.** The router sets `ts-rel` on HTML + responses — format `r1...... +`: `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp` (TTL 24 h); + `release` from the allowlist; `cohort ∈ {canary, control}`; + `assignment_id` = 32-hex CSPRNG (the pseudonymous **randomization unit**, + §8); `sig` = unpadded base64url HMAC-SHA-256 over the domain-separated + length-prefixed input `"ts-affinity-v1" || u32be-len fields || +u64be(exp)`; keys owned and rotated by the routing layer (active + + previous, ≥ 24 h retention); constant-time verification; test vectors + checked in. Attributes `Secure; HttpOnly; SameSite=Lax; Path=/`. Cache + keys use the post-validation release label only. +- **State-dependent routing defaults:** during canary, valid tokens route by + their binding; invalid/expired/forged → control + reissue. **After forward + cutover ("weight 100%"), the safe default flips:** stale, invalid, or + control-bound tokens on HTML requests are reassigned to the active + release; non-HTML requests with unknown or stale tokens route to the + active release — 100% means 100%, not "except 24 h of old cookies." + Rollback flips the default back. +- **CSP-report affinity never depends on cookies:** the renderer is + sandboxed without `allow-same-origin` (`aps/render.ts:4`), so its + browser-generated reports are cross-origin to the publisher endpoint and + carry no cookie. Release/cohort identity is encoded in the + **server-generated report path's `policy_id`** (minted per + `{release, policy version, cohort}`, registered in the header manifest); + the router routes `/_ts/csp-reports/` by that registry. +- Beacon and trace-auth transports use `credentials: "same-origin"` so the + affinity cookie routes them; handlers derive no identity from cookies. +- **Canary/control measurement (closing the empty-control-arm gap):** the + control pool for Phase-3 statistics runs an **observation-only control + build** — the baseline plus Phase-2 instrumentation only (trace, beacon, + probes; zero behavior changes) — so both arms emit comparable sampled + telemetry. Arms write to **arm-specific datasources**; the canonical + union view stamps a trusted `deployment_pool` dimension from the write + identity (token → dataset → arm), making the arm a queryable row + dimension rather than an authorization side effect. +- Router weight over sticky cohorts is the sole activation primitive; flags + are in-pool emergency kill switches. Cutover = weight 100% + CDN purge; + rollback = weight back + re-purge. The affinity acceptance test covers + HTML, assets, APIs, beacons, and CSP reports (via path identity). ## 1. Problem statement -APS demand is fully integrated server-side — the edge runs the APS OpenRTB -auction, wins bids, and ships a typed renderer descriptor — yet APS -creatives do not appear reliably. Four serial fixes (the `bid.meta` -carrier, the decoupled shim, the `hb_adid` fallback, the baseline -PUC/collapsed-shell fix) each survived review; the pattern is the finding: -**multiple independent failure points, most failing silently**, with no -client→server signal about which fired. The TSJS library (56 files, -~11,900 lines, two ~1,800-line monoliths, duplicated ES5/TS logic, -inverted layering, ~100 error-swallowing catches) is the same problem -structurally. +APS demand is fully integrated server-side, yet APS creatives do not appear +reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim, +the `hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived +review; the pattern is the finding: **multiple independent failure points, +most failing silently**, with no client→server signal about which fired. +The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths, +duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches) +is the same problem structurally. ### Non-goals @@ -127,28 +129,25 @@ server-side to one that painted. ### 2.5 Failure → signal mapping (normative) -Two gates, precisely separated: the **tester cookie** (explicitly -non-security, `tester_cookie.rs:3`) gates **debug content** — the -`tsjs.boot.debug` envelope and the page-bids/`/auction` -`ext.trusted_server.debug` fields, the same sensitivity class as the -existing tester-gated `ts-debug` comment. The **diagnostic credential** -(§5.3) gates **telemetry volume**. The cookie never affects sampling; the -credential never gates mere content. - -| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | -| ------- | ----------------------------------------- | ------------------------------------- | ------------------------------- | -| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | -| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | -| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary | -| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | -| B1/B2 | `bridge_request{matched:false}` | join via trace (§G1) | console warn | -| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | -| C2 | `render_fail{renderer_document_no_load}` | `ts_ops_counters` | console warn | -| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn | -| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn | -| C5 | — (fixed at baseline) | — | — | -| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn | -| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn | +The **tester cookie** (non-security, `tester_cookie.rs:3`) gates **debug +content** (`tsjs.boot.debug`, response `ext.trusted_server.debug` — same +class as the existing `ts-debug` comment). The **diagnostic credential** +(§5.3) gates **telemetry volume**. Neither crosses into the other's role. + +| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | +| ------- | ---------------------------------------------------- | ------------------------------------- | ------------------------------- | +| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | +| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | +| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary | +| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | +| B1/B2 | `bridge_request{matched:false}` (§6.8) | join via trace | console warn | +| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | +| C2 | `render_terminal{failed, renderer_document_no_load}` | `ts_ops_counters` | console warn | +| C3 | `render_terminal{failed, bridge_id_mismatch}` | join via trace | console warn | +| C4 | `render_terminal{failed, descriptor_invalid}` | schema corpus CI | console warn | +| C5 | — (fixed at baseline) | — | — | +| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn | +| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn | ## 3. The GPT and baseline reality @@ -159,303 +158,243 @@ credential never gates mere content. 4. `enableSingleRequest()` called blind after publisher `enableServices()`. 5. Responsive-resolution ambiguity silently skips slots. 6. Three independent `pubads().refresh` wrappers. -7. **GPT has no cancellation, no per-refresh identity, no completion-order - guarantee**; `slotRenderEnded` = code injected, not resources loaded; - `responseIdentifier` identifies responses only. -8. **`display()` under disabled initial load creates no request — for any - caller** (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`); GPT's - behavior is caller-independent. +7. GPT has no cancellation, no per-refresh identity, no completion-order + guarantee; `slotRenderEnded` = code injected; `responseIdentifier` + identifies responses only. +8. `display()` under disabled initial load creates no request — for any + caller (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`). 9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled` - (`gpt/index.ts:1091`); G4a needs unconditional early subscription. -10. Baseline fix `248fe9558`: MessageChannel APS-PUC handshake - (`aps.rs:65-125`, `aps/render.ts:415-437`; the reply still terminates - inside the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5 - consolidated, real-PUC browser test added. -11. The bridge keeps consumed-id tombstones for security - (`gpt/index.ts:1527`). + (`gpt/index.ts:1091`). +10. Baseline `248fe9558`: MessageChannel APS-PUC handshake + (`aps.rs:65-125`, `aps/render.ts:415-437`), collapsed-shell resize + (`gpt/index.ts:217`), C5 consolidated, real-PUC browser test. +11. The bridge keeps consumed-id tombstones (`gpt/index.ts:1527`). 12. The tester cookie is not a security control (`tester_cookie.rs:3`). -13. **Fastly constructs application state per request** (`app.rs:146`); - its platform rate counter is a 60 s fixed window with separate - lookup/increment (`rate_limiter.rs:40`). +13. Fastly constructs application state per request (`app.rs:146`); its + platform counter is a 60 s window with separate lookup/increment + (`rate_limiter.rs:40`). 14. The baseline auction client collapses every failure into an empty - array (`core/auction.ts:185-224`): absent fetch, timeout, network - error, non-2xx, wrong content type, malformed body, and a genuine - zero-bid auction are indistinguishable to callers. + array (`core/auction.ts:185-224`). +15. A single `/auction` request can carry several slots; concurrent calls + race today (`request.ts:31`) and share one fetch. ## 4. Design gates -### G1 — Trace identity, sampling, correlation +### G1 — Trace identity, attempts, sampling, correlation -- The client-visible auction id is EC-derived (`publisher.rs:3237`) and - never ingested. Initial-HTML telemetry precedes page JS - (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by - whoever acts first: the **server** for `nav_gen 0` (trace + signed - authorization in `tsjs.boot`); the **client** afterwards via - `X-TSJS-Trace-Id` on page-bids (GET) and the `/auction` POST, echoed - back with the authorization and the server's telemetry auction id: +- EC-derived auction ids (`publisher.rs:3237`) are never ingested. + Initial-HTML telemetry precedes page JS (`telemetry.rs:148`, + `publisher.rs:2452`); correlation is minted by whoever acts first: the + server for `nav_gen 0` (trace + signed authorization in `tsjs.boot`); + the client afterwards via `X-TSJS-Trace-Id` on page-bids (GET) and the + `/auction` POST, echoed back with `ext.trusted_server.trace = {trace_id, auth, auction_id}`. -- **Deterministic keyed sampling, numerically exact:** take the first - 8 bytes of `HMAC-SHA-256(sampling_key, trace_id)` as a big-endian u64; - `mode = sampled` iff `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` - must be finite and in `[0, 1]` (validated at load; 0 → nothing sampled, - 1 → everything). Concurrent requests for one trace always derive the - same mode with no shared state (§3.13). -- **Cross-tier join:** the equality key is the globally unique - **`auction_id`** (server-minted telemetry UUID), echoed to the client - and stamped on every event of attempts born from that auction. - Generations (`nav_gen`, `refresh_gen`) exist **client-side only**, for - attempt aggregation — auction rows do not carry them. Canonical join: - `(publisher_domain, trace_id, auction_id)`, attempt grain added from - client events. -- **Cache-privacy invariant:** traces/authorizations only in per-request - auction-bearing responses; such HTML is `private, no-store`, no - validators; by construction and by test. +- **Attempt identity (closing the parent/child collision):** every render + attempt mints a client-side **`attempt_id`** (8-char `[a-z0-9]`, + CSPRNG, unique per trace) and carries nullable **`parent_attempt_id`** + (fallback children reference their parent). The tuple + `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key + only**; `ts_render_attempts_v` keys by `attempt_id`. Exactly one + terminal event per `attempt_id` (G4c) is a tested invariant. +- **Deterministic keyed sampling:** first 8 bytes of + `HMAC-SHA-256(sampling_key, trace_id)` as u64 BE; `sampled` iff + `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` finite in `[0, 1]`. +- **Cross-tier join:** equality key = the server telemetry + **`auction_id`** (UUID; the client column is `Nullable(UUID)` and + ingest validates canonical UUID syntax). Generations are client-side + only. **Join grains are explicit:** auction-level joins hit the one + summary row per `auction_id`; slot-level joins use + `auction_id + slot + row_kind`; bid-level joins are opt-in for + bid-grained analyses. Raw attempt × auction-row joins are forbidden + (row multiplication). +- Cache-privacy invariant: traces/authorizations only in per-request + auction-bearing responses; such HTML is `private, no-store`. - Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry - `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow`** is closed: - `ssat | prebid | page_bids | direct | fallback | system` — `system` for - heartbeat and overflow events, which have no render flow; the generated - per-event validity matrix (§5.1) says which events may carry which - flows. -- Traces are navigation-scoped; attempt counts key on - `(trace_id, nav_gen, refresh_gen, slot)`. + `{nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?, +auction_id?, t_rel_ms?}`. **`t_rel_ms`** is a bounded monotonic + duration (`performance.now()` truncated to u32 ms, relative to + navigation start) — the latency gates' basis; `received_at` is ingest + time and is never used as event time. `flow` is closed: + `ssat | prebid | page_bids | direct | fallback | system`. +- Traces are navigation-scoped; attempt counts key on `attempt_id`. ### G2 — Render identity - Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte - (`publisher.rs:3355`; the PUC fetches `?uuid=`, `gpt/index.ts:1772`). - Markup bids: existing fallback chain. Renderer-only bids: server-minted - token `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, - cross-auction uniqueness probabilistic (36¹² ≈ 4.7×10¹⁸) and harmless - via scoping; TTL 15 min; one-time consumption. -- **Reservation store — one capacity, no unexpired eviction:** live - registrations and tombstones (consumed / stale / navigation-disposed - ids) share one bounded structure, **union capacity 320**; expired - entries are pruned; **unexpired entries are never evicted**; at - capacity, new registration is refused with `registry_full`. A late - prior-navigation bridge request always meets suppression until its id's - original TTL passes (preserving `gpt/index.ts:1527`). Test: >320 - registrations, then a late request for the oldest unexpired id. -- The client-Prebid path keeps Prebid's generated `adId`; one store - serves both paths. Non-APS cache-path byte-identity regression tests. - -### G3 — Runtime ABI under the IIFE build (exact-release) - -Every entry point is a self-contained IIFE with inlined imports -(`build-all.mjs:46`, `bundle.rs:23`) — imports never share state across -bundles (live defect: `core/context.ts:11` vs `permutive/index.ts:102`). - -- The kernel ships only in `tsjs-core`, publishes - `tsjs._internal = {release_id, registry}` once (window sentinel), - freezes `_internal` after boot, and constructs/registers core services - (event bus, beacon queue, sessions, slot registry, render engine) - during boot; integrations register integration-scoped services during - `install()`. -- **Exact release matching:** every registration carries `release_id` - (plugins via the §7.6 object API whose `release` is a build-generated - constant); `registry.get(name)` succeeds only on equality; mismatch - quarantines (`abi_mismatch` service / `bundle_partial` plugin) with a - console error. -- Stateful access only via the registry at call time; stateless helpers - may inline. -- **Boundary enforcement:** `import/no-restricted-paths` for layering - **plus** `no-restricted-properties`/`no-restricted-syntax` rules - catching member-expression access to `googletag`/`pbjs` through - `window`, `globalThis`, `self`, and local aliases outside `adapters/` - (`no-restricted-globals` cannot catch member expressions). Adapters are - the only access to **external ad-tech globals**; kernel and messaging - necessarily touch `window.tsjs`, listeners, and `postMessage`. + (`publisher.rs:3355`, `gpt/index.ts:1772`). Markup bids: existing + fallback chain. Renderer-only bids: server-minted token + `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min, + one-time consumption. +- **Reservation store:** live registrations + tombstones (consumed / + stale / disposed) share one structure, **union capacity 320**; expired + entries pruned; **unexpired entries never evicted**; at capacity, new + registration refused with `registry_full`. Late prior-navigation + requests always meet suppression until original TTL (preserving + `gpt/index.ts:1527`). Test: >320 registrations, late oldest-id request. +- Client-Prebid keeps Prebid's `adId`; one store serves both paths. + Non-APS cache-path byte-identity regression tests. + +### G3 — Runtime ABI (exact-release) + +- IIFE-per-bundle with inlined imports (`build-all.mjs:46`, + `bundle.rs:23`); imports never share state (defect: + `core/context.ts:11` vs `permutive/index.ts:102`). Kernel only in + `tsjs-core`; `tsjs._internal = {release_id, registry}` frozen after + boot; core services constructed at boot; plugins via + `definePlugin({id, release, install})` with build-generated `release`; + `registry.get` succeeds only on equality; mismatch quarantines + (`abi_mismatch`/`bundle_partial`) loudly. +- **Boundary enforcement:** `import/no-restricted-paths` for layering, + plus a **custom scope-aware ESLint rule** for external-global access — + standard `no-restricted-properties`/`no-restricted-syntax` cannot + follow arbitrary aliasing, so the custom rule tracks member access to + `googletag`/`pbjs` through `window`/`globalThis`/`self` **and + same-file const aliases**; anything cleverer (cross-module smuggling) + is caught by review, and the claim is scoped to exactly that. Adapters + are the only access to external ad-tech globals; kernel/messaging + necessarily touch `window.tsjs` and `postMessage`. ### G4 — Render lifecycle -**G4a — Physical request cycles.** - -- **Intents, both classes, one causal queue.** Every observable - initiation — TS and wrapped publisher `display()`/`refresh()` — records - an intent in causal order, classified `ts | publisher`. Any - `display()` issued while initial load is disabled is **retired at - issuance regardless of caller** (GPT is caller-independent, §3.8) — it - never enters the matcher. Hindsight zero-request intents (`refresh()` - on a never-displayed slot) expire at 2 s with `intent_no_request`; - **any later request-capable intent — same class or opposite — - supersedes a pending uncertain intent immediately**, and if the - uncertain intent's request could still legitimately be in flight - (within its 2 s bound), the next `slotRequested` is ambiguous and the - slot quarantines. A stale no-op `refresh()` can therefore never steal - a later `display()`'s request in either direction, TS→TS, - publisher→publisher, or across classes. -- **Cycles** open only on `slotRequested`, matched to the causal queue - head; SRA batching yields one per slot per batch; cycles close on - `slotRenderEnded`; `responseIdentifier` deduplicates responses during - drain (it never attributes initiation). -- **Serialization:** at most one outstanding TS cycle per slot; one - queued TS replacement (later intents coalesce). -- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS - cycle is outstanding and no publisher/untracked request overlaps; - otherwise quarantine (`cycle_unattributable`), fail closed. -- **No timeout re-arm.** Physical cycle/drain state lives in the - RuntimeSession slot record; unissued intents are NavigationSession - children (cancelled by navigation disposal). A quarantined or stale - slot re-arms only on count-based drain, safe TS-owned - destroy/redefine, or page end. Timeouts emit diagnostics and never - restore attribution. Late stale events are matched and discarded - (`stale_navigation`). -- Deterministic-harness CI plus the release-gating real-GAM suite - (topologies enumerated in Appendix A.3). - -**G4b — Acknowledgement, per render path.** Four normative sequences; -each names its nonce producer, transport, authenticated acceptance -observation, cancellation, and deadlines (document 3 s, runner 10 s, -adm 5 s). All nonces are per-attempt 128-bit CSPRNG values minted by the -attempt owner; the kernel validates, in order: source ownership (§6.8 -walk), nonce, token, `nav_gen`, `refresh_gen` — before any transition or -notification. Navigation/supersession invalidates the nonce; late acks → -`stale_navigation`. - -1. **APS-PUC** (baseline transport): bridge mints the nonce; - MessageChannel into the renderer document (`ports.length` checks, - exact-key replies, one-shot `accepted` latch, port close — - `aps.rs:65-125`, `aps/render.ts:415-437`); the document posts - authenticated `renderer_document_loaded` then - `render_accepted | render_failed{reason}` to the top window. -2. **Generic ADM/cache-PUC:** the display renderer creates the sandboxed - adm frame with an injected reporter snippet that posts authenticated - `adm_document_loaded{nonce}` on document load; acceptance = that - message (the baseline merely appends an iframe with no observation). -3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent; - the baseline parent-postMessage branch (`ports.length === 0`) is - already kernel-observed; same three messages, same validation. -4. **Direct ADM/cache:** as (2) with the kernel as parent. - -**G4c — Honest observations; one terminal state.** Inline-adm frames are -sandboxed `srcdoc` without `allow-same-origin` (`gpt/index.ts:510`) — -opaque; geometry proves nothing. Observations: `gam_nonempty`, -`gam_empty`, `gam_collapsed{action: resized | guarded, reason?}` -(observation and remediation separate), `renderer_document_loaded`, -`runner_loaded`, `runner_failed`, `adm_document_loaded`. **An attempt has -exactly one terminal state: `accepted | failed{reason} | no_bid | -cancelled`.** Post-acceptance runner failure is an observation only — -there is **no** `billing_outcome` event: no path has an honest producer -for a post-accept billing-failure claim (APS is excluded from -notifications and opaque frames offer no authenticated post-accept -signal), so the design does not pretend otherwise. No observation claims -paint; there is no `render_confirmed`. The baseline resize -(`gpt/index.ts:217`) is a sanctioned, guarded exception to the -no-foreign-DOM-mutation rule (authenticated source frame only; wrapper -only when both dimensions ≤ 1 px; anchor-ad and fixed/sticky guards). +**G4a — Physical request cycles.** Intents (both classes, one causal +queue): any `display()` under disabled initial load is retired at +issuance regardless of caller; hindsight zero-request intents expire at +2 s with `intent_no_request`; **any later request-capable intent — same +class or opposite — supersedes a pending uncertain intent**, and if the +uncertain one could still be in flight, the next `slotRequested` is +ambiguous → quarantine. Cycles open only on `slotRequested` (causal +head; SRA = one per slot per batch) and close on `slotRenderEnded` +(`responseIdentifier` dedups drain). One outstanding TS cycle per slot; +one queued replacement. Attribution requires exactly one outstanding TS +cycle and no overlap; otherwise `cycle_unattributable`, fail closed. **No +timeout re-arm** — re-arm only on count-based drain, safe TS-owned +destroy/redefine, or page end; unissued intents are NavigationSession +children; physical state is RuntimeSession. Deterministic-harness CI + +the release-gating real-GAM suite (Appendix A.3). + +**G4b — Acknowledgement, per render path.** Nonces are per-attempt +128-bit CSPRNG values; the kernel validates source ownership (§6.8), +nonce, token, `nav_gen`, `refresh_gen` before transitions or +notifications; navigation/supersession invalidates; late acks → +`stale_navigation`. Deadlines: document 3 s, runner 10 s, adm 5 s. + +1. **APS-PUC** (baseline transport): MessageChannel into the renderer + document (`ports.length` checks, exact-key replies, one-shot latch, + port close); the document posts authenticated + `renderer_document_loaded` then the accepted/failed result to the top + window. +2. **Generic ADM/cache-PUC:** **the acceptance observation lives in the + trusted owner, not the creative document** — the owner observes its + own iframe's `load`/`error` events and emits `adm_document_loaded`; + the nonce never enters the bidder realm (an injected reporter would + hand bidder-controlled code the acceptance credential and let it + trigger `burl` early — revision 8's reporter is withdrawn). +3. **Direct APS:** the kernel is the frame parent; the baseline + parent-postMessage branch is already kernel-observed. +4. **Direct ADM/cache:** as (2), owner-observed `load`/`error`. + +**G4c — Honest observations; one terminal event.** Observations: +`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded}`, +`renderer_document_loaded`, `runner_loaded`, `runner_failed`, +`adm_document_loaded`. **The terminal is one discriminated event: +`render_terminal{outcome: accepted | failed | no_bid | cancelled, +reason?}` — exactly one per `attempt_id`** (replacing separate +accepted/fail events the schema could not reconcile). A parent attempt +whose GAM cycle ends empty emits `render_terminal{failed, gam_empty}` +**before** its fallback child starts. Post-acceptance runner failure is +an observation only (no billing-failure event exists — no honest +producer). No observation claims paint. The baseline resize +(`gpt/index.ts:217`) stays a sanctioned, guarded exception. **G4d — Notifications.** APS carries neither `nurl` nor `burl` -(`aps.rs:839`; the AAX envelope excludes them; the integration guide -documents no generic APS beacons) — excluded entirely; the Amazon runner -lifecycle is unchanged. For carrying paths (PBS and other OpenRTB -providers): - -- Bind per flow, never selection or targeting (`ad_init.test.ts:1824`): - PUC — an owned, slot-and-ad-id-matched bridge claim; direct — - validated render start (the server must preserve and macro-expand - `nurl`/`burl` in `/auction` responses, `formats.rs:423` omits them; - the client must parse and https-validate them, `core/auction.ts:43` - drops them); fallback — attributed `gam_empty` immediately before - fallback render. -- `nurl` at bind; `burl` at `accepted`; **no retries**; idempotency key - `(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)` with the - normalized economic identity `(id_kind, id_value)` (direct attempts - without `hb_adid` use `bid_id`). -- **Observability without client cryptography:** the server mints an - opaque **`notif_id`** (12-char token, same generator as G2) per - notification-carrying bid and delivers it with the bid; every dispatch - emits `notification_sent{kind: nurl | burl, notif_id, result: -queued | failed}`. The browser computes no hashes (a client-held HMAC - key would break the pseudonymization boundary; a rotating token would - break stability across a gate window). -- **The duplicate-`burl` invariant is proven hermetically and - reconciled externally, not "proven" by lossy telemetry:** hermetic - tests pin exactly-once dispatch logic; production - `notification_sent` duplicates are a **detection alarm** (any - observed duplicate is a red gate); absence-of-duplicates is - established by billing reconciliation (GAM/SSP reports vs server-side - win counts) because sampled, best-effort telemetry cannot prove a - zero. - -**G4e — Fallback.** Opt-in -(`[auction].client_render_fallback = "renderer"`); renders only after a -terminal `gam_empty` unambiguously attributed to a TS cycle; ownership -does not gate it; publisher-initiated or unattributable cycles never -trigger it; timeouts never render. - -**G4f — Direct `/auction` lifecycle.** `RenderAttempt` keyed -`(trace_id, nav_gen, refresh_gen, slot)`; per-slot **latest-wins with -cancellation** (concurrent calls cancel the older attempt; -`request.ts:31` races today); generation checks before every DOM/beacon -effect; G4b sequences 3/4; G4d direct binds; navigation disposal; one -terminal state. **The auction client returns a discriminated result** — -`{ok: bids[]} | {error: "auction_timeout" | "network_error" | -"http_error" | "invalid_response"}` — replacing the baseline's -everything-is-an-empty-array collapse (§3.14); only a successfully -parsed response with no winner maps to `no_bid`. Public API: +(`aps.rs:839`) — excluded entirely. For carrying paths: bind per flow +(PUC: owned matched bridge claim; direct: validated render start — +server must preserve + macro-expand the URLs (`formats.rs:423` omits), +client must parse + https-validate (`core/auction.ts:43` drops); +fallback: attributed parent `gam_empty` immediately before child +render). `nurl` at bind; `burl` at `accepted`; idempotency key +`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. +**Dispatch mechanics (normative):** macros are expanded server-side +only; the client fires +`fetch(url, {method: "GET", mode: "no-cors", credentials: "omit", +redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})`; +on synchronous failure the fallback is a detached `Image()` request; no +retries either way. Every dispatch emits +`notification_sent{kind, notif_id, result: queued | failed}` with the +**server-minted `notif_id`** (12-char token delivered with the bid). +Duplicates: hermetic exactly-once proof + production detection alarm + +billing reconciliation (lossy telemetry cannot prove a zero). + +**G4e — Fallback.** Opt-in; child attempt (own `attempt_id`, +`parent_attempt_id`, `flow = fallback`); renders only after the parent's +attributed `render_terminal{failed, gam_empty}`; publisher-initiated or +unattributable never triggers; timeouts never render. + +**G4f — Direct `/auction` lifecycle and the AuctionBatch.** A single +`/auction` fetch may serve several slots, so cancellation is +batch-aware: an **`AuctionBatch`** owns the fetch (its `AbortController`) +and the child `RenderAttempt`s. Supersession cancels **children +individually** (`render_terminal{cancelled}`); the fetch aborts only +when every child is dead or the batch times out or its navigation +disposes; every response bid is filtered through the **currently live** +child identity before any effect. Tests: partial overlap, full overlap, +timeout, navigation disposal, reversed responses. The auction client +returns a discriminated result — `{ok: bids[]} | {error: +"auction_timeout" | "network_error" | "http_error" | "invalid_response"}` +(§3.14); only a parsed empty response is `no_bid`. Public API: `tsjs.requestAds(options): Promise`, -`RequestAdsResult = {traceId, slots: [{slot, outcome: "rendered" | -"no_bid" | "failed" | "cancelled", reason?}]}`, settling when every slot -attempt is terminal. Reversed-response tests required. -**Fallback identity:** fallback is a **child attempt** — new -`RenderAttempt`, `flow = fallback`, carrying `parent_flow` (the -originating flow); the terminal `gam_empty` belongs to the parent -attempt under the parent's flow; the canonical view links parent and -child on `(trace_id, nav_gen, slot, refresh_gen)`. - -**G4g — Mid-attempt configuration and the commit point.** An attempt -snapshots configuration at creation. **Commit = the earliest -irreversible action** — the first of: notification dispatch (`nurl` at -bind), `bridge_response_sent`, or first DOM insertion. The -generation/kill-switch check runs **immediately before each** of those; -an attempt past commit runs to its terminal state; dispatched -notifications are never recalled. (Revision 7 put commit after the -`nurl` side effect; that ordering error is corrected.) +`RequestAdsResult = {traceId, slots: [{slot, outcome, reason?}]}`, +settling when every child attempt is terminal. + +**G4g — Mid-attempt configuration, honestly scoped.** Attempts snapshot +configuration at creation. **Commit = the earliest irreversible action** +(first of: notification dispatch, `bridge_response_sent`, first DOM +insertion), with the generation/kill check immediately before each. +**The kill switch's delivery is page-bound:** already-loaded pages have +no push channel, so live switch state travels only on responses the +page later fetches — page-bids and `/auction` responses carry +`ext.trusted_server.switches`, and new HTML carries current state. The +guarantee is therefore scoped: the switch affects attempts created +after the page received switch state; SSAT attempts on already-loaded +pages are unaffected by design, and the spec says so rather than +implying a live channel that does not exist. ### G5 — Deployment contracts -- Config `format_version` + manifest hash verification (§0). -- **Assets pre-materialized at release publication:** config is a - release-time input, so validated module vectors are known when the - release is built; concatenated bytes + hashes are produced then and - embedded; serving is lookup-only on every adapter (Fastly is - per-request, §3.13, so construction-time caching would be - meaningless). Unknown vector = release-build error; unknown hash = - `410 no-store`; exact match = `public, max-age=31536000, immutable`. -- **Internal route families — four:** renderer, client-events, - CSP-report, `/_ts/trace-auth`. All dispatch before auth/EC/publisher/ - integration filters (Fastly today runs EC setup and pre-route filters - first, `app.rs:709`); all methods and version prefixes reserved - locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`; - never the publisher fall-through of `adapter-spin app.rs:804`); no - body/cookie/authorization forwarding. **Origin policy is per family**, - not universal: client-events and trace-auth require strict normalized - same-origin (scheme+host+port); the CSP route admits opaque/`null` - origins and authenticates by server-selected path identity plus abuse - limits; the renderer document is a public GET validated by - version/path only (it is loaded from sandboxed opaque contexts — - browser-origin authentication is impossible there by design). -- Ingest routes exist in all four adapters; Fastly has real sinks; - others accept-count-drop by contract (DR-5). -- §5.6 schemas deploy and validate before writers enable. +- Config verification per §0. Assets pre-materialized at release + publication (config is a release-time input); serving is lookup-only; + unknown vector = build error; unknown hash = `410 no-store`; exact + match = `public, max-age=31536000, immutable`. +- **Internal route families — four** (renderer, client-events, + CSP-report, `/_ts/trace-auth`): dispatch before auth/EC/publisher/ + integration filters (`app.rs:709` orders these wrong today); all + methods + version prefixes reserved locally (405 + `Allow` + + `no-store`; unknown version 404 `no-store`; no publisher fall-through, + `adapter-spin app.rs:804`); no body/cookie/authorization forwarding. + **Per-family origin policy:** client-events + trace-auth strict + normalized same-origin; CSP admits opaque/`null` origins with path + identity + limits; renderer is a public GET validated by version/path. +- Ingest routes in all four adapters; Fastly has real sinks; others + accept-count-drop (DR-5). §5.6 schemas deploy before writers. ## 5. Observability -### 5.1 Wire payload and per-event field matrix +### 5.1 Wire payload and field matrix ``` { v: 1, traces: [ { trace_id, auth, events: [ - { nav_gen, refresh_gen, seq, flow, auction_id?, t, ...fields } ] } ] } + { nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?, + auction_id?, t_rel_ms?, t, ...fields } ] } ] } ``` | `t` | fields | allowed `flow` | | -------------------------- | --------------------------------------------- | ----------------------- | | `bid_received` | slot, id_kind, source | render flows | | `targeting_set` | slot, id_kind | render flows | +| `attempt_started` | slot, source | render flows | | `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids | | `bridge_response_sent` | slot, source | ssat, prebid, page_bids | -| `render_attempt` | slot, source | render flows | -| `render_accepted` | slot, source | render flows | -| `render_fail` | slot, reason, source? | render flows | +| `render_terminal` | slot, outcome, reason?, source? | render flows | | `gam_nonempty` | slot | ssat, prebid, page_bids | | `gam_empty` | slot | ssat, prebid, page_bids | | `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | ssat, prebid, page_bids | @@ -463,385 +402,326 @@ notifications are never recalled. (Revision 7 put commit after the | `runner_loaded` | slot | render flows | | `runner_failed` | slot, reason | render flows | | `adm_document_loaded` | slot | render flows | -| `fallback_start` | slot, parent_flow | fallback | +| `fallback_start` | slot | fallback | | `notification_sent` | slot, kind (`nurl`\|`burl`), notif_id, result | render flows | | `client_queue_overflow` | dropped (count) | system | -| `heartbeat` | probe_id, expected_seq | system | - -"Render flows" = `ssat | prebid | page_bids | direct | fallback`. -`source` on `render_fail` is **nullable**: absent for pre-source reasons -(`gpt_absent`, `pbjs_absent`, `slot_unresolved`, `intent_no_request`, -`abi_mismatch`, `registry_full`, `bundle_partial`); required otherwise. -The per-event/per-reason validity matrix is a generated artifact (§6.7). -Reason enum (closed): `renderer_document_no_load`, `runner_no_load`, -`runner_failed`, `descriptor_invalid`, `invalid_dimensions`, -`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`, -`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`, -`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, -`pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`, -`registry_full`, `currency_mismatch`, `auction_timeout`, -`network_error`, `http_error`, `invalid_response`, -`adm_document_no_load`. No client timestamp; the server stamps -`received_at`; ordering within a trace is `seq`. - -### 5.2 Transport and overflow - -`fetch(..., {keepalive: true, credentials: "same-origin"})` primary (§0 -affinity; the handler still derives no identity from cookies); -`pagehide` fallback `navigator.sendBeacon(url, new Blob([json], {type: -"application/json"}))`. Flush every 5 s and on -`visibilitychange`/`pagehide`. Queue bound 256 events. **Overflow never -enqueues into the full queue:** an out-of-band saturating counter -accumulates drops and one coalesced `client_queue_overflow{dropped}` is -materialized into the next flush. - -### 5.3 Signed trace authorization - -Format `v1....`; the `auth` field has its own -ingest bound of **256 bytes** (every other string keeps the 64-char -cap — a 43-char unpadded-base64url signature cannot fit 64 with its -prefix fields). - -- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform - secret store; keys ≥ 256-bit CSPRNG; previous keys retained ≥ 24 h - (≫ max token lifetime + skew). Missing key with the feature enabled → - startup/first-use failure, never silent. -- `exp`: canonical decimal unix seconds (no sign, no leading zeros); - ±60 s skew; ≤ 15 min future. -- `mode`: `sampled | unsampled | diagnostic | probe`. **`unsampled` is - the signed discard decision:** the client neither enqueues nor - transmits for it, and ingest rejects any group carrying it. `probe` - marks synthetic monitors (server-issued to probe runners); probe - traffic is never sampled out and is excluded from product metrics by - mode. -- `sig`: unpadded base64url of HMAC-SHA-256 (43 chars) over the - domain-separated, length-prefixed input `"ts-trace-auth-v1" || -u32be(len(origin)) || origin || u32be(len(trace_id)) || trace_id || -u32be(len(mode)) || mode || u64be(exp)`, strings UTF-8, `origin` = - externally visible scheme+host+port. Constant-time comparison. -- **Renewal preserves mode by verification, not trust:** - `GET /_ts/trace-auth` presents the current still-valid token in - `X-TSJS-Trace-Auth` (plus the trace header); the server verifies and - re-signs the same `trace_id` and `mode` with fresh `exp`. **The only - mode transition that exists is the diagnostic upgrade, a distinct - operation:** `POST /_ts/trace-auth/upgrade` presenting the current - token **and** a valid diagnostic credential; it re-signs with - `mode = diagnostic` and `exp = min(now + 15 min, credential expiry)`. - Plain renewal never changes mode. Renewal after expiry fails; the - client stops transmitting and counts locally. -- **Diagnostic credential:** issued `POST -/_ts/admin/diagnostic-credential` under the existing admin - authentication (CSRF: same-origin + custom header), format - `d1....`, absolute expiry ≤ 60 min, - origin-bound, **replayable short-lived bearer by design** (bounded by - expiry + origin binding; stated, not implied). **Exposure-minimized - transport:** the operator opens the page with `#tsdiag=`; - the synchronous bootstrap reads it, **immediately clears the fragment - via `history.replaceState`**, holds the credential **in memory only** - (RuntimeSession — never `sessionStorage`, which page scripts can - read), and exchanges it via the upgrade operation as soon as the trace - exists. Because initial HTML cannot see the fragment, `nav_gen 0` - starts `sampled | unsampled`; **when a pending `#tsdiag` fragment is - detected, the client buffers events locally without transmission - (bounded 256) until the upgrade resolves**, then flushes under - diagnostic mode — one-page-load diagnostic completeness holds without - delaying rendering. Forgery, wrong-origin, and replay-past-expiry - tests required. Validation is stateless HMAC — all four adapters. -- **Lazy cached initialization** applies to every secret-backed - component (trace-auth keys, diagnostic keys, sampling key, sinks): - first-use resolution with a cached result on request-bound platforms; - failure with the feature enabled is that feature's loud error path. +| `heartbeat` | probe_run_id, expected_seq, adapter, target | system | + +Render flows = `ssat | prebid | page_bids | direct | fallback`. +`attempt_started` carries the attempt's `t_rel_ms` baseline; the latency +metric is `render_terminal{accepted}.t_rel_ms − +attempt_started.t_rel_ms` per attempt. `source` on `render_terminal` is +nullable for pre-source reasons (`gpt_absent`, `pbjs_absent`, +`slot_unresolved`, `intent_no_request`, `abi_mismatch`, `registry_full`, +`bundle_partial`); the per-event/per-reason validity matrix is a +generated artifact (§6.7). Reason enum: `renderer_document_no_load`, +`runner_no_load`, `runner_failed`, `descriptor_invalid`, +`invalid_dimensions`, `dimensions_out_of_range`, `bridge_id_mismatch`, +`cycle_unattributable`, `intent_no_request`, `stale_navigation`, +`bridge_claim_timeout`, `gam_empty`, `no_render_source`, +`slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`, +`fallback_cancelled`, `abi_mismatch`, `registry_full`, +`currency_mismatch`, `auction_timeout`, `network_error`, `http_error`, +`invalid_response`, `adm_document_no_load`. + +### 5.2 Transport, batching, and budgets (limiter-consistent) + +- Batches are capped by **both** 64 events **and** 12 KiB encoded + payload (headroom under the 16 KiB ingest cap); a flush drains the + queue as up to **4 sequential batches**. +- Cadence: flush every **10 s** and on `visibilitychange`/`pagehide`. + Worst-case honest traffic per tab: 6 flushes/min × ≤ 4 batches = ≤ 24 + requests/min transient, typically ≤ 6. +- **Budgets derived from that worst case:** client-side trace budget + ≤ 8 batches/min sustained (excess coalesces into the next flush); + ingest per-address budget **60 req/min, burst 120** (≈ 5 active tabs + plus pagehide bursts). The limiter can no longer reject honest + steady-state traffic by construction; tests cover sustained + single-tab, multi-tab (5), diagnostic pre-upgrade buffer flush, and + pagehide bursts. +- Transport: `fetch(..., {keepalive: true, credentials: +"same-origin"})`; `pagehide` fallback `sendBeacon(url, new +Blob([json], {type: "application/json"}))`. Queue bound 256; overflow + uses the out-of-band saturating counter + one coalesced + `client_queue_overflow` in the next flush (never enqueued into a full + queue). + +### 5.3 Signed authorizations + +**Trace authorization** `v1...[.].` (`auth` +ingest bound 256 bytes; all other strings 64): + +- `kid ^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store; + previous keys retained ≥ 24 h; missing key with the feature enabled → + loud first-use failure. +- `exp` canonical decimal; ±60 s skew; ≤ 15 min future. `mode`: + `sampled | unsampled | diagnostic | probe`. **`dexp` is present iff + `mode = diagnostic`** — the immutable diagnostic ceiling, signed into + the token, set at upgrade to the credential's absolute expiry. + **Every renewal of a diagnostic token re-derives + `exp = min(now + 15 min, dexp)` and preserves `dexp`; past `dexp`, + renewal fails** — diagnostic access is bounded by the credential + forever, not just at upgrade (closing the indefinite-renewal hole). + Tests: renewal-before-expiry capped, repeated renewal to the ceiling. +- `sig` = unpadded base64url HMAC-SHA-256 over + `"ts-trace-auth-v1" || u32be(len(origin)) || origin || +u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode || +u64be(exp) [|| u64be(dexp)]`; constant-time compare; per-group + rejection at ingest. `unsampled` transmits nothing and is rejected if + carried. **Probe issuance protocol:** the probe runner authenticates + to `POST /_ts/admin/probe-authorization` (admin auth + CSRF) and + receives a batch of pre-signed probe-mode tokens tagged + `probe_run_id`; probe traffic is never sampled out and excluded from + product metrics by mode. +- Renewal: `GET /_ts/trace-auth` presenting the current still-valid + token in `X-TSJS-Trace-Auth`; re-signs same trace + mode (+ `dexp`). + **Diagnostic upgrade** is the sole mode transition: + `POST /_ts/trace-auth/upgrade` presenting token + credential. + +**Diagnostic credential** `d1....` — full byte-level +spec with vectors: `oh` = first 16 hex chars of SHA-256 of the +externally visible origin (scheme+host+port, UTF-8); `sig` = unpadded +base64url HMAC-SHA-256 over `"ts-diag-cred-v1" || u32be(len(origin)) || +origin || u64be(exp)`; same kid charset, key strength, rotation, and +≥ 24 h previous-key retention; ±60 s skew; absolute expiry ≤ 60 min; +constant-time compare; replayable short-lived bearer by design (bounded +by expiry + origin). Issued `POST /_ts/admin/diagnostic-credential` +(admin auth, CSRF: same-origin + custom header). Transport: `#tsdiag=` +fragment → read synchronously, cleared via `history.replaceState`, held +in memory only (RuntimeSession); pre-upgrade events buffer locally +(bounded 256) and flush after upgrade. Forgery, wrong-origin, +replay-past-expiry tests. + +Lazy cached initialization applies to every secret-backed component; +failure with the feature enabled is that feature's loud error path. ### 5.4 Ingest and rate limiting - `POST /_ts/client-events`: `application/json` only; no - `Content-Encoding`; responds `204`, `no-store`; never echoes input. - Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars - (`auth` ≤ 256 bytes); `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`; - width/height `[0, 8192]`. Violations → drop-and-count with `204`. + `Content-Encoding`; `204`, `no-store`; never echoes input. Pre-parse: + body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 (`auth` ≤ 256 B); + `trace_id ^[0-9a-f]{32}$`; `attempt_id ^[a-z0-9]{8}$`; `auction_id` + canonical UUID; integers `[0, 2³¹)`; `t_rel_ms` u32. - Same-origin (client-events, trace-auth): `Sec-Fetch-Site: -same-origin` when present, else normalized `Origin` equality; absent - both → drop-and-count. -- **Rate limiting — adapter abstraction with declared semantics:** - trait `ClientEventLimiter`, key namespace per route family; intent - 10 req/min, burst 20 per client address. Axum: real in-process token - bucket, map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/instance - best-effort, ≤ 4,096 entries; entry TTL 10 min with cleanup on access - plus periodic sweep — **capacity pressure rejects unseen identities, - but expired entries are always reclaimable, so saturation is bounded, - not permanent**; missing client address → shared `unknown` bucket at - 1 req/min. Fastly: the platform 60 s fixed-window counter at limit 20 - as a documented approximation; because its lookup and increment are - separate operations (§3.13), **overshoot under a synchronized burst - is bounded only by in-flight concurrency, and no numeric multiple is - claimed** — the synchronized-burst test (> 40 concurrent) documents - observed behavior, and a penalty-box follow-up is recorded if - observed overshoot is operationally unacceptable. Limiter - unavailable/errored → drop early with `204`. Trusted client address: - Fastly platform client IP; Axum rightmost `X-Forwarded-For` entry - after skipping exactly `trusted_proxy_hops` (absent config → socket - peer only); Cloudflare `CF-Connecting-IP`; Spin platform address. - `/_ts/trace-auth` and `/_ts/csp-reports/` carry their own - buckets with the same intent. - -### 5.5 Sinks, canonical views, per-sink monitoring - -- Stable event key `(publisher_domain, trace_id, seq)`. Canonical views: - `ts_client_events_v` (dedup: latest `received_at` per key) and - `ts_render_attempts_v` (attempt grain per G1). Dashboards and alerts - query canonical views only; raw-to-raw joins are forbidden (row - multiplication). -- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) - and cannot see downstream rejection — **each datasource gets its own - synthetic probe and freshness/loss query, per adapter write path**: - client-events via `heartbeat` events (mode `probe`, - `expected_seq` gaps = loss, lag = freshness); CSP via probe reports - to a reserved `policy_id = probe`; ops via a probe counter. A green - client-events heartbeat says nothing about the CSP or ops - credentials — hence three probes. Alert owner: release owner's - on-call. +same-origin` else normalized `Origin` equality; absent both → + drop-and-count. +- Limiter (per §5.2 budgets): trait `ClientEventLimiter`; Axum real + token bucket (60/min, burst 120), map ≤ 65,536; Cloudflare/Spin + best-effort ≤ 4,096/instance; TTL 10 min, cleanup on access + sweep; + at capacity reject unseen identities (expired always reclaimable); + unknown address → shared bucket 6/min; Fastly platform 60 s window at + limit 120 (approximation; overshoot bounded only by in-flight + concurrency — documented by the synchronized-burst test, no numeric + multiple claimed). Limiter unavailable → drop early with `204`. + Trusted address per adapter (Fastly platform IP; Axum rightmost XFF + after `trusted_proxy_hops`, absent → socket peer; CF + `CF-Connecting-IP`; Spin platform). Trace-auth and CSP routes carry + their own buckets (10/min, burst 20). + +### 5.5 Sinks, canonical views, per-sink authenticated probes + +- Event key `(publisher_domain, trace_id, seq)`; canonical views + `ts_client_events_v` (dedup) and `ts_render_attempts_v` (**keyed by + `attempt_id`**); the arm-union views stamp `deployment_pool` from the + write identity (§0). Dashboards/alerts query canonical views only. +- The Fastly sink is fire-and-forget (`tinybird.rs:153`) — + **per-datasource authenticated probes**: every probe row carries + `{probe_run_id, expected_seq, adapter, target}`; client-events via + `heartbeat` events under probe-mode tokens; CSP via probe reports to a + **secret-derived probe `policy_id`** (registered like any policy id, + not guessable — `policy_id = "probe"` would be publicly forgeable); + ops via an authenticated probe counter write. **Persistence gates are + scoped to sink-backed adapters** (DR-5); accept-count-drop adapters + get HTTP-parity gates only. Freshness = probe lag ≤ 5 min; loss = + `expected_seq` gaps < 0.1%; alert owner: release owner's on-call. +- **Alert-delivery drill:** a synthetic canary page injects a known + failure class at a known rate; the failure-detection alert must fire + within one hour — dashboards and alert latency are tested, not + assumed (Appendix A row). ### 5.6 Physical schemas (deployed before writers) - **`ts_client_events`**: `received_at DateTime64, publisher_domain -LowCardinality(String), release_id String, trace_id FixedString(32), -mode Enum(sampled|diagnostic|probe), nav_gen UInt32, refresh_gen -UInt32, seq UInt32, flow Enum(ssat|prebid|page_bids|direct|fallback| -system), auction_id Nullable(FixedString(36)), event Enum(§5.1), slot -Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8), -source Nullable(Enum), reason Nullable(Enum), action Nullable(Enum), -parent_flow Nullable(Enum), kind Nullable(Enum), notif_id -Nullable(FixedString(12)), result Nullable(Enum), dropped -Nullable(UInt32), probe_id Nullable(String), expected_seq -Nullable(UInt32)`. Sorting key `(publisher_domain, received_at, -trace_id, seq)`; TTL 30 days; own ingest token; sink batch cap 512; - startup validation of dataset + token when enabled; - sink-unavailable → accept-count-drop. +LowCardinality(String), release_id String, deployment_pool +Enum(canary|control), assignment_id Nullable(FixedString(32)), +trace_id FixedString(32), mode Enum(sampled|diagnostic|probe), nav_gen +UInt32, refresh_gen UInt32, seq UInt32, flow +Enum(ssat|prebid|page_bids|direct|fallback|system), attempt_id +Nullable(FixedString(8)), parent_attempt_id Nullable(FixedString(8)), +auction_id Nullable(UUID), t_rel_ms Nullable(UInt32), event Enum(§5.1), +slot Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8), +source Nullable(Enum), reason Nullable(Enum), outcome +Nullable(Enum(accepted|failed|no_bid|cancelled)), action +Nullable(Enum), kind Nullable(Enum), notif_id Nullable(FixedString(12)), +result Nullable(Enum), dropped Nullable(UInt32), probe_run_id +Nullable(String), expected_seq Nullable(UInt32), adapter +Nullable(Enum), target Nullable(Enum)`. Sorting key `(publisher_domain, +received_at, trace_id, seq)`; TTL 30 days; sink batch cap 512; startup + validation; sink-unavailable → accept-count-drop. - **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`): - add nullable `trace_id`, `mode`, `release_id`. Two added row types - with an explicit **`row_kind Enum(slot | totals | overflow)`** so - totals/overflow rows are valid instances (no publisher-controlled - sentinel strings; inapplicable fields nullable): - - `bid_drop {row_kind, provider Nullable(LowCardinality(String)) — -NULL on overflow, slot Nullable(String), reason + add nullable `trace_id`, `mode`, `release_id`; + `row_kind Enum(slot|totals|overflow)`; `bid_drop {row_kind, provider +Nullable(LowCardinality(String)), slot Nullable(String), reason Enum(AuctionDropReason), width Nullable(UInt16), height -Nullable(UInt16), count UInt32}` — cap 32 slot-rows/auction plus one - overflow row whose `count` = **actual dropped bids**, not compacted - rows; - - `selection_summary {row_kind, slot Nullable(String) — NULL on -totals, winner_source Nullable(Enum(mediator|direct|none)) — NULL on -totals, winner_provider Nullable(String) — NULL when winner_source -≠ a winner, candidates_direct UInt16, candidates_mediator UInt16, -dedup_hits UInt16, currency_rejected UInt16, provenance_invalid -UInt16, mediator_superseded UInt16}` — cap 8 slot-rows plus one - totals row that always survives truncation. Counters saturate at - `0xFFFF`/`0xFFFFFFFF`. - - **`AuctionDropReason` (closed, exhaustive over baseline - producers):** `script_rendering_disabled, invalid_dimensions, -dimensions_out_of_range, missing_render_source, -invalid_creative_url, unsupported_tagtype, -render_payload_too_large, unexpected_response_shape, -currency_mismatch, floor_rejected, provenance_invalid, -duplicate_demand, missing_bid_id, duplicate_bid_id, unknown_impid, -invalid_price, unsupported_media_type, creative_id_too_large, -empty_seatbid, renderer_extension_serialization_failed, -no_render_source, lost_to_higher_bid, overflow` — covering the - outcomes emitted at `aps.rs:740-929` and `formats.rs:408-419`; a - **compile-time exhaustiveness test maps every producer to the - enum**. +Nullable(UInt16), count UInt32}` (32 slot-rows + one overflow row whose + `count` = actual dropped bids); `selection_summary {row_kind, slot +Nullable(String), winner_source Nullable(Enum(mediator|direct|none)), +winner_provider Nullable(String), candidates_direct UInt16, +candidates_mediator UInt16, dedup_hits UInt16, currency_rejected +UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` (8 + slot-rows + one totals row that survives truncation; saturating + counters `0xFFFF`/`0xFFFFFFFF`). + - **`AuctionDropReason` (closed, exhaustive over baseline producers, + one shared typed enum — no string literals):** + `script_rendering_disabled, invalid_dimensions, +dimensions_out_of_range, missing_render_source, invalid_creative_url, +unsupported_tagtype, render_payload_too_large, +unexpected_response_shape, currency_mismatch, floor_rejected, +provenance_invalid, duplicate_demand, missing_bid_id, +duplicate_bid_id, bid_id_too_large, empty_seatbid, +empty_seatbid_bids, unknown_impid, invalid_price, +unsupported_media_type, creative_id_too_large, +renderer_extension_serialization_failed, no_render_source, +lost_to_higher_bid, overflow` — covering `aps.rs:740-929` + (incl. `empty_seatbid_bids` at `:875`) and `formats.rs:408-419`; a + **compile-time exhaustiveness test maps every producer to the enum**. - **`ts_csp_reports`**: `received_at DateTime64, publisher_domain LowCardinality(String), release_id String, policy_id -LowCardinality(String), cohort LowCardinality(String), -directive_bucket Enum(script|style|frame|img|connect|font|media| -worker|other), source_bucket Enum(https_host_allowlisted|data|blob| -inline|eval|other), count UInt32`; sorting key `(publisher_domain, -received_at, policy_id)`; TTL 30 days; settings - `[telemetry.csp_reports] enabled, api_host, dataset, token_secret, -secret_store`. Ingest: pre-buffer body ≤ 8 KiB, ≤ 10 reports/request, - strings ≤ 256, nesting ≤ 4, both media types - (`application/csp-report`, `application/reports+json`) with separate - validators, unused fields discarded before logging, own limiter - bucket. +LowCardinality(String), cohort LowCardinality(String), directive_bucket +Enum(script|style|frame|img|connect|font|media|worker|other), +source_bucket Enum(https_host_allowlisted|data|blob|inline|eval|other), +count UInt32`; sorting key `(publisher_domain, received_at, policy_id)`; + TTL 30 days. Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256, + nesting ≤ 4, both media types with separate validators, unused fields + discarded, own limiter bucket. **Caps with values:** 10,000 + reports/hour/publisher and 1,000/hour/cohort; overflow increments the + `csp_overflow` ops counter (dropped reports counted, never parsed + further). - **`ts_ops_counters`**: `received_at DateTime64, publisher_domain LowCardinality(String), release_id String, counter Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked| ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged| -probe), value UInt64`; sorting key `(publisher_domain, received_at, -counter)`; TTL 90 days; settings `[telemetry.ops_counters]` (same - shape). -- **Sink plumbing:** one generic multi-target Tinybird sink trait; the +csp_overflow|probe), value UInt64`; sorting key `(publisher_domain, +received_at, counter)`; TTL 90 days. +- **Sink plumbing:** one generic multi-target Tinybird sink trait; `RuntimeServices` (`platform/types.rs:158`) gains handles for client-events, CSP, and ops targets beside the auction sink; each - target has its own dataset + token settings as above. + target has its own dataset + token settings. - **Settings:** `[telemetry.client_events] collection_enabled, sink_enabled, sample_rate, api_host, dataset, token_secret, -secret_store, max_body_bytes`; `[telemetry.trace_auth] secret_store, -active_kid, previous_kids, sampling_key_secret`; - `[telemetry.diagnostic] secret_store, active_kid`. +secret_store, max_body_bytes`; `[telemetry.csp_reports]` and + `[telemetry.ops_counters]` (same transport shape); + `[telemetry.trace_auth] secret_store, active_kid, previous_kids, +sampling_key_secret`; `[telemetry.diagnostic] secret_store, +active_kid`; `[telemetry.probe]` admin-issued run configuration. - APS parsing returns structured drop observations - `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values - today); >8192 → `dimensions_out_of_range` with dimensions omitted. + `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values); + > 8192 → `dimensions_out_of_range` unclamped. ### 5.7 Modes and SLIs -Production (sink-backed): deterministic keyed sampling (default 0.10). -SLIs: **pipeline availability** — per-sink probe freshness ≤ 5 min and -probe loss < 0.1% (fails during sink outages, alarmed); **failure -detection** — a failure mode affecting ≥ 1% of sampled render attempts -visible within one hour, at ≥ 10,000 sampled attempts/hour. Diagnostic: -credential-gated, unsampled, full stream + console mirroring + debug -envelopes (§2.5). +Production (sink-backed): deterministic sampling (0.10). SLIs: pipeline +availability (per-sink authenticated probe freshness ≤ 5 min, loss +< 0.1%); failure detection (≥ 1% of sampled render attempts visible +within one hour at ≥ 10,000/hour) — **verified by the injected-failure +alert drill**, not only by probe persistence. Diagnostic: +credential-gated, `dexp`-bounded, unsampled, full stream + console +mirroring + debug envelopes. ### 5.8 Server-side drop surfacing -Bounded structured summary whenever any bid is dropped; `bid_drop` and -`selection_summary` rows (§5.6); the initial-HTML `ts-debug` comment -carries the drop summary; page-bids and `/auction` carry the -tester-gated structured `debug` field. Startup warnings: APS + +Bounded structured summary whenever any bid is dropped; `bid_drop` + +`selection_summary` rows; `ts-debug` comment; tester-gated structured +`debug` on page-bids and `/auction`. Startup warnings: APS + `allow_script_creatives = false`; mediator + direct providers without -an explicit `winner_selection` (§6.1 hard error). +explicit `winner_selection`. ## 6. APS delivery fixes -### 6.1 Mediation — total order, no fictional identities - -Baseline defects: no forwarded candidate id; lossy last-write-wins -`(provider, slot, bidder)` field restoration (`adserver_mock.rs:95`); -arrival-order equal-price ties (`orchestrator.rs:827`); Prebid assumes -USD (`prebid.rs:2433`); APS stamps USD (`aps.rs:475`); no configured -currency. Replacement, identical in the synchronous and split -dispatch/collect paths via one shared helper: - -1. **Currency.** Required `[auction].currency` (ISO 4217). Every - provider parse validates its response currency; contract-implied - currencies are validated as implied — **APS enabled with a non-USD - configured currency is a startup error**, not a silent all-drop. - Mismatch → `bid_drop{currency_mismatch}`. -2. **Candidate identity.** `source_candidate_id` = - `(provider_name, upstream_bid_id)`; the upstream id is **required, - ≤ 64 chars, and unique per provider response — bids missing an id or - duplicating one are rejected** (`bid_drop{missing_bid_id | -duplicate_bid_id}`). No fingerprint fallback: a fingerprint over a - partial field set can merge economically distinct demand, and - OpenRTB requires bid ids — rejection is honest. `candidate_id` (wire - echo) is CSPRNG with in-auction collision retry and is never an - ordering key. Mediator-native bids get identities the same way from - the mediator's response. -3. **Mediator exchange.** Forwarded candidates carry - `ext.trusted_server.candidate_id`; the mediator echoes it (contract - for every mediator, `adserver_mock` included). Echoed id resolves → - the forwarded candidate, provenance `mediator`: **price is - authoritative from the mediator; every render-source and - notification field comes from the stored candidate; deal fields are - out of scope entirely** (no deal identity exists in the model). A - mediator bid whose any render-source field differs from the stored - candidate is reclassified mediator-native. An unresolvable echoed id - → that bid is discarded and counted (`provenance_invalid`); - mediator-native bids **and direct candidates remain eligible** — - fail-closed applies to the invalid claim, not the slot. -4. Floors filter both populations; echoed candidates remove their - direct twins (`dedup_hits`). -5. **Selection order (total, intrinsic):** decoded CPM desc → - provenance rank (mediator first) → `source_candidate_id` asc. - Arrival order can never matter. -6. **Strategy** required when mediator + direct providers coexist - (startup error if absent): `mediator_only` (timeout → no winners - unless `mediator_timeout_fallback = "direct"`) or - `merge_highest_cpm` (timeout → direct-only, reported). -7. Reporting: `selection_summary` slot rows + totals row (§5.6). +### 6.1 Mediation + +Eight rules, arrival-independent, one shared helper across both +lifecycles: (1) required `[auction].currency` (APS + non-USD = startup +error; Prebid validates at parse, `prebid.rs:2433`); (2) candidate +identity `source_candidate_id = (provider_name, upstream_bid_id)` with +the upstream id **required, ≤ 64 chars, unique** — missing/duplicate/ +oversized → `bid_drop{missing_bid_id | duplicate_bid_id | +bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` (CSPRNG +wire echo, never an ordering key); (3) mediator echoes +`ext.trusted_server.candidate_id` — resolves → forwarded candidate, +provenance `mediator`, **price authoritative from the mediator, every +render-source and notification field from the stored candidate, deal +fields out of scope**; any render-source difference → mediator-native; +unresolvable echo → discarded + counted (`provenance_invalid`), +mediator-native and direct candidates stay eligible; (4) floors both +populations, echoes dedup twins; (5) **total order** decoded CPM desc → +provenance rank (mediator first) → `source_candidate_id` asc; (6) +required `winner_selection` (`mediator_only`: timeout → no winners +unless `mediator_timeout_fallback = "direct"`; `merge_highest_cpm`: +timeout → direct-only); (7) `selection_summary` reporting. ### 6.2 Dimensions -Exact size membership stays (`aps.rs:675`). The fix is visibility -(`bid_drop{invalid_dimensions, w, h}`) plus documentation ("request the -sizes you accept"); accepting unrequested sizes would conceal an -upstream protocol violation. +Exact membership (`aps.rs:675`); structured `bid_drop{invalid_dimensions, +w, h}`; "request the sizes you accept." ### 6.3 Script creatives -`allow_script_creatives` stays default-`false`; the consequence is loud -(§5.8). **DR-2's output is a deployment decision:** enable with explicit -sandbox/security approval, or accept a quantified maximum -excluded-demand share and gate Phase 3 on it. +Default `false`, loud (§5.8); **DR-2 is a deployment decision** (enable +with security approval, or accept a quantified excluded share and gate +Phase 3 on it). -### 6.4 Render identity +### 6.4 / 6.5 -As specified in G2 (token format, registry-union capacity, tombstones, -cache-path byte identity). - -### 6.5 Fallback - -As specified in G4e/G4a/G4f (attribution-gated child attempt; awaitable -renderer conversion precedes it; timeouts never render). +Render identity per G2; fallback per G4e (child attempt). ### 6.6 Renderer endpoint -- The static renderer document route registers **unconditionally in - every adapter** (the APS provider stays config-gated); startup - validation fails if an auth handler pattern covers it; §G5 isolation - rules apply. -- Path `/integrations/aps/renderer/v1`, embedded, served - `Cache-Control: public, max-age=31536000, immutable`; canary versions - `no-store` (or bounded below cohort lifetime); a **checked-in header - manifest per renderer version** freezes headers (CSP included) with - the bytes — a version's headers never change after publication. - Unknown versions → 404 `no-store`. -- Three-message acknowledgement per G4b sequence 1. -- Server route counters are aggregate (`ts_ops_counters`) — the nonce - rides the URL fragment and never reaches the server. -- **CSP rollout, three instruments:** discovery on the **currently - enforced** policy with reporting attached (report-only alone cannot - reveal what the enforced policy already blocks); **tightening** via - report-only; **relaxation** via a small enforced cohort on a - short-lived canary version, gated on runner acceptance rate, CSP - violation rate, and render-failure rate, with a kill switch; once - frozen, a new immutable `/v2` ships. **CSP reports are advisory** — - for opaque-origin reports the body-supplied document URL and policy - version are forgeable, so policy identity is encoded in the - **server-selected report path** (`/_ts/csp-reports/`); - bucketed aggregation only (§5.6 buckets, global and per-cohort caps); - never a sole automatic rollback signal. Browser capture on Chromium, - Firefox, and WebKit (CI is Chromium-only today, - `playwright.config.ts:16`; the matrix extends for this suite). +Unconditional route in every adapter (provider stays config-gated); +startup auth-pattern validation; `/integrations/aps/renderer/v1` +embedded, served `public, max-age=31536000, immutable` with a +checked-in per-version header manifest (headers frozen with bytes); +canary versions `no-store`; unknown version 404 `no-store`; +three-message ack (G4b-1); aggregate route counters in +`ts_ops_counters`; CSP rollout three instruments (enforced discovery / +report-only tightening / enforced-cohort relaxation on a short-lived +canary version, gated on runner acceptance, violation rate, render +failure, with a kill switch); **policy identity in the server-selected +`policy_id` path** (also the release/cohort carrier, §0); bucketed +aggregation only with the §5.6 caps; never a sole rollback signal; +three-browser capture (`playwright.config.ts:16`). ### 6.7 One descriptor schema -Wire truth is the tagged `BidRenderer` envelope (discriminator on the -enum, `types.rs:188-211`). A wire-schema crate/xtask (separate from -`trusted-server-js` — core already depends on it, `Cargo.toml:45`, so -the reverse edge would cycle) generates: the JSON-Schema artifact, the -TS structural parser, the ES5 inline validator fragment, the §5.1 -per-event/per-reason validity matrix, and shared fixtures — checked in, -staleness-gated. Semantic checks stay handwritten on both sides -(URL/origin policy, canonical base64, length bounds, the exact one-bid -AAX projection, cross-field equality). Unknown-field tolerance applies -only to the outer versioned descriptor; the decoded AAX envelope -remains an exact projection. A shared positive + adversarial corpus -(extra fields, wrong versions, oversized payloads, URL smuggling, -non-canonical base64) runs through the Rust validator, the generated TS -parser, and the generated inline fragment in CI. +Tagged `BidRenderer` envelope (`types.rs:188-211`); wire-schema +crate/xtask (no core↔js cycle, `Cargo.toml:45`) generates JSON-Schema, +TS parser, ES5 inline fragment, the §5.1 validity matrix, and fixtures; +staleness CI; semantic validators handwritten; outer tolerance only; +exact AAX projection; shared positive + adversarial corpus through all +three validators. ### 6.8 Bridge hardening -Processing order (normative; preserves the baseline defense that stops -propagation before source validation, `gpt/index.ts:1584-1637`): +Order (normative — preserving the baseline stolen-capability defense, +`gpt/index.ts:1584-1637`, **plus the read-only lookup the altered-id +signal needs**): 1. parse `e.data` (bare catch → return); -2. identify a TS-reserved ad id — **live registry or tombstone** (G2); -3. if TS-reserved: `stopImmediatePropagation()` before any validation — - a rejected foreign frame must not be answerable by Prebid's native - handler either; -4. validate source ownership via the bounded walk: known slot-root - `WindowProxy` map, the sender's own parent chain - (`event.source.parent`, …) to depth 5 — never scanning an - attacker-controllable frame tree; -5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); -6. respond, or refuse with `bridge_id_mismatch`. - -Non-TS ad ids are untouched. The stolen-token browser test asserts -**neither TS nor the native Prebid listener responds**; listener -registration order has a real-browser assertion. Renderer branches emit -the full §5.1 sequence with G4d notifications only on carrying paths. +2. **read-only source→active-slot lookup for every Prebid Request** — if + the resolved slot expects a different ad id, emit + `bridge_request{matched: false}` (the B1 signal) **without responding + and without suppressing native Prebid** (a truncated `hb_adid` is not + TS-reserved, so suppression would be wrong and the old order could + never produce the signal); +3. identify a TS-reserved ad id (live registry or tombstone, G2); +4. if TS-reserved: `stopImmediatePropagation()` before validation; +5. validate source ownership (known slot-root `WindowProxy` map; + sender's parent chain to depth 5; never scanning the frame tree); +6. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); +7. respond, or refuse with `bridge_id_mismatch`. + +Non-TS ids are otherwise untouched. Stolen-token test: neither TS nor +native Prebid responds. Listener order: real-browser assertion. ## 7. TSJS target architecture @@ -854,35 +734,30 @@ services/ slots (registry+handoff), auction client, render engine, consen integrations/ gpt, prebid, aps, creative, datadome, … ``` -Enforced by the two G3 lint rule families. Dissolves the audited -inversions (`core/auction.ts`/`core/request.ts` → -`integrations/aps/render`; `gpt`/`prebid` → `aps`; `prebid` owning the -GPT refresh wrapper). Kernel imports nothing above it; adapters import -kernel only; services import kernel + adapters; integrations import -kernel + services, never each other; stateful services via the G3 -registry only. +Kernel imports nothing above it; adapters import kernel only; services +import kernel + adapters; integrations import kernel + services, never +each other; stateful services via the G3 registry only; enforced per +G3's two rule families. ### 7.2 Adapters -Per external global: `present | pending | timed_out`; `timed_out` is -non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and -drains what is still valid); queued operations carry their own timeouts -and expire with disposition reasons. +`present | pending | timed_out` per external global; `timed_out` +non-terminal; queued operations carry their own timeouts and expire with +disposition reasons. ### 7.3 Slot registry service Kernel-owned; `WeakMap` + div-id index; -ownership (ts/publisher/adopted), adoption, handoff claims, responsive -resolution, the G4a causal intent queue (NavigationSession for unissued -intents) and cycle/drain state (RuntimeSession), targeting-key history. -No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid` -deleted). +ownership, adoption, handoff claims, responsive resolution, the G4a +causal intent queue (NavigationSession for unissued intents) and +cycle/drain state (RuntimeSession), targeting history. No expandos +(`__tsRenderGeneration`/`__tsRenderBid` deleted). ### 7.4 Final global surface (hard cutover) | Legacy surface (removed at cutover) | Final shape | | --------------------------------------- | --------------------------------------------------------------------- | -| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue | +| `window.tsjs.que` | `window.tsjs.que` — unchanged | | `globalThis.tscreative` | `tsjs.creative.*` | | `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | | `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) | @@ -892,84 +767,52 @@ deleted). | `tsjs._internal` | kernel registry (G3), frozen after boot | | (new, public) | `tsjs.definePlugin({id, release, install})` | -**Bootstrap correctness and transactional ownership:** every -server-injected initializer creates the container **idempotently, -field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}` -(the ad-slot script's `window.tsjs = {}` at `publisher.rs:3665` is -fixed). Ownership states: `unclaimed → installing → kernel | fallback`, -with an **owner generation** counter. The kernel installs wrappers -**inert** and flips them live at a single commit point; a throw before -commit runs the shared unwind inventory and marks `failed`. **The -watchdog path is race-free:** on a 10 s stuck `installing`, the -watchdog aborts the owner-generation-scoped `AbortController`, runs the -same shared unwind inventory to completion, and only then atomically -transitions `failed → fallback`; **every late kernel continuation and -disposer validates the owner generation** and self-discards on -mismatch — a resumed async installation can neither overwrite fallback -wrappers nor perform a stale commit. A bundle arriving after fallback -committed defers for the page (`bundle_partial`). Tests: throws -injected after each boot checkpoint **and** hung checkpoints that -resume after fallback claims ownership. +Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que +||= []; tsjs.boot ||= {}`; `publisher.rs:3665`'s clobber fixed); +transactional ownership `unclaimed → installing → kernel | fallback` +with an owner-generation counter; kernel installs inert and flips at one +commit point; throws unwind to `failed`; the 10 s watchdog aborts the +owner-generation-scoped controller, completes the shared unwind, then +atomically transitions `failed → fallback`; late continuations and +disposers validate the owner generation and self-discard; a bundle +arriving after fallback committed defers (`bundle_partial`). Tests: +throws per checkpoint and hung-resume-after-fallback. ### 7.5 Messaging module All `postMessage` through one module: versioned envelopes, name -constants (the `'Prebid Request'` literal appears at six sites today; -the APS handshake existed in three copies), G4b nonces, §6.8 -validation. The minimal module (envelope + constants + validators used -by the bridge) lands in Phase 1; full call-site migration in Phase 4. - -### 7.6 Plugin lifecycle — transactional — and sessions - -`tsjs.definePlugin({id, release, install})` — object form; `release` is -the build-generated `release_id` constant; **there is no plugin-level -`dispose` hook** — disposal is exclusively `ctx.onDispose` -registrations, which have exactly-once reverse-order semantics -(revision 7's optional `dispose?` had no defined ordering and is -removed). `install(ctx): void | Promise`: - -- `ctx.signal` (aborted on quarantine/disposal); synchronous - `ctx.onDispose(fn)`; effects registered as they are made; - reverse-order unwind on throw/reject/abort; per-disposer exception - isolation; a disposer registered after disposal is invoked - immediately; pending late registrations capacity 16, bound 10 s → - `bundle_partial`; release mismatch quarantines before `install`. -- Sessions: `RuntimeSession` (page lifetime: bridge listener + - reservation store, history hook, pbjs subscriptions, adapters, beacon - queue, physical slot cycle/drain state, in-memory diagnostic - credential); `NavigationSession` (per navigation: trace + - authorization + renewal timer, render attempts, slot aliases, - unissued intents, targeting history); `RenderAttempt` (per G4a cycle - / G4f attempt). Each owns an enumerable disposal inventory; - navigation disposes NavigationSession children only. -- Error policy: no empty `catch` — handle, log with context, or emit a - disposition. The auction fetch gains timeout + `AbortController` and - the G4f discriminated result. -- **Console logging retained, not replaced:** every issue-surfacing - condition keeps or gains a `log.warn` carrying the same reason code - as its beacon event; `debug`-level delivery/security failures are - promoted to `warn`. +constants, G4b nonces, §6.8 validation. Minimal module in Phase 1; full +migration in Phase 4. + +### 7.6 Plugins and sessions + +`tsjs.definePlugin({id, release, install})`; **no plugin-level dispose +hook** — `ctx.onDispose` only, exactly-once reverse order. +`install(ctx): void | Promise` with `ctx.signal`, unwind on +throw/reject/abort, per-disposer isolation, disposer-after-disposal +invoked immediately, pending capacity 16 / 10 s → `bundle_partial`, +release mismatch quarantined before install. Sessions: `RuntimeSession` +(bridge listener + reservation store, history hook, pbjs subscriptions, +adapters, beacon queue, cycle/drain state, in-memory diagnostic +credential), `NavigationSession` (trace + auth + renewal timer, +attempts, aliases, unissued intents, targeting history), +`RenderAttempt`/`AuctionBatch`; enumerable disposal inventories. No +empty `catch`; console logging retained (paired `warn` with the beacon +reason; `debug`-level delivery/security failures promoted). ### 7.7 Bootstrap -`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/ -hydration logic, with the live `servicesEnabled` divergence) shrinks to -a queue-and-flags stub; the bundle replays recorded early calls on -install (browser specs cover replay timing); the no-bundle fallback -("ads render if the bundle fails", pinned by `gpt.rs:1174-1179`) is -**generated from the same TypeScript source** at build time, activated -per §7.4's transactional rules. +`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays +recorded calls; the no-bundle fallback is generated from the same +TypeScript source (pinned by `gpt.rs:1174-1179`), activated per §7.4. ### 7.8 GPT correctness carried with the restructure Unconditional early `slotRequested`/`slotRenderEnded` subscription -(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent -recording); restore the #922 orphan-slot recovery and `updateRender` -enrichment (DR-3 decides #997 vs re-merge); `changeCorrelator: false` -on TS-initiated refreshes (configurable); `enableSingleRequest()` only -when GPT services are not already enabled; ambiguous responsive -resolution emits `render_fail{slot_unresolved}` alongside its console -warning. +(replacing `gpt/index.ts:1091`'s gate); restore #922/#997 (DR-3); +`changeCorrelator: false` (configurable); `enableSingleRequest()` only +when services are not already enabled; ambiguous responsive resolution +emits `render_terminal{failed, slot_unresolved}` alongside its warning. ### 7.9 Decomposition targets @@ -978,324 +821,255 @@ warning. | `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | | `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | | `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | -| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) | +| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` | | `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | ### 7.10 Performance (reproducible) -- **Dedicated workflow** pinned `runs-on: ubuntu-24.04` (browser CI is - `ubuntu-latest` today, `integration-tests.yml:155`) inside a pinned - container image digest; browser = the lockfile-resolved - `@playwright/test` build with its browser revision recorded in the - baseline artifact (the manifest is a caret range, - `browser/package.json:10` — lockfile + recorded revision are - authoritative); compressors pinned by version in the container; - deterministic flags (`gzip -9 -n`, `brotli -q 11`). -- **Module vectors enumerated:** minimal = `[core]`; reference = - `[core, creative, gpt, prebid, datadome]`; maximal = all 13 - discovered modules. Budgets: raw/gzip/Brotli per bundle per vector vs - checked-in baselines (`perf/baselines/*.json`; updates are reviewed - diffs recording image/browser/tool versions; a baseline update is - invalid if any pinned component differs); +5% bytes. -- **Browser timing:** marks `performance.mark("tsjs:bids-script")` - (emitted by the injected bids script) to - `performance.mark("tsjs:first-display")` (emitted by the adapter - wrapper at the first `display()`/`refresh()` dispatch); reference - fixture page; warm HTTP cache; all resources local; 5 warm-ups - discarded, 50 samples; p90 = nearest-rank; gate p90 ≤ baseline × - 1.10; inconclusive (3-run agreement worse than 5%) → one rerun, then - fail. **Maximal-vector peak JS heap ≤ baseline × 1.10.** -- **Server benchmark:** the G5 lookup path; 100 warm-ups, 1,000 - iterations; median and p90; one-sided ≤ baseline × 1.10; 3 - consecutive runs within 5% or inconclusive (rerun, never pass). +Pinned workflow (`runs-on: ubuntu-24.04` — browser CI is `ubuntu-latest` +today, `integration-tests.yml:155` — inside a pinned container digest); +lockfile-resolved Playwright with recorded browser revision +(`browser/package.json:10` is a caret range); pinned compressors +(`gzip -9 -n`, `brotli -q 11`). Vectors: minimal `[core]`; reference +`[core, creative, gpt, prebid, datadome]`; maximal all 13. Budgets vs +checked-in baselines (+5% bytes; baseline records image/browser/tool +versions and is invalid if any differ). Browser timing: +`performance.mark("tsjs:bids-script")` → +`performance.mark("tsjs:first-display")`; reference fixture; warm cache; +local resources; 5 warm-ups, 50 samples, nearest-rank p90 ≤ baseline × +1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail. **Peak +JS heap, reproducibly:** via the Playwright CDP session — +`HeapProfiler.collectGarbage` then `Runtime.getHeapUsage`, sampled at +five fixed points (post-boot, post-adInit, post-first-render, +post-refresh, post-SPA-navigation) on the maximal vector; metric = max +sample; gate ≤ baseline × 1.10; same rerun rule. Server benchmark: the +G5 lookup path; 100 warm-ups, 1,000 iterations; median + p90 one-sided +≤ baseline × 1.10; 3-run 5% agreement or inconclusive. ### 7.11 Toolchain -TypeScript floor to the resolved 5.9 line (lockfile resolves 5.9.3 -under the stale `^5.5.4` manifest). **Release-gating flags:** `strict`, -`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, +TypeScript floor to the resolved 5.9 line; release-gating flags +`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `noImplicitOverride`, -`useUnknownInCatchVariables`. **Gate command (checked-in npm script -`typecheck`):** +`useUnknownInCatchVariables`; checked-in npm script `typecheck`: ``` cd crates/trusted-server-js/lib && npx --no-install tsc -p tsconfig.json --noEmit ``` -(runs where the pinned compiler is installed; `--no-install` guarantees -the lockfile-resolved binary). Dev toolchain bumps as individual -CI-gated PRs with changelog review (this library monkeypatches -`fetch`/`sendBeacon`/DOM prototypes); `prebid.js` excluded from casual -bumps (runtime Prebid is the manifest-locked external bundle; npm pin -and deployed bundle version documented together); monthly review. +Dev toolchain bumps as individual CI-gated PRs; `prebid.js` excluded +from casual bumps; monthly review. ## 8. Rollout -Single-release state machine per §0 (authenticated sticky-cohort -affinity; infrastructure-attributed cohorts). The normative gates table -is Appendix A; every gate names a **checked-in artifact** (versioned -Tinybird pipe under `tinybird/pipes/`, script under `scripts/gates/`, -or workflow under `.github/workflows/`) — prose never substitutes for -an executable reference. Threshold changes require reviewed decision -records. - -**Phase 0 decision records** (owner, evidence, deadline, explicit -go/no-go): DR-1 mediator presence (gates §6.1's Phase-3 scope); DR-2 -script creatives — a **deployment decision** (§6.3); DR-3 #997 vs -re-merge (#922 restoration path); DR-4 mediator candidate-id echo owner -and timeline (`merge_highest_cpm` is config-blocked until delivered); -DR-5 non-Fastly sinks (splits Phase-2 gates). - -- **Phase 0 — Identity, schemas, toolchain, decisions.** Release-time - asset materialization; `format_version` + config-hash verification; - §5.6 schemas deployed writer-off; toolchain floors; dead expando - deletion; §5.8 drop surfacing; the five DRs; the gate artifacts - themselves (pipes/scripts/workflows). -- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry, - transactional bootstrap ownership.** -- **Phase 2 — Trace + beacon.** Issuance on all three paths + renewal + - diagnostic upgrade + probe mode; four-adapter ingest incl. - `/_ts/trace-auth`; per-sink probes. Gates split per DR-5: HTTP parity - (all adapters) vs persistence (sink-backed). -- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required - `winner_selection` + `[auction].currency`; render token + reservation - store; renderer route + three-message ack + CSP report route; §6.8; - G4a–G4g; `notification_sent` with server-minted `notif_id`; fallback; - DR-3 restoration; correlator + SRA fixes. -- **Phase 4 — Structure.** Full layering + both lint families; plugin - lifecycle; adapters; full slot registry; full messaging migration; - final namespace; four-flow behavioral parity. -- **Phase 5 — Decomposition + cutover.** File splits; script-guard - consolidation; bootstrap stub + generated fallback (error/hang/ - arbitration tests); four-flow parity rerun; **the full Phase-3 - statistical canary/control gates and the real-GAM suite repeat on the - exact immutable release candidate** before router weight rises beyond - the low-weight canary; then weight-up, purge, 24 h monitored window. - -**Statistical method (normative for A.1 production gates):** -populations are **sampled traces only** — diagnostic traffic is -operator-selected and failure-enriched, so it is reported separately -and never enters a statistical gate. Assignment unit = the sticky -cohort token (browser session); cohorts randomized at HTML request; -stratification by publisher and slot. Non-inferiority gates use -**one-sided 95% confidence bounds on the relative difference** -(canary/control − 1 ≥ −2% for fill and billing-per-1,000-attempts; -canary/control − 1 ≤ +2% for p95 latency); improvements always pass. -Floors are **per flow per arm** (Appendix A); flows that cannot reach -their floor in the window (direct, fallback at low adoption) are gated -hermetically and by the real-GAM suite instead of statistically — a -rare flow never permanently blocks rollout, and a statistical gate that -cannot reach its floor is **inconclusive** (extend once, then Hold). -`cycle_unattributable` is divided by **all TS request cycles that were -candidates for attribution** — the failures live in their own -denominator. Missing telemetry counts as failure. Billing reconciliation -(§G4d) runs alongside as the authoritative duplicate check. +Single-release state machine per §0. **Statistical method:** sampled +traces only (diagnostic reported separately); **randomization unit = +`assignment_id`** (minted inside the affinity token, §0; persisted on +client-event rows), so attempts cluster by session; the estimator is a +**checked-in cluster bootstrap** (`scripts/gates/estimator.py`): +resample assignment ids, 2,000 resamples, fixed seed recorded in the +gate artifact, strata (publisher × slot) weighted by control-arm +traffic share; one-sided 95% confidence bounds on **relative +differences**; each gate is an independent go/no-go (no cross-gate +multiplicity correction — stated). Missing telemetry counts as failure. +Per-flow floors; rare flows (direct, fallback) gate hermetically + +real-GAM, never statistically. `cycle_unattributable` divides by all +attribution-candidate TS cycles. **Billing cohort dimension in external +reports:** TS-driven GAM requests set a `ts_arm=` key-value, +making the arm a reportable GAM dimension for reconciliation. + +**Phase 0 decision records:** DR-1 mediator presence; DR-2 script +creatives (deployment decision); DR-3 #997 vs re-merge; DR-4 +candidate-id echo owner (`merge_highest_cpm` config-blocked until +delivered); DR-5 non-Fastly sinks (splits Phase-2 gates, scopes +persistence gates). + +Phases: **0** identity/schemas/toolchain/DRs + gate artifacts +(pipes/scripts/workflows) + observation-only control build definition; +**1** kernel/sessions/minimal messaging/cycle registry/transactional +bootstrap — Phase-1 gates are **hermetic** (in-page counters; beacon +transport does not exist until Phase 2); **2** trace + beacon + renewal + +- diagnostic upgrade + probe issuance + four-adapter ingest + per-sink + probes + the alert drill; **3** APS delivery (schema crate + corpus; + mediation; render token + reservation store; renderer route + + three-message ack + CSP route; §6.8; G4a–G4g incl. AuctionBatch; + `notification_sent`; fallback; DR-3 restoration; correlator + SRA); + **4** structure (layering, plugins, adapters, registry, messaging, + namespace; four-flow parity); **5** decomposition + bootstrap stub + + parity rerun + **full Phase-3 statistical and real-GAM gates repeated + on the exact immutable RC** (attested per A.3) before weight-up, then + cutover per §0. ## 9. Test acceptance matrix -Hermetic CI blocks PRs; the real-GAM suite is release-gating per -Appendix A.3. - -| Area | Must cover | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Request cycles | intent-vs-request; disabled-initial-load `display()` retired for **any caller**; publisher-display → TS-refresh and TS-noop-refresh → TS-display supersession (same-class stealing); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap | -| Ack protocol | all four G4b sequences incl. the adm reporter; three-message APS sequence; five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed acks; acks after navigation disposal; per-path deadlines | -| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; prior-navigation ids suppressed via the reservation store after disposal; bounded parent-chain walk; listener order (real browser) | -| Render semantics | binds per flow (never targeting); `burl` at accepted; attempt idempotency; `notification_sent{notif_id}` per dispatch; duplicate-detection alarm; accepted-but-blank honesty; `gam_collapsed{action}` emission + guarded resize | -| Direct `/auction` | trace header + `ext.trusted_server.trace` echo; discriminated auction-client errors (timeout/network/http/invalid vs `no_bid`); per-slot latest-wins with reversed responses; generation checks; `RequestAdsResult` settlement; server preserves + expands `nurl`/`burl`, client validates | -| Fallback | child-attempt identity with `parent_flow`; renders only on attributed parent `gam_empty`; publisher-initiated never; timeout never renders; SPA cancellation; kill-switch pre-commit cancellation (commit = earliest irreversible action) | -| Mediation | required-unique upstream ids (`missing_bid_id`/`duplicate_bid_id` rejection — no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules (repricing kept; any render-source difference → native); provenance fail-closed scope; strategy-specific timeouts; both lifecycles; APS + non-USD startup error | -| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed | -| Trace auth | auth ≤ 256 B; encoding vectors (kid charset, canonical exp, u32/u64 BE prefixes, unpadded base64url); expiry/skew/max-future; renewal preserves mode via token presentation; renewal-after-expiry fails closed; previous-key retention; deterministic sampling (same trace → same mode concurrently; exact u64 threshold algorithm) | -| Diagnostic | credential issuance under admin auth + CSRF; fragment cleared via `replaceState`; in-memory-only storage; upgrade as the sole mode transition; auth `exp` capped at credential expiry; pre-upgrade local buffering then diagnostic flush; forgery/wrong-origin/replay-past-expiry | -| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket | -| Affinity | opaque token validation (forged/expired/retired → control + reissue); coherence for HTML, assets, APIs, **beacons, CSP reports**; cache-key normalization; rollback reassignment | -| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; infrastructure cohort attribution (per-pool tokens) | -| Funnels | `flow` set per path incl. `system`; per-flow expected-stage conformance; heartbeat/overflow excluded from render denominators | -| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; overflow coalescing without recursion; ingest abuse incl. absent Origin; sendBeacon Blob type; `credentials: same-origin` with identity-free handling | -| Ingest/limits | per-adapter limiter semantics as declared; TTL reclaim under saturation; unknown-address bucket; Fastly synchronized-burst behavior documented (> 40 concurrent); XFF hop selection; fail-closed 204 | -| Internal routes | wrong-method 405 + `Allow` + `no-store` on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding; per-family origin policies | -| CSP | both media types with separate validators; opaque/null-origin admission; policy-id path identity (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; per-version frozen header manifest | -| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection; generated validity matrix; **compile-time exhaustiveness of `AuctionDropReason` over all producers** | -| Runtime ABI | one kernel under concatenation; exact-release verdicts; late registration; failure isolation; object-form `definePlugin` release check | -| Plugins | partial-install unwind; async rejection; abort while pending; disposer-after-disposal; per-disposer isolation | -| Bootstrap | field-wise idempotent init (ad-slot script no longer clobbers); inert-install + commit flip; throw after each checkpoint unwinds; **hung checkpoint resuming after fallback self-discards via owner generation**; fallback-then-late-bundle deferral | -| Lifecycle | `timed_out → present`; session disposal inventories; unissued intents cancelled by navigation disposal; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`, `definePlugin`) | -| Delivery | unknown hash 410 `no-store`; exact-match immutable with full directive; release-time vector materialization (unlisted vector = build error); config-hash verification failure; cutover rehearsal (weight, purge, rollback) | -| Sinks/monitoring | per-datasource probes (client-events heartbeat, CSP probe policy-id, ops probe counter) per adapter write path; canonical-views-only enforcement (raw-join multiplication test); `publisher_domain` naming | -| Failure injection | Amazon runner redirect/hang/CSP block/script error → distinct §5.1 outcomes; EC/filter failure before renderer dispatch | -| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | -| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness per §2.5; kill-switch snapshot semantics | -| Perf | marks present; three vector contents; heap budget; inconclusive-rerun policy; pinned-environment baseline validity | -| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters | +Hermetic CI blocks PRs; the real-GAM suite is release-gating (A.3). + +| Area | Coverage | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Attempts | `attempt_id` uniqueness; parent/child fallback linkage; exactly one `render_terminal` per attempt; parent `failed{gam_empty}` precedes `fallback_start` | +| Terminal model | discriminated outcomes incl. `no_bid`/`cancelled`; per-reason `source` nullability; validity-matrix conformance | +| Request cycles | disabled-initial-load `display()` retired for any caller; TS-noop-refresh → TS-display and publisher variants (same-class supersession); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap | +| Ack per path | four G4b sequences; APS three-message; owner-observed ADM `load`/`error` with **nonce never in bidder realm**; bidder-synthesized message ignored; early-`burl` attempt fails; per-path deadlines; stale/replayed; after-disposal | +| Bridge signal | truncated/replaced `hb_adid` → `bridge_request{matched:false}` with no response and native Prebid untouched; stolen TS ids fully suppressed (neither TS nor native responds); listener order | +| AuctionBatch | multi-slot partial/full supersession; fetch aborts only when all children dead; stale-bid filtering through live child identity; timeout; navigation disposal; reversed responses; discriminated auction-client errors | +| Diagnostic | `dexp` ceiling — renewal-before-expiry capped, repeated renewal to ceiling then failure; credential byte-level vectors; fragment clearing; in-memory storage; pre-upgrade buffering then flush; forgery/wrong-origin/replay | +| Transport/limits | batch caps (64 events AND 12 KiB); ≤ 4 batches/flush; sustained single-tab, 5-tab, diagnostic flush, pagehide burst inside the 60/120 budget; overflow coalescing; Fastly synchronized-burst documented; unknown-address bucket | +| Affinity | token vectors (format, rotation, constant-time); state-dependent defaults (canary vs post-cutover reassignment; no stale-cookie stragglers); coherence for HTML/assets/APIs/beacons; **CSP affinity via `policy_id` path, cookie-less renderer reports** | +| Arms/measurement | observation-only control build emits comparable sampled telemetry (its zero-behavior-change gated by hermetic parity vs baseline); arm-specific datasources; union view stamps `deployment_pool`; `assignment_id` on rows; cluster-bootstrap fixture; GAM `ts_arm` key | +| Latency/fill | `t_rel_ms` monotonic bounds; `attempt_started`→`render_terminal` durations; per-gate numerator/denominator queries (A.1); named source tables/joins | +| Joins | `Nullable(UUID)` type equality; auction-level vs slot-level vs bid-level canonical joins; raw-join multiplication rejected | +| Probes | authenticated probe rows (`probe_run_id`/`expected_seq`/`adapter`/`target`) per datasource; secret-derived CSP probe `policy_id` unforgeable; probe issuance protocol; persistence gates scoped to sink-backed adapters | +| Alerting | injected-failure drill: alert fires ≤ 1 h | +| Kill switch | switch state via HTML and response extensions; attempts created after delivery honor it before each irreversible action (incl. before `nurl`); SSAT-on-stale-page exemption documented and tested | +| Drop enum | `empty_seatbid_bids` + `bid_id_too_large` mapped; shared typed enum across producers; compile-time exhaustiveness | +| RC attestation | real-GAM workflow consumes the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}` and emits it in the attested output; gates parameterized by (release, pool, epoch); RC re-canary inherits each row's window | +| Config/affinity | `config_hash` SHA-256 over exact bytes verified at startup (mismatch = fail); affinity HMAC vectors + rotation + constant-time | +| Heap | CDP procedure at the five fixed points; GC before sample; rerun rule | +| Lint | custom scope-aware rule catches `window`/`globalThis`/`self` member access and same-file aliases outside adapters (claim scoped to these shapes) | +| Notifications | dispatch mechanics (no-cors GET, no-referrer, keepalive; `Image()` fallback; server-side macro expansion only); `notif_id` emission; duplicate alarm + reconciliation; hermetic exactly-once | +| Mediation | required-unique bounded upstream ids (`missing`/`duplicate`/`bid_id_too_large`, no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules; provenance fail-closed scope; strategy timeouts; both lifecycles; APS + non-USD startup error | +| Render token | format/CSPRNG/retry/TTL/one-time; scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed | +| Trace auth | auth ≤ 256 B; encoding vectors; expiry/skew/max-future; renewal preserves mode; renewal-after-expiry fails; previous-key retention; deterministic sampling (exact u64 threshold; same trace → same mode concurrently) | +| Internal routes | wrong-method 405 + `Allow` + `no-store`; unknown version 404; no fall-through; dispatch before filters; no forwarding; per-family origin policies | +| CSP | both media types; opaque/null origin; policy-id path identity (forged body ignored); bucket caps + overflow counter; three-browser capture; per-version frozen header manifest | +| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX; generated validity matrix | +| ABI/plugins/boot | one kernel; exact-release verdicts; object-form release check; partial-install unwind; abort-pending; disposer-after-disposal; hung-resume self-discard; fallback-then-late-bundle deferral | +| Lifecycle | `timed_out → present`; disposal inventories; unissued intents cancelled by navigation; boot consume/freeze/delete; final-namespace smoke | +| Delivery | unknown hash 410 `no-store`; exact-match immutable; release-time vector materialization (unlisted = build error); cutover rehearsal | +| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | +| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness; kill-switch snapshot semantics | +| Perf | marks present; three vectors; heap CDP; inconclusive-rerun; pinned-environment baseline validity | ## 10. Alternatives considered -1. Patching APS point-failures without telemetry — rejected: four - correct fixes have not produced reliable ads. -2. Always direct-render APS (skip GAM/PUC) — rejected: changes GAM - reporting/pacing unilaterally; kept as the attributed-`gam_empty` - fallback. -3. Single module graph / shared chunks now — rejected for this release: - changes the delivery pipeline while everything else changes; - successor option behind the same registry surface. -4. Full rewrite in one branch without phases — rejected: the - browser-spec safety net is thinnest exactly where behavior changes. -5. Dropping the ES5 bootstrap — rejected: loses the pinned no-bundle - guarantee; the generated fallback keeps it. -6. Timeout-triggered fallback rendering — rejected: GPT requests cannot - be cancelled; timeout racing a late fill can double-render and - double-bill. -7. Timeout-based quarantine re-arm — rejected (recreates the stale-event - bug). -8. N/N−1 compatibility machinery — removed by the §0 policy decision. -9. Client-computed notification hashes — rejected (no key without - breaking the pseudonymization boundary); server-minted `notif_id`. -10. Fingerprint identities for id-less bids — rejected (can merge - distinct demand); rejection with closed reasons instead. -11. Plain readable cohort cookie — rejected (dark-pool opt-in + - cache-cardinality abuse); opaque authenticated token. -12. `billing_outcome` event — removed (no honest producer exists). +Revision 8's twelve rejections stand (patch-without-telemetry; +always-direct-render; shared chunks now; big-bang rewrite; dropping the +bootstrap; timeout fallback; timeout re-arm; N/N−1 machinery; +client-computed hashes; fingerprint identities; plain cohort cookie; +`billing_outcome`), plus: **13.** injected in-realm ADM reporter — +rejected (hands bidder code the acceptance credential); owner-observed +`load`/`error` instead. **14.** cookie-routed CSP affinity — rejected +(opaque-origin reports carry no cookie); path-identity routing. **15.** +token-identity alone for arm attribution — rejected (authorization is +not a row dimension); arm-specific datasources + stamped union view. +**16.** indefinite diagnostic renewal — rejected; `dexp` ceiling. +**17.** shared attempt identity for parent/child — rejected; distinct +`attempt_id`. ## 11. Risks -- Hard-cutover blast radius — accepted by policy; bounded by the §0 - runbook (probes, low-weight canary, 24 h window, weight-back - rollback). -- Sticky-cohort routing is new infrastructure the cutover depends on — - Phase 0 work; its coherence test is release-gating. -- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates - `merge_highest_cpm`; config validation enforces the block. -- Notification triggers become a published contract for PBS-path - demand — changing them later is a breaking change for SSP reporting. -- Required `[auction].currency` and `winner_selection` (mediated - deployments) are a deliberate startup-error class under §0. -- Beacon abuse — pre-parse caps, per-family origin policies, fail-closed - numeric limits, signed modes, credentialed diagnostics. -- Registry/limiter memory — explicit capacities, TTL reclamation, - reject-at-capacity; Fastly overshoot documented, not claimed. -- CSP data is advisory — never a sole rollback signal. -- Sink blindness — per-datasource probes with datasource-side queries. -- ABI freeze — `tsjs._internal.registry` is load-bearing; exact-release - verdicts are the contract. -- Schema generation — checked-in artifacts + staleness CI. +Revision 8's register stands (cutover blast radius; sticky-cohort +infrastructure; mediator wire-contract change; published notification +triggers; required-config startup errors; beacon abuse; memory bounds; +advisory CSP; sink blindness; ABI freeze; schema generation), plus: the +**observation-only control build** is new scoped work whose "zero +behavior change" property is itself gated (hermetic parity vs baseline); +and `assignment_id` persistence is pseudonymous but new — bounded by the +24 h token TTL and excluded from any identity join by schema review. ## 12. Success criteria -1. APS creatives render in each configured flow (SSAT, client-Prebid, - page-bids, direct), hermetically and in the release-gating real-GAM - suite per Appendix A.3's enumerated topologies. +1. APS creatives render in each configured flow, hermetically and in the + attested real-GAM suite. 2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the - failing class from one page load (including A1–A4 via the debug - envelopes, and including an initially-unsampled page via pre-upgrade - buffering); §5.7 SLIs hold on sink-backed deployments. -3. Both lint families pass with zero exceptions; stateful sharing only - via the registry; exact-release mismatches quarantine loudly. + failing class from one page load; §5.7 SLIs hold, including the alert + drill. +3. Both lint families (incl. the custom scope-aware rule) pass; stateful + sharing only via the registry; exact-release mismatches quarantine + loudly. 4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or generated. -5. Attempt counts key on `(trace_id, nav_gen, refresh_gen, slot)`; - traces stay navigation-scoped; no double counting; orphan recovery - has a non-vacuous test; G4a holds including caller-independent - retirement and same-class supersession. -6. The only TSJS-owned global is `window.tsjs` with the §7.4 final - shape; no expandos; legacy names gone at cutover. -7. §7.10 budgets hold on the dedicated pinned workflow. -8. No existing warning lost; issue-surfacing conditions log `warn`+ - with the beacon's reason code. -9. TypeScript floor matches resolved 5.9 with the §7.11 flags via the - checked-in `typecheck` script; `prebid.js` pin documented with the - deployed bundle. -10. `nurl`/`burl` fire only on carrying paths at their G4d binds, - attempt-scoped and idempotent; APS fires neither; hermetic - exactly-once tests pass; production duplicates alarm via - `notification_sent` and reconcile to zero via billing reports. -11. Trace-bearing responses are `private, no-store`; authorizations - are per-trace, signed, mode-carrying, renewal-preserving, with - diagnostic upgrade as the sole authenticated mode transition; - unsampled traces transmit nothing. -12. The cutover runbook rehearsed (weight switch, purge, rollback); - config-hash verification enforced. -13. The Phase-3 statistical and real-GAM gates pass on the exact - immutable Phase-5 release candidate before weight-up. -14. The Appendix A gates table shipped with this design; every change - carries a reviewed decision record. -15. The baseline APS fix behaviors are re-implemented in the target - architecture with the baseline browser tests passing unmodified. +5. Exactly one `render_terminal` per `attempt_id`; parent/child fallback + attempts are distinct rows; attempt aggregation keys on `attempt_id` + with the tuple as grouping only. +6. The only TSJS-owned global is `window.tsjs` (§7.4); no expandos. +7. §7.10 budgets hold, including the CDP heap procedure. +8. No existing warning lost; issue-surfacing conditions log `warn`+ with + the beacon reason. +9. TypeScript floor and flags via the checked-in `typecheck` script; + `prebid.js` pin documented. +10. `nurl`/`burl` only on carrying paths at their binds with the + normative dispatch mechanics; APS fires neither; hermetic + exactly-once + production alarm + reconciliation. +11. Trace-bearing responses `private, no-store`; authorizations signed, + mode-carrying, renewal-preserving, diagnostic bounded by `dexp`; + unsampled transmits nothing. +12. Cutover rehearsed; config-hash verification enforced; post-cutover + routing defaults flip (no stale-cookie stragglers). +13. Phase-3 statistical and real-GAM gates pass on the attested + immutable RC before weight-up. +14. Appendix A shipped with this design; changes carry decision records. +15. Baseline APS fix behaviors re-implemented; baseline browser tests + pass unmodified. ## 13. Open questions -Only one remains outside the decision records: does Amazon expose any -creative-completion acknowledgement that could add a -post-`render_accepted` state under a new name (future enhancement)? +One: does Amazon expose any creative-completion acknowledgement that +could add a post-`accepted` state under a new name (future +enhancement)? --- ## Appendix A — Normative rollout gates (initial values) -Owners are roles: **RO** = release owner, **QA** = QA owner, **OPS** = -release owner's on-call. Assignment unit = the authenticated sticky -cohort token (§0), randomized at HTML request, stratified by publisher -and slot. Statistical gates use **sampled traces only** (§8 method); -diagnostic traffic is reported separately. All production queries run -against canonical views via **checked-in versioned pipes** -(`tinybird/pipes/gate_.pipe`); probe/parity suites are checked-in -scripts (`scripts/gates/.sh`) or workflows. "Hold" = router -weight frozen; "Rollback" = weight to previous release + re-purge. -Changing any row requires a reviewed decision record. +Roles: **RO** release owner, **QA** QA owner, **OPS** on-call. +Randomization unit: `assignment_id` (§0). Statistical gates: sampled +traces, cluster bootstrap (§8), canonical views only, checked-in +artifacts (`tinybird/pipes/gate_*.pipe`, `scripts/gates/*.sh`, +`.github/workflows/*`). Every statistical row specifies +numerator/denominator/source; missing rows count as failure. "Hold" = +weight frozen; "Rollback" = weight back + re-purge. Changes require +decision records. Low volume: inconclusive → extend once → Hold. ### A.1 Phase gates -| Phase | Gate | Artifact (checked in) | Denominator | Floor | Threshold | Window | Owner | Action | -| ----- | ----------------------------- | ---------------------------------------------------------- | --------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------- | ------ | ----- | -------- | -| 0 | Dark-pool health | `scripts/gates/probe-pool.sh` | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold | -| 0 | Schema validation | `scripts/gates/schema-writes.sh` (deterministic writes) | synthetic rows | 10,000 | **0 rejections** (writes are deterministic) | 24 h | RO | Hold | -| 0 | Asset identity | `scripts/gates/asset-probe.sh` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold | -| 0 | Config binding | `scripts/gates/config-hash.sh` | pools | all | manifest hash verified on every pool | once | OPS | Hold | -| 1 | ABI cleanliness | `gate_abi.pipe` (probe pages) | probe page loads | 1,000 | 0 `abi_mismatch`/`bundle_partial` | 24 h | QA | Hold | -| 1 | Bootstrap ownership | hermetic suite `bootstrap-ownership.spec` | checkpoints incl. hung-resume | all | 100% unwind/self-discard | CI | QA | Hold | -| 2 | Ingest HTTP parity | `scripts/gates/ingest-parity.sh` (4 adapters, 4 families) | parity cases | all | 100% | CI | QA | Hold | -| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | probe batches | 10,000 events | acceptance ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | -| 2 | Per-sink probes | `gate_probes.pipe` (3 datasources × adapters) | probe writes per sink | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | -| 3 | Funnel: ssat/prebid/page_bids | `gate_funnel.pipe` per flow | eligible APS wins (sampled traces), per flow-arm | 10,000 each | per A.2 | 24 h | RO | Rollback | -| 3 | Funnel: direct/fallback | hermetic + real-GAM rows (A.3) — not statistical | suite cases | all | 100% | CI+RG | QA | Hold | -| 3 | Attribution soundness | `gate_cycles.pipe` | **all TS request cycles candidate for attribution** | 10,000 | `cycle_unattributable` < 0.5% | 24 h | RO | Rollback | -| 3 | GAM fill | `gate_fill.pipe` | cohort ad requests per arm | 10,000 | one-sided 95% CB: rel. diff ≥ −2% | 24 h | RO | Rollback | -| 3 | Latency | `gate_latency.pipe` | cohort attempts per arm | 10,000 | one-sided 95% CB: p95 rel. diff ≤ +2% | 24 h | RO | Rollback | -| 3 | Billing | `gate_billing.pipe` + GAM report reconciliation | attempts per arm (per-1,000 normalization) | 100,000 or 7 d | one-sided 95% CB: rel. diff ≥ −2% | window | RO | Rollback | -| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` (detection) + billing reconciliation | burl dispatches | 1,000 | 0 observed duplicates; reconciliation clean | 24 h | RO | Rollback | -| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | -| 4 | Four-flow parity | `flow-parity.spec` (hermetic) | parity cases | all | 100% | CI | QA | Hold | -| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / within §7.10 tolerances | CI | QA | Hold | -| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback | -| 5 | Cutover monitor | `gate_slis.pipe` | production traffic | — | probe lag ≤ 5 min; probe loss < 0.1%; `render_fail` rate ≤ pre-cutover canary + 0.5 pt | 24 h | OPS | Rollback | - -Low-volume handling: a statistical gate that cannot reach its floor in -its window is inconclusive — extend once; a second inconclusive is a -Hold, never a pass. - -### A.2 Expected stages per flow (Phase 3 funnel) - -Denominators are named per stage; document/runner rates apply wherever -the renderer document participates. - -| Flow | Expected sequence | Stage thresholds (each vs its named denominator) | -| --------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each ≥ 95% of prior; `renderer_document_loaded`/`bridge_response_sent` ≥ 99%; `runner_failed`+timeouts ≤ 1% of `renderer_document_loaded` | -| prebid | same as ssat (keyed by Prebid `adId`) | same | -| page_bids | same as ssat (after SPA navigation) | same | -| direct | `render_attempt → renderer_document_loaded → render_accepted` | document ≥ 99% of attempts; accepted ≥ 95% of attempts (hermetic/real-GAM gate, not statistical) | -| fallback | parent `gam_empty` → child `fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of `fallback_start` (hermetic/real-GAM gate, not statistical) | - -### A.3 Real-GAM suite (operational row) - -| Field | Value | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------- | -| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | -| Topologies | one per A.2 flow, plus publisher-overlap and disabled-initial-load formation (G4a), plus the same-class supersession case | -| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) | -| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | -| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | -| Command | `npx playwright test --config real-gam.config.ts` from the browser test package | -| Artifact | Playwright HTML report + trace zips as workflow artifacts, retained 90 days | -| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | -| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO | +| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action | +| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | --------------- | -------------------------------------------------- | ------------------------- | ----- | -------- | +| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold | +| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold | +| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold | +| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold | +| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold | +| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold | +| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | +| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | +| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold | +| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback | +| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold | +| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback | +| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback | +| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback | +| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback | +| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback | +| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | +| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold | +| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold | +| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback | +| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; `render_terminal{failed}` rate vs pre-cutover canary | — | lag ≤ 5 min; loss < 0.1%; failed ≤ canary + 0.5 pt | 24 h | OPS | Rollback | + +### A.2 Expected stages per flow + +| Flow | Expected sequence | Stage thresholds (named denominators) | +| --------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_terminal{accepted}` | `targeting_set`/eligible wins ≥ 98%; each later stage ≥ 95% of prior; document/`bridge_response_sent` ≥ 99%; runner fail+timeout ≤ 1% of document | +| prebid | same (keyed by Prebid `adId`) | same | +| page_bids | same (post-SPA-navigation) | same | +| direct | `attempt_started → renderer_document_loaded → render_terminal{accepted}` | document ≥ 99% of attempts; accepted ≥ 95%; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) | +| fallback | parent `render_terminal{failed, gam_empty}` → child `fallback_start → renderer_document_loaded → render_terminal{accepted}` | `fallback_start`/eligible parent `gam_empty` ≥ 99%; accepted ≥ 95% of starts; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) | + +### A.3 Real-GAM suite (attested) + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | +| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** | +| **Output** | **attested report embedding the manifest** — a green run attests the exact build it exercised, parameterized by (release, pool, deployment epoch) | +| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession | +| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) | +| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | +| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | +| Command | `npx playwright test --config real-gam.config.ts` | +| Artifact | Playwright HTML report + trace zips, retained 90 days | +| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | +| Approval evidence | green attested run URL in the release checklist, signed off by RO | From f7c953941ed1f9d79f441a45e9dbf0fea0a0d441 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:38:39 -0700 Subject: [PATCH 551/844] Revise design spec for the ninth review round Revision 10 closes the release-blocking findings and restores true self-containment. The router now injects a trusted internal cohort header the ingest stamps server-side (assignment_id never a client field); the observation-only control build gets a normative event-parity contract so both arms populate the funnel and latency gates identically; affinity token lifetime covers the full experiment window with same-arm renewal and a publisher-host binding in the MAC, and cutover/rollback explicitly retire the opposite release on every request family; the canonical attempt key becomes (publisher_domain, trace_id, attempt_id) with a per-trace collision-retry, and the slot funnel joins a dedicated one-row-per-(auction_id, canonical_slot) summary view with shared client and server slot normalization; the bridge acknowledgement splits into a claim phase that mints the nonce after validating the reserved adId and kernel-held generations and an ack phase that validates the nonce, since the baseline PUC request cannot carry it; a materialized request_cycle_started event gives fill and attribution a real denominator; AuctionBatch terminates every still-live child no_bid after processing a partial multi-slot response; attempt_started.source and render_terminal.source become source-presence-driven; probe run metadata is bound to the issued authorization and added to the CSP and ops schemas; the drop enum adds unsupported_currency and missing_request_context under one shared typed enum; an admin issuance route family joins the internal-route contract; the Image() notification fallback is removed; billing gets an impression-level or aggregate estimator that the cluster bootstrap can actually run; the real-GAM workflow independently verifies the deployed build against its manifest and emits OIDC-backed provenance; a server-side sampled exposure row makes whole-missing traces detectable; the heap budget is renamed to retained-heap; the typecheck gate uses the package script; ts_arm is reserved and A/A-gated; and the alternatives and risk registers are inlined in full. --- ...s-render-fix-and-tsjs-resilience-design.md | 529 ++++++++++++------ 1 file changed, 353 insertions(+), 176 deletions(-) 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 b988e6286..78a31b9b7 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 @@ -1,8 +1,11 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 9 — closes the eighth review round's mandatory set: - attempt schema, diagnostic renewal, telemetry/gate model, CSP routing, and - rollout measurement. Fully self-contained. +- **Status:** revision 10 — closes the ninth review round (assignment-id + trusted path, control-build event parity, affinity lifetime, two-phase + bridge ack, missing-slot termination, materialized cycle denominator, + publisher-bound affinity token, admin route family, billing estimator + source, independent RC attestation) and re-inlines the alternatives and + risk registers so the document is genuinely self-contained. - **Date:** 2026-08-04 - **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed GAM shells"). All file:line citations refer to this commit. @@ -31,23 +34,45 @@ - Assets: embedded only; hashed pathnames for cache identity; unknown hash → `410`, `no-store`. - **Authenticated sticky affinity.** The router sets `ts-rel` on HTML - responses — format `r1...... -`: `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp` (TTL 24 h); - `release` from the allowlist; `cohort ∈ {canary, control}`; - `assignment_id` = 32-hex CSPRNG (the pseudonymous **randomization unit**, - §8); `sig` = unpadded base64url HMAC-SHA-256 over the domain-separated - length-prefixed input `"ts-affinity-v1" || u32be-len fields || -u64be(exp)`; keys owned and rotated by the routing layer (active + - previous, ≥ 24 h retention); constant-time verification; test vectors - checked in. Attributes `Secure; HttpOnly; SameSite=Lax; Path=/`. Cache - keys use the post-validation release label only. + responses — format + `r1.......`: + `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp`; `phost` = normalized + publisher host; `release ^[a-z0-9._-]{1,40}$` from the allowlist; + `cohort ∈ {canary, control}`; `assignment_id` = 32-hex CSPRNG (the + pseudonymous **randomization unit**, §8); `sig` = unpadded base64url + HMAC-SHA-256 over the domain-separated length-prefixed input + `"ts-affinity-v1" || u32be(len)||field …` over **every** field + including `phost` (so a valid token replayed against another publisher + host fails); keys owned/rotated by the routing layer (active + previous, + retained ≥ the affinity lifetime + skew); constant-time verification; + test vectors checked in. Attributes `Secure; HttpOnly; SameSite=Lax; +Path=/`. Cache keys use the post-validation release label only. +- **Affinity lifetime covers the experiment.** `exp` TTL is set to the + **maximum experiment window** in force (default 14 days — the billing + gate's 7 d + 7 d extension), not 24 h, so a canary visitor never crosses + over to control mid-experiment. Renewal on any HTML response before + expiry re-signs the **same `assignment_id` and `cohort`** with a fresh + `exp` (a browser session's arm is fixed for the experiment). Expired + tokens are distinguished from forged ones (expired = valid sig, past + `exp`) and, during an active experiment, expired-but-valid tokens are + renewed to their original arm rather than reassigned. +- **assignment_id reaches telemetry only by a trusted server path.** The + cookie is `HttpOnly`; the client never reads it and never sends + `assignment_id`. The router **strips any inbound assignment/cohort + header**, validates `ts-rel`, and injects trusted internal metadata + (`X-TS-Cohort`, `X-TS-Assignment`, `X-TS-Release`) on the proxied + request; the client-events handler stamps those onto every row + server-side. Ingest rejects any client-supplied assignment/cohort field. + Tests: spoofed inbound header stripped; wrong-arm; null-rate; cookie + expiry mid-session; cross-pool. - **State-dependent routing defaults:** during canary, valid tokens route by - their binding; invalid/expired/forged → control + reissue. **After forward - cutover ("weight 100%"), the safe default flips:** stale, invalid, or - control-bound tokens on HTML requests are reassigned to the active - release; non-HTML requests with unknown or stale tokens route to the - active release — 100% means 100%, not "except 24 h of old cookies." - Rollback flips the default back. + their binding; invalid/forged → control + reissue; expired-but-valid → + renewed to their arm (above). **Forward cutover ("weight 100%") + explicitly retires the old release:** for HTML **and** non-HTML request + families, stale/invalid/old-release-bound tokens are reassigned/routed to + the active release — 100% means 100%. **Rollback symmetrically retires + the new release:** still-valid canary bindings are overridden on every + request family, not merely defaulted. - **CSP-report affinity never depends on cookies:** the renderer is sandboxed without `allow-same-origin` (`aps/render.ts:4`), so its browser-generated reports are cross-origin to the publisher endpoint and @@ -58,13 +83,21 @@ u64be(exp)`; keys owned and rotated by the routing layer (active + - Beacon and trace-auth transports use `credentials: "same-origin"` so the affinity cookie routes them; handlers derive no identity from cookies. - **Canary/control measurement (closing the empty-control-arm gap):** the - control pool for Phase-3 statistics runs an **observation-only control - build** — the baseline plus Phase-2 instrumentation only (trace, beacon, - probes; zero behavior changes) — so both arms emit comparable sampled - telemetry. Arms write to **arm-specific datasources**; the canonical - union view stamps a trusted `deployment_pool` dimension from the write - identity (token → dataset → arm), making the arm a queryable row - dimension rather than an authorization side effect. + control pool runs an **observation-only control build** — baseline render + _decisions_, but with **every measurement-only lifecycle hook the + Phase-3 gates read**. The control build implements a normative + **event-parity contract**: it emits `request_cycle_started`, + `attempt_started`, `bridge_request`, `bridge_response_sent`, + `renderer_document_loaded`/`adm_document_loaded`, and `render_terminal` + at the **same timing points** as the canary build, populated from + baseline behavior (e.g. its own `slotRenderEnded`, its own bridge + responses) — only the render-decision code differs, never the event + definitions or their emission points. Both arms therefore populate the + funnel and latency gates identically. Arms write to **arm-specific + datasources**; the union view stamps a trusted `deployment_pool` from the + write identity. The event-parity contract is itself gated by an **A/A + test** (canary-build vs canary-build) that must show zero metric + movement before any A/B canary begins. - Router weight over sticky cohorts is the sole activation primitive; flags are in-pool emergency kill switches. Cutover = weight 100% + CDN purge; rollback = weight back + re-purge. The affinity acceptance test covers @@ -191,28 +224,40 @@ class as the existing `ts-debug` comment). The **diagnostic credential** `ext.trusted_server.trace = {trace_id, auth, auction_id}`. - **Attempt identity (closing the parent/child collision):** every render attempt mints a client-side **`attempt_id`** (8-char `[a-z0-9]`, - CSPRNG, unique per trace) and carries nullable **`parent_attempt_id`** - (fallback children reference their parent). The tuple - `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key - only**; `ts_render_attempts_v` keys by `attempt_id`. Exactly one - terminal event per `attempt_id` (G4c) is a tested invariant. + CSPRNG) and carries nullable **`parent_attempt_id`** (fallback children + reference their parent). **The canonical attempt key is + `(publisher_domain, trace_id, attempt_id)`** — `attempt_id` alone (≈ 2.8 + T values) is not globally unique at production volume, so + `ts_render_attempts_v` keys and parent lookups use the full triple. + Uniqueness within a trace is guaranteed by a **per-trace live-set + collision check with retry** (the `NavigationSession` holds the issued + set; a collision re-draws). The tuple + `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key only**. + Exactly one terminal event per attempt key (G4c) is a tested invariant. - **Deterministic keyed sampling:** first 8 bytes of `HMAC-SHA-256(sampling_key, trace_id)` as u64 BE; `sampled` iff `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` finite in `[0, 1]`. - **Cross-tier join:** equality key = the server telemetry **`auction_id`** (UUID; the client column is `Nullable(UUID)` and ingest validates canonical UUID syntax). Generations are client-side - only. **Join grains are explicit:** auction-level joins hit the one - summary row per `auction_id`; slot-level joins use - `auction_id + slot + row_kind`; bid-level joins are opt-in for - bid-grained analyses. Raw attempt × auction-row joins are forbidden - (row multiplication). + only. **Join grains are explicit, and the slot grain goes through a + dedicated summary view.** `auction_events_raw` has multiple + `row_kind=slot` rows per slot (bid, provider, drop, selection), so a + `auction_id + slot` join multiplies rows; funnels therefore join against + **`ts_auction_slot_summary_v` — one row per `(auction_id, +canonical_slot)`** built from the `selection_summary` slot rows. + Auction-level joins hit the one totals/summary row per `auction_id`; + bid/drop/provider joins carry their real grain and are opt-in. **Client + `slot` and server `slot_id` use the same canonical normalization** + (lowercased, `-container` suffix stripped — the resolution rule of + `gpt/index.ts:112-138`), applied on both sides and tested. - Cache-privacy invariant: traces/authorizations only in per-request auction-bearing responses; such HTML is `private, no-store`. - Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry `{nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?, -auction_id?, t_rel_ms?}`. **`t_rel_ms`** is a bounded monotonic - duration (`performance.now()` truncated to u32 ms, relative to +auction_id?, t_rel_ms?}`. **`t_rel_ms`** (time, relative, in + milliseconds) is a bounded monotonic duration (`performance.now()` + truncated to u32 ms, relative to navigation start) — the latency gates' basis; `received_at` is ingest time and is never used as event time. `flow` is closed: `ssat | prebid | page_bids | direct | fallback | system`. @@ -264,25 +309,44 @@ class or opposite — supersedes a pending uncertain intent**, and if the uncertain one could still be in flight, the next `slotRequested` is ambiguous → quarantine. Cycles open only on `slotRequested` (causal head; SRA = one per slot per batch) and close on `slotRenderEnded` -(`responseIdentifier` dedups drain). One outstanding TS cycle per slot; -one queued replacement. Attribution requires exactly one outstanding TS -cycle and no overlap; otherwise `cycle_unattributable`, fail closed. **No -timeout re-arm** — re-arm only on count-based drain, safe TS-owned -destroy/redefine, or page end; unissued intents are NavigationSession -children; physical state is RuntimeSession. Deterministic-harness CI + +(`responseIdentifier` dedups drain). **Each attributable `slotRequested` +emits exactly one `request_cycle_started` event** — the materialized +denominator the fill and attribution gates divide by (`attempt_started` +is not equivalent: an attempt can fail before producing a physical +request). One outstanding TS cycle per slot; one queued replacement. +Attribution requires exactly one outstanding TS cycle and no overlap; +otherwise `cycle_unattributable`, fail closed. **No timeout re-arm** — +re-arm only on count-based drain, safe TS-owned destroy/redefine, or page +end; unissued intents are NavigationSession children; physical state is +RuntimeSession. Deterministic-harness CI + the release-gating real-GAM suite (Appendix A.3). -**G4b — Acknowledgement, per render path.** Nonces are per-attempt -128-bit CSPRNG values; the kernel validates source ownership (§6.8), -nonce, token, `nav_gen`, `refresh_gen` before transitions or -notifications; navigation/supersession invalidates; late acks → -`stale_navigation`. Deadlines: document 3 s, runner 10 s, adm 5 s. - -1. **APS-PUC** (baseline transport): MessageChannel into the renderer - document (`ports.length` checks, exact-key replies, one-shot latch, - port close); the document posts authenticated - `renderer_document_loaded` then the accepted/failed result to the top - window. +**G4b — Two-phase acknowledgement (the nonce is minted by the claim, not +carried in the request).** The baseline PUC request is `{message, adId}` + +- a `MessagePort` — it cannot carry a nonce or generations, and the nonce + can only exist **after** a claim succeeds. So the protocol is two phases: + +* **Phase 1 — claim (on the incoming `Prebid Request`):** validate source + ownership (§6.8), the reserved `adId` against the reservation store, and + the internal current `nav_gen`/`refresh_gen` **held in the kernel** + (not in the message); on success, **mint the per-attempt 128-bit CSPRNG + nonce**, bind notifications, reply over the `MessagePort` (the renderer + depends on this reply to settle its PUC promise), and — for the APS + path — hand the nonce to the renderer document in the response. +* **Phase 2 — acknowledgement (later document/runner messages):** validate + source, nonce, token, `nav_gen`, `refresh_gen`. Navigation/supersession + invalidates the nonce; late acks → `stale_navigation`. Deadlines: + document 3 s, runner 10 s, adm 5 s. + +Per render path: + +1. **APS-PUC** (baseline transport): the Phase-1 reply travels over the + `MessagePort`; the response also transfers the freshly minted nonce + into the renderer document (`ports.length` checks, exact-key replies, + one-shot latch, port close); the document then posts authenticated + `renderer_document_loaded` and the accepted/failed result to the top + window (Phase 2). 2. **Generic ADM/cache-PUC:** **the acceptance observation lives in the trusted owner, not the creative document** — the owner observes its own iframe's `load`/`error` events and emits `adm_document_loaded`; @@ -315,11 +379,15 @@ fallback: attributed parent `gam_empty` immediately before child render). `nurl` at bind; `burl` at `accepted`; idempotency key `(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. **Dispatch mechanics (normative):** macros are expanded server-side -only; the client fires +only; the client fires exactly one `fetch(url, {method: "GET", mode: "no-cors", credentials: "omit", -redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})`; -on synchronous failure the fallback is a detached `Image()` request; no -retries either way. Every dispatch emits +redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})` +and awaits its settlement. **There is no `Image()` fallback** — a +detached image request would construct a separate credentialed, +referrer-bearing request outside this privacy contract; a request that +rejects (construction error) or resolves to a network failure emits +`notification_sent{result: failed}` and stops. No retries. Every +dispatch emits `notification_sent{kind, notif_id, result: queued | failed}` with the **server-minted `notif_id`** (12-char token delivered with the bid). Duplicates: hermetic exactly-once proof + production detection alarm + @@ -341,7 +409,11 @@ child identity before any effect. Tests: partial overlap, full overlap, timeout, navigation disposal, reversed responses. The auction client returns a discriminated result — `{ok: bids[]} | {error: "auction_timeout" | "network_error" | "http_error" | "invalid_response"}` -(§3.14); only a parsed empty response is `no_bid`. Public API: +(§3.14); only a parsed empty response is `no_bid`. **After the batch +processes every response bid, each still-live child with no valid +matching winner is terminated `render_terminal{no_bid}`** — a response +carrying only slot X never leaves slot Y's child nonterminal, so +`RequestAdsResult` always settles. Public API: `tsjs.requestAds(options): Promise`, `RequestAdsResult = {traceId, slots: [{slot, outcome, reason?}]}`, settling when every child attempt is terminal. @@ -365,15 +437,21 @@ implying a live channel that does not exist. publication (config is a release-time input); serving is lookup-only; unknown vector = build error; unknown hash = `410 no-store`; exact match = `public, max-age=31536000, immutable`. -- **Internal route families — four** (renderer, client-events, - CSP-report, `/_ts/trace-auth`): dispatch before auth/EC/publisher/ - integration filters (`app.rs:709` orders these wrong today); all - methods + version prefixes reserved locally (405 + `Allow` + - `no-store`; unknown version 404 `no-store`; no publisher fall-through, - `adapter-spin app.rs:804`); no body/cookie/authorization forwarding. - **Per-family origin policy:** client-events + trace-auth strict - normalized same-origin; CSP admits opaque/`null` origins with path - identity + limits; renderer is a public GET validated by version/path. +- **Internal route families — five** (renderer, client-events, + CSP-report, `/_ts/trace-auth`, and the **admin issuance family** + `/_ts/admin/*` — diagnostic-credential and probe-authorization, §5.3): + all dispatch before auth/EC/publisher/integration filters (`app.rs:709` + orders these wrong today); all methods + version prefixes reserved + locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`; no + publisher fall-through, `adapter-spin app.rs:804`); no + body/cookie/authorization forwarding to the publisher. **Per-family + origin/auth policy:** client-events + trace-auth strict normalized + same-origin; CSP admits opaque/`null` origins with path identity + + limits; renderer is a public GET validated by version/path; **the admin + family sits behind operator authentication with its own rate limits and + exists on all adapters that expose an admin plane** (elsewhere it is + absent, and diagnostic/probe modes are unavailable there — stated, not + implied). All five families appear in the four-adapter parity matrix. - Ingest routes in all four adapters; Fastly has real sinks; others accept-count-drop (DR-5). §5.6 schemas deploy before writers. @@ -389,9 +467,10 @@ implying a live channel that does not exist. | `t` | fields | allowed `flow` | | -------------------------- | --------------------------------------------- | ----------------------- | +| `request_cycle_started` | slot | ssat, prebid, page_bids | | `bid_received` | slot, id_kind, source | render flows | | `targeting_set` | slot, id_kind | render flows | -| `attempt_started` | slot, source | render flows | +| `attempt_started` | slot, source? | render flows | | `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids | | `bridge_response_sent` | slot, source | ssat, prebid, page_bids | | `render_terminal` | slot, outcome, reason?, source? | render flows | @@ -408,13 +487,18 @@ implying a live channel that does not exist. | `heartbeat` | probe_run_id, expected_seq, adapter, target | system | Render flows = `ssat | prebid | page_bids | direct | fallback`. -`attempt_started` carries the attempt's `t_rel_ms` baseline; the latency -metric is `render_terminal{accepted}.t_rel_ms − -attempt_started.t_rel_ms` per attempt. `source` on `render_terminal` is -nullable for pre-source reasons (`gpt_absent`, `pbjs_absent`, -`slot_unresolved`, `intent_no_request`, `abi_mismatch`, `registry_full`, -`bundle_partial`); the per-event/per-reason validity matrix is a -generated artifact (§6.7). Reason enum: `renderer_document_no_load`, +`attempt_started` carries the attempt's `t_rel_ms` baseline and its +`source` is **optional** (direct attempts start before a response selects +a source); the latency metric is `render_terminal{accepted}.t_rel_ms − +attempt_started.t_rel_ms` per attempt. **`render_terminal.source` is +present iff a render source was actually bound** — absent for +`no_bid`/`cancelled` and every pre-winner outcome (`auction_timeout`, +`network_error`, `http_error`, `invalid_response`) as well as the +pre-source reasons (`gpt_absent`, `pbjs_absent`, `slot_unresolved`, +`intent_no_request`, `abi_mismatch`, `registry_full`, `bundle_partial`); +the generated per-event/per-reason validity matrix (§6.7) encodes +source-presence by whether a source was bound, not by a hand-list. +Reason enum: `renderer_document_no_load`, `runner_no_load`, `runner_failed`, `descriptor_invalid`, `invalid_dimensions`, `dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`, `intent_no_request`, `stale_navigation`, @@ -525,13 +609,18 @@ same-origin` else normalized `Origin` equality; absent both → `attempt_id`**); the arm-union views stamp `deployment_pool` from the write identity (§0). Dashboards/alerts query canonical views only. - The Fastly sink is fire-and-forget (`tinybird.rs:153`) — - **per-datasource authenticated probes**: every probe row carries - `{probe_run_id, expected_seq, adapter, target}`; client-events via - `heartbeat` events under probe-mode tokens; CSP via probe reports to a - **secret-derived probe `policy_id`** (registered like any policy id, - not guessable — `policy_id = "probe"` would be publicly forgeable); - ops via an authenticated probe counter write. **Persistence gates are - scoped to sink-backed adapters** (DR-5); accept-count-drop adapters + **per-datasource authenticated probes**. **Every probe-capable table + carries `{probe_run_id, expected_seq, adapter, target}`** (added to + `ts_csp_reports` and `ts_ops_counters` below, not only + `ts_client_events`), so loss queries distinguish runs, retries, resets, + and adapters. **These four fields are cryptographically bound to the + issued probe authorization** — the admin-issued probe token + (`POST /_ts/admin/probe-authorization`, §5.3) signs `probe_run_id`, + `adapter`, and `target`, and the ingest handler stamps the row from the + verified token rather than trusting submitted values. A secret-derived + CSP probe `policy_id` is a bearer capability for _routing_ only; it is + never treated as proof of authentic run metadata. **Persistence gates + are scoped to sink-backed adapters** (DR-5); accept-count-drop adapters get HTTP-parity gates only. Freshness = probe lag ≤ 5 min; loss = `expected_seq` gaps < 0.1%; alert owner: release owner's on-call. - **Alert-delivery drill:** a synthetic canary page injects a known @@ -582,27 +671,37 @@ duplicate_bid_id, bid_id_too_large, empty_seatbid, empty_seatbid_bids, unknown_impid, invalid_price, unsupported_media_type, creative_id_too_large, renderer_extension_serialization_failed, no_render_source, -lost_to_higher_bid, overflow` — covering `aps.rs:740-929` - (incl. `empty_seatbid_bids` at `:875`) and `formats.rs:408-419`; a - **compile-time exhaustiveness test maps every producer to the enum**. +lost_to_higher_bid, unsupported_currency, missing_request_context, +overflow` — covering every baseline producer at `aps.rs:740-929` + (incl. `empty_seatbid_bids` at `:875`, `unsupported_currency`, and + the response-level `missing_request_context`, whose totals/error-row + disposition is `row_kind = totals` with a null slot) and + `formats.rs:408-419`. `currency_mismatch` and `unsupported_currency` + are **distinct** (config-vs-response mismatch vs an unsupported + currency code). Producers emit a **shared typed enum, no string + literals**, so the **compile-time exhaustiveness test is real**: every + variant maps to a producer and every producer to a variant. - **`ts_csp_reports`**: `received_at DateTime64, publisher_domain LowCardinality(String), release_id String, policy_id LowCardinality(String), cohort LowCardinality(String), directive_bucket Enum(script|style|frame|img|connect|font|media|worker|other), source_bucket Enum(https_host_allowlisted|data|blob|inline|eval|other), -count UInt32`; sorting key `(publisher_domain, received_at, policy_id)`; - TTL 30 days. Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256, - nesting ≤ 4, both media types with separate validators, unused fields - discarded, own limiter bucket. **Caps with values:** 10,000 - reports/hour/publisher and 1,000/hour/cohort; overflow increments the - `csp_overflow` ops counter (dropped reports counted, never parsed - further). +count UInt32, probe_run_id Nullable(String), expected_seq +Nullable(UInt32), adapter Nullable(Enum), target Nullable(Enum)`; + sorting key `(publisher_domain, received_at, policy_id)`; TTL 30 days. + Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256, nesting ≤ 4, + both media types with separate validators, unused fields discarded, own + limiter bucket. **Caps with values:** 10,000 reports/hour/publisher and + 1,000/hour/cohort; overflow increments the `csp_overflow` ops counter + (dropped reports counted, never parsed further). - **`ts_ops_counters`**: `received_at DateTime64, publisher_domain LowCardinality(String), release_id String, counter Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked| ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged| -csp_overflow|probe), value UInt64`; sorting key `(publisher_domain, -received_at, counter)`; TTL 90 days. +csp_overflow|probe), value UInt64, probe_run_id Nullable(String), +expected_seq Nullable(UInt32), adapter Nullable(Enum), target +Nullable(Enum)`; sorting key `(publisher_domain, received_at, counter)`; + TTL 90 days. - **Sink plumbing:** one generic multi-target Tinybird sink trait; `RuntimeServices` (`platform/types.rs:158`) gains handles for client-events, CSP, and ops targets beside the auction sink; each @@ -640,14 +739,17 @@ explicit `winner_selection`. ### 6.1 Mediation -Eight rules, arrival-independent, one shared helper across both +Seven rules, arrival-independent, one shared helper across both lifecycles: (1) required `[auction].currency` (APS + non-USD = startup error; Prebid validates at parse, `prebid.rs:2433`); (2) candidate identity `source_candidate_id = (provider_name, upstream_bid_id)` with the upstream id **required, ≤ 64 chars, unique** — missing/duplicate/ oversized → `bid_drop{missing_bid_id | duplicate_bid_id | -bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` (CSPRNG -wire echo, never an ordering key); (3) mediator echoes +bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` is a +server-minted 12-char `^[a-z0-9]{12}$` CSPRNG wire echo with in-auction +collision retry, **never an ordering key**, and the mediator's echoed +value is validated against the issued set on return (an echo matching no +issued id is `provenance_invalid`); (3) mediator echoes `ext.trusted_server.candidate_id` — resolves → forwarded candidate, provenance `mediator`, **price authoritative from the mediator, every render-source and notification field from the stored candidate, deal @@ -837,28 +939,34 @@ versions and is invalid if any differ). Browser timing: `performance.mark("tsjs:bids-script")` → `performance.mark("tsjs:first-display")`; reference fixture; warm cache; local resources; 5 warm-ups, 50 samples, nearest-rank p90 ≤ baseline × -1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail. **Peak -JS heap, reproducibly:** via the Playwright CDP session — +1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail. +**Retained-heap budget (named accurately — this measures retained, not +transient allocation peak):** via the Playwright CDP session — `HeapProfiler.collectGarbage` then `Runtime.getHeapUsage`, sampled at five fixed points (post-boot, post-adInit, post-first-render, post-refresh, post-SPA-navigation) on the maximal vector; metric = max -sample; gate ≤ baseline × 1.10; same rerun rule. Server benchmark: the -G5 lookup path; 100 warm-ups, 1,000 iterations; median + p90 one-sided -≤ baseline × 1.10; 3-run 5% agreement or inconclusive. +retained sample; gate ≤ baseline × 1.10; same rerun rule. (Transient +allocation peak is out of scope; if a future leak needs it, add +`HeapProfiler` continuous sampling as a separate budget.) Server +benchmark: the G5 lookup path; 100 warm-ups, 1,000 iterations; median + +p90 one-sided ≤ baseline × 1.10; 3-run 5% agreement or inconclusive. ### 7.11 Toolchain TypeScript floor to the resolved 5.9 line; release-gating flags `strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `noImplicitOverride`, -`useUnknownInCatchVariables`; checked-in npm script `typecheck`: +`useUnknownInCatchVariables`. The gate is a **checked-in package.json +script**, `"typecheck": "tsc -p tsconfig.json --noEmit"`, invoked so the +lockfile-resolved compiler is used: ``` -cd crates/trusted-server-js/lib && npx --no-install tsc -p tsconfig.json --noEmit +npm --prefix crates/trusted-server-js/lib run typecheck ``` -Dev toolchain bumps as individual CI-gated PRs; `prebid.js` excluded -from casual bumps; monthly review. +(the script runs `tsc` from the package's own `node_modules/.bin`, so no +`npx` path ambiguity). Dev toolchain bumps as individual CI-gated PRs; +`prebid.js` excluded from casual bumps; monthly review. ## 8. Rollout @@ -871,12 +979,31 @@ resample assignment ids, 2,000 resamples, fixed seed recorded in the gate artifact, strata (publisher × slot) weighted by control-arm traffic share; one-sided 95% confidence bounds on **relative differences**; each gate is an independent go/no-go (no cross-gate -multiplicity correction — stated). Missing telemetry counts as failure. -Per-flow floors; rare flows (direct, fallback) gate hermetically + -real-GAM, never statistically. `cycle_unattributable` divides by all -attribution-candidate TS cycles. **Billing cohort dimension in external -reports:** TS-driven GAM requests set a `ts_arm=` key-value, -making the arm a reportable GAM dimension for reconciliation. +multiplicity correction — stated). **"Missing telemetry counts as +failure" is made enforceable for whole-missing traces:** the server +writes a **sampled exposure row** (`ts_expected_attempts`, one per +server-observed eligible APS win in a sampled trace) at auction time; +gates **left-join client attempts to expected rows**, so a trace whose +client events never arrive is a visible missing attempt (not an absent +row that silently shrinks the denominator). A per-window +client-transport completeness ratio below 90% makes the statistical gate +**inconclusive** rather than passing on a biased sample. Per-flow floors; +rare flows (direct, fallback) gate hermetically + real-GAM, never +statistically. `cycle_unattributable` divides by all +attribution-candidate TS cycles. + +**Billing is the one gate the cluster bootstrap cannot run**, because GAM +aggregate reports expose only per-`ts_arm` totals, not session clusters. +Two options, one chosen per DR: (a) source billing from **GAM Data +Transfer / impression-level logs**, which carry the `ts_arm` key-value +**and** a joinable impression/`attempt_id` correlator, and run the same +cluster bootstrap over impression rows; or (b) if Data Transfer is +unavailable, use a **separate pre-reviewed aggregate estimator** with the +**day** as the randomization unit (a two-sample non-inferiority test over +daily per-arm RPM across the billing window), declared in the gate +artifact. Whichever is chosen, `ts_arm` is a **reserved, network-audited +key proven untargeted by any production line item and A/A-validated +before canary** (§0, §11), so it cannot perturb the metric it measures. **Phase 0 decision records:** DR-1 mediator presence; DR-2 script creatives (deployment decision); DR-3 #997 vs re-merge; DR-4 @@ -941,32 +1068,81 @@ Hermetic CI blocks PRs; the real-GAM suite is release-gating (A.3). | Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness; kill-switch snapshot semantics | | Perf | marks present; three vectors; heap CDP; inconclusive-rerun; pinned-environment baseline validity | -## 10. Alternatives considered - -Revision 8's twelve rejections stand (patch-without-telemetry; -always-direct-render; shared chunks now; big-bang rewrite; dropping the -bootstrap; timeout fallback; timeout re-arm; N/N−1 machinery; -client-computed hashes; fingerprint identities; plain cohort cookie; -`billing_outcome`), plus: **13.** injected in-realm ADM reporter — -rejected (hands bidder code the acceptance credential); owner-observed -`load`/`error` instead. **14.** cookie-routed CSP affinity — rejected -(opaque-origin reports carry no cookie); path-identity routing. **15.** -token-identity alone for arm attribution — rejected (authorization is -not a row dimension); arm-specific datasources + stamped union view. -**16.** indefinite diagnostic renewal — rejected; `dexp` ceiling. -**17.** shared attempt identity for parent/child — rejected; distinct -`attempt_id`. - -## 11. Risks - -Revision 8's register stands (cutover blast radius; sticky-cohort -infrastructure; mediator wire-contract change; published notification -triggers; required-config startup errors; beacon abuse; memory bounds; -advisory CSP; sink blindness; ABI freeze; schema generation), plus: the -**observation-only control build** is new scoped work whose "zero -behavior change" property is itself gated (hermetic parity vs baseline); -and `assignment_id` persistence is pseudonymous but new — bounded by the -24 h token TTL and excluded from any identity join by schema review. +## 10. Alternatives considered (complete) + +1. **Patch APS point-failures without telemetry** — rejected: four correct + fixes produced no reliable ads; the next would be another guess. +2. **Always direct-render APS** (skip GAM/PUC) — rejected: unilaterally + changes GAM reporting/pacing; kept only as the attributed-`gam_empty` + fallback. +3. **Single module graph / shared chunks now** — rejected for this release: + changes the delivery pipeline while everything else changes; successor + option behind the same registry surface. +4. **Full rewrite in one branch without phases** — rejected: the + browser-spec safety net is thinnest exactly where behavior changes. +5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle + guarantee; the generated fallback keeps it. +6. **Timeout-triggered fallback rendering** — rejected: uncancelable GPT + requests race late fills → double-render/double-bill. +7. **Timeout-based quarantine re-arm** — rejected: recreates the + stale-event bug. +8. **N/N−1 compatibility machinery** — removed by the §0 policy decision. +9. **Client-computed notification hashes** — rejected: no key without + breaking the pseudonymization boundary; server-minted `notif_id`. +10. **Fingerprint identities for id-less bids** — rejected: can merge + distinct demand; rejection with closed reasons instead. +11. **Plain readable cohort cookie** — rejected: dark-pool opt-in + + cache-cardinality abuse; opaque authenticated token. +12. **`billing_outcome` event** — removed: no honest post-accept producer. +13. **Injected in-realm ADM reporter** — rejected: hands bidder code the + acceptance credential; owner-observed `load`/`error` instead. +14. **Cookie-routed CSP affinity** — rejected: opaque-origin reports carry + no cookie; server-selected `policy_id` path identity. +15. **Token-identity alone for arm attribution** — rejected: authorization + is not a queryable row dimension; the router injects a trusted internal + cohort header ingest stamps (§0). +16. **Indefinite diagnostic renewal** — rejected: signed `dexp` ceiling. +17. **Shared attempt identity for parent/child** — rejected: distinct + `attempt_id`. +18. **24 h affinity for a multi-day experiment** — rejected: the affinity + lifetime now covers the maximum experiment window (§0). +19. **`Image()` notification fallback** — rejected: it constructs a + separate credentialed/referrer-bearing request outside the primary + transport's privacy contract; a failed dispatch reports `failed` + instead (§G4d). + +## 11. Risks (complete register) + +- **Hard-cutover blast radius** — accepted by policy; bounded by the §0 + runbook (probes, low-weight canary, monitored window, weight-back). +- **Sticky-cohort routing is new infrastructure** the cutover depends on — + Phase 0 work; its coherence and affinity-lifetime tests are + release-gating. +- **Mediator wire-contract change** (`candidate_id` echo) — DR-4 gates + `merge_highest_cpm`; config validation enforces the block. +- **Published notification triggers** become a contract for PBS-path + demand — changing them later breaks SSP reporting. +- **Required `[auction].currency`/`winner_selection`** in mediated + deployments are a deliberate startup-error class under §0. +- **Beacon abuse** — pre-parse caps, per-family origin policies, + fail-closed numeric limits, signed modes, credentialed diagnostics. +- **Registry/limiter memory** — explicit capacities, TTL reclamation, + reject-at-capacity; Fastly overshoot documented, not claimed. +- **CSP data is advisory** — never a sole automatic rollback signal. +- **Sink blindness** — per-datasource authenticated probes. +- **ABI freeze** — `tsjs._internal.registry` is load-bearing; exact-release + verdicts are the contract. +- **Schema generation** — checked-in artifacts + staleness CI. +- **Observation-only control build** is new scoped work whose "zero + behavior change" property is itself gated by hermetic parity vs baseline + **and an A/A pre-canary** (the `ts_arm` GAM key must move no metric). +- **`assignment_id` persistence** is pseudonymous but new — bounded by the + affinity token lifetime and excluded from any identity join by schema + review; it reaches telemetry only via the router-injected trusted header + (§0), never a client field. +- **`ts_arm` GAM targeting key** could itself perturb line-item matching — + reserved and audited network-wide, proven untargeted by production line + items, and A/A-tested before canary (§8). ## 12. Success criteria @@ -1024,29 +1200,29 @@ decision records. Low volume: inconclusive → extend once → Hold. ### A.1 Phase gates -| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action | -| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | --------------- | -------------------------------------------------- | ------------------------- | ----- | -------- | -| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold | -| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold | -| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold | -| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold | -| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold | -| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold | -| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | -| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | -| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold | -| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback | -| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold | -| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback | -| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback | -| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback | -| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback | -| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback | -| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | -| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold | -| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold | -| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback | -| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; `render_terminal{failed}` rate vs pre-cutover canary | — | lag ≤ 5 min; loss < 0.1%; failed ≤ canary + 0.5 pt | 24 h | OPS | Rollback | +| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action | +| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------- | ----- | -------- | +| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold | +| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold | +| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold | +| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold | +| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold | +| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold | +| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | +| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | +| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold | +| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback | +| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold | +| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback | +| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback | +| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback | +| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback | +| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback | +| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | +| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold | +| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold | +| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback | +| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; per-flow `render_terminal{failed}` rate vs pre-cutover canary | 10,000 attempts/flow | lag ≤ 5 min; loss < 0.1%; per-flow failed ≤ canary + 0.5 pt (flow-weighted; a below-floor flow holds, not passes) | 24 h | OPS | Rollback | ### A.2 Expected stages per flow @@ -1060,16 +1236,17 @@ decision records. Low volume: inconclusive → extend once → Hold. ### A.3 Real-GAM suite (attested) -| Field | Value | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | -| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** | -| **Output** | **attested report embedding the manifest** — a green run attests the exact build it exercised, parameterized by (release, pool, deployment epoch) | -| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession | -| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) | -| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | -| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | -| Command | `npx playwright test --config real-gam.config.ts` | -| Artifact | Playwright HTML report + trace zips, retained 90 days | -| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | -| Approval evidence | green attested run URL in the release checklist, signed off by RO | +| Field | Value | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | +| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** | +| **Independent verification** | **the workflow does not trust its input**: it fetches the deployed pool's runtime `release_id`/`config_hash` from a trusted control-plane endpoint, hashes the actually-served bundle bytes, verifies the deploy-provider binary provenance and the pool/epoch, and **compares all of them to the manifest — any mismatch fails the run** (so a green run cannot claim manifest X while exercising deployment Y) | +| **Output** | **OIDC-backed signed provenance** embedding the verified (release, pool, deployment epoch), not a caller-supplied echo | +| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession | +| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) | +| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | +| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | +| Command | `npx playwright test --config real-gam.config.ts` | +| Artifact | Playwright HTML report + trace zips, retained 90 days | +| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | +| Approval evidence | green attested run URL in the release checklist, signed off by RO | From c0285789ecc2fb9b22242d046702b4596f69e22c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:48:20 -0700 Subject: [PATCH 552/844] docs: finalize APS and TSJS resilience design --- ...8-04-aps-tsjs-resilience-implementation.md | 2737 +++++++++++ ...s-render-fix-and-tsjs-resilience-design.md | 4359 ++++++++++++----- 2 files changed, 5859 insertions(+), 1237 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md 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 new file mode 100644 index 000000000..05c88d6cf --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md @@ -0,0 +1,2737 @@ +# APS Render Fix and TSJS Resilience 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. Use +> `superpowers:test-driven-development` for each behavior change and +> `superpowers:verification-before-completion` before claiming a phase or the plan +> complete. + +**Goal:** make APS render deterministically across SSAT, Trusted Server Prebid, +page-bids, direct auction, and fallback paths while replacing TSJS's duplicated +global state with one bounded, lifecycle-owned runtime. + +**Architecture:** one composition root constructs a core runtime from injected +adapters and services. Integration IIFEs register as release-matched integration +modules, prepare inertly, and activate together behind one synchronous commit +barrier. Rust emits one bounded tagged render-source union and one exact per-slot +auction decision set. Universal Creative 1.17.2 supplies the outer response and +owner-registration channels; the kernel owns control and APS document channels. +Direct and PUC paths settle through the same terminal state machine. + +**Tech Stack:** Rust (`error-stack`, `http`, `serde`), lockfile TypeScript, Vitest, +Playwright, GPT/Prebid test adapters, Viceroy, and the existing four runtime +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 +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. + +## Scope guardrails + +- Do not change analytics, persistence, billing, experimentation, or deployment + routing. Those are separate specifications. +- Do not add compatibility aliases, a runtime selector, or dual old/new protocols. + New construction is reachable only from test code until the coordinated switch. +- Keep unrelated publisher integrations behaviorally unchanged. Their only planned + changes are thin integration-module registration and shared-runtime access. +- Follow `CLAUDE.md`: use adapter-specific Cargo aliases, `error-stack`, `log`, and + descriptive `expect("should ...")`; never use bare `cargo test --workspace`. +- Every task follows red → green → refactor: add the narrow failing test, run it to + prove the failure, implement the minimum complete contract, rerun focused tests, + then run the task's regression commands. +- Do not create additional design or plan files. Implementation fixtures, schemas, + tests, and workflow edits listed below are implementation artifacts, not extra + plans. +- Never check APS runner bytes, a runner version/digest/license/metadata record, SRI, + an updater, or an offline runner fallback into source, tests, evidence, or release + artifacts. The only positive runner route is the live fixed-target proxy at + `/integrations/aps/runner.js`; `/integrations/aps/runner/v1.js` is negative-only. + This plan defines no runner cache behavior. + +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. + +## Planned source shape + +The exact split may be adjusted during implementation only when it preserves these +owners and dependency directions. + +```text +crates/trusted-server-js/lib/src/ + kernel/ + 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 + runtime.ts bootstrap ownership and shared Runtime object + sessions.ts RuntimeSession and NavigationSession + adapters/ + googletag.ts only GPT-global access + prebid.ts only Prebid-global access + messaging.ts exact/versioned global and MessagePort envelopes + services/ + context.ts runtime-owned auction-context contributors + slots.ts SlotRecord indexes, request intents, physical cycles + projections.ts immutable navigation projection admission/commit + targeting.ts owner-aware GPT targeting journal and restoration + reservations.ts live renderer capabilities, WinnerContext, tombstones + render.ts RenderAttempt state machine and path drivers + auction_batch.ts shared fetch and child cancellation + integrations/ + aps/render.ts descriptor validation and static-renderer client + gpt/index.ts thin GPT composition + prebid/index.ts thin Prebid composition + core/ + index.ts final public API installation + request.ts input validation and AuctionBatch entry point + types.ts public and wire types + composition/ + browser.ts sole construction root for concrete adapters and services +``` + +Test files mirror source ownership under `crates/trusted-server-js/lib/test/`. +Do not make `kernel/` depend on `adapters/`, `services/`, or `integrations/`. +Only `composition/` may import every layer and construct concrete dependencies. + +## Dependency order + +```text +descriptor contract + -> server admission/mediation/projection + -> renderer endpoint + +kernel primitives + -> sessions + adapters + -> slot/reservation services + -> lifecycle + auction batch + -> GPT/Prebid/direct/fallback migration + +server path + browser path + -> hermetic browser matrix + -> real-GAM conformance + -> legacy deletion and hard cutover +``` + +## Coordinated-switch rule + +Tasks 1–18 build contracts, pure serializers/parsers, services, integration modules, +and test-only composition without switching a shipped page onto an incompatible +half-state. In particular: + +- Task 3 creates canonical projection/response serializers and parsers exercised + directly in tests; production `/auction`, initial HTML, and page-bids stay on their + current shapes until Task 19; +- Task 5 builds the versioned handler and exercises adapter parity through test-only + registries while production dispatch remains unchanged until Task 19; +- Task 8 computes and exposes release metadata but does not emit a required-integration + manifest or claim production bootstrap ownership; +- Tasks 16–18 produce tested integration-module preparation/activation and composition without changing the + shipped entry-point side effects. + +Task 19 is the single coordinated production switch: initial HTML/page-bids, core, +GPT, Prebid, APS, fallback, and every enabled integration begin using the new +projection, manifest, versioned renderer, and integration-module runtime together. No runtime +flag, deployment router, or operator-visible old/new selector is introduced. Task +22 removes the now-unreachable old surfaces before any release candidate is built. +Every task's regression suite therefore remains green in task order. + +## Phase 0 — pin the contract and baseline + +### Task 0: Establish the focused baseline and scope checks + +**Files:** + +- Modify: `crates/trusted-server-js/lib/package.json` +- Modify: `crates/trusted-server-js/lib/tsconfig.json` +- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Create: `crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs` +- Create: `crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json` +- Create: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts` +- Modify: `scripts/integration-tests-browser.sh` +- Create: `scripts/dispatch-workflow-run.mjs` +- Create: `crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs` +- Create: `crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs` +- Modify: `.github/workflows/test.yml` +- Modify: `.github/workflows/integration-tests.yml` +- Test: existing Rust, Vitest, and APS Playwright suites + +- [ ] **Step 1: Write and run the failing executable adoption-manifest test.** Extract + the `rcjuly-tsjs-manifest-v1` JSON block from the revision-27 spec, enumerate + every `includeRoot` and exact file at + `905984e62a0858c53d9f0ff6dd3a1bf190cf311d` with `git ls-tree`, and fail for an + unmapped pinned file, a `lib/src` file mapped only to `RCJ-QUAL-01`, a dead + mapping, or a manifest/ledger id mismatch. Pin the expected audit result at 144 + files, 38 mapping rows, and 23 ledger/manifest ids so a moving baseline cannot + enter implementation silently: + + ```bash + node --test crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs + ``` + + Expected before the script exists: FAIL. Expected after the minimal extractor and + checker: PASS with zero unmapped/dead/gap arrays. Add the same command to CI and run + it before every phase exit. + +- [ ] **Step 2: Record baseline results before behavior changes:** + + ```bash + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + npm --prefix crates/trusted-server-js/lib ci + npm --prefix crates/trusted-server-js/lib test + npm --prefix crates/trusted-server-js/lib run build + ``` + + Any pre-existing failure is recorded in the execution notes; it is not silently + attributed to this work. + +- [ ] **Step 3: Keep the lockfile-resolved compiler; dependency upgrading is not part of this** + work. Add the checked-in `typecheck` script: + + ```json + "typecheck": "tsc -p tsconfig.json --noEmit" + ``` + + Enable `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, + `verbatimModuleSyntax`, `noImplicitOverride`, and + `useUnknownInCatchVariables`. Add the CI invocation with the lockfile-resolved + compiler. Add both `typecheck` and the existing architectural `lint` command to + CI. + +- [ ] **Step 4: Before behavior changes, make bundle measurement deterministic and record the** + minimal/reference/maximal gzip and Brotli bytes, Node/npm/TypeScript versions, + Chromium version, CI machine class, fixture, five warmups, 50 samples, p90 + boot-to-first-display, and forced-GC CDP heap checkpoints. Commit these values to + `aps-tsjs-prechange.json`; later tasks may compare against it but must not + regenerate it from the completed implementation. + + 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: + it builds release Fastly WASM with the integration environment, generates + Viceroy configuration, builds/loads the framework image, installs browser + dependencies, and builds both TSJS fixture variants. Run the new performance + test itself—not only the bundle script—and write its 50-sample/heap output to the + exact baseline path: + + ```bash + TS_BROWSER_FRAMEWORKS=nextjs \ + TSJS_PERF_MODE=baseline \ + TSJS_PERF_OUTPUT=crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json \ + ./scripts/integration-tests-browser.sh \ + tests/shared/tsjs-performance.spec.ts --project=chromium + node crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs --baseline-only + ``` + + The integration workflow runs the same focused command on its pinned CI image and + uploads the resulting JSON. Add required manual input `evidence_id` and include it + in `run-name`. `dispatch-workflow-run.mjs` validates that the ref is a pushed + branch/tag, dispatches with a unique evidence id, polls for exactly that run, and + prints its numeric run id. Record the successful id in the baseline JSON: + + ```bash + TASK0_REF="$(git branch --show-current)" + TASK0_SHA="$(git rev-parse HEAD)" + test -n "$TASK0_REF" + git fetch origin "$TASK0_REF" + test "$TASK0_SHA" = "$(git rev-parse "origin/$TASK0_REF")" + TASK0_EVIDENCE_ID="aps-tsjs-baseline-$TASK0_SHA" + TASK0_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \ + integration-tests.yml "$TASK0_REF" \ + evidence_id="$TASK0_EVIDENCE_ID")" + gh run watch "$TASK0_RUN_ID" --exit-status + test "$TASK0_SHA" = "$(gh run view "$TASK0_RUN_ID" --json headSha --jq .headSha)" + ``` + +- [ ] **Step 5: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib run typecheck + npm --prefix crates/trusted-server-js/lib run lint + node --test crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs + test -s crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json + ``` + +### Task 1: Make the APS descriptor a cross-language executable contract + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/types.rs` +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json` +- Create: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json` +- Create: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json` +- Create: `scripts/generate-aps-renderer-contract.mjs` +- Create: `crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js` +- Create: `crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts` +- Create: `crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs` +- Modify: `crates/trusted-server-js/lib/package.json` +- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` + +- [ ] **Step 1: Add failing corpus tests in Rust and Vitest for:** + - required versus optional exact keys; + - UTF-8 byte limits, not JavaScript character counts; + - exact numeric/integral dimensions at 0/1/4096/4097 and distinct + `invalid_dimensions` versus `dimensions_out_of_range` results; + - HTTPS URL, credentials, publisher-origin rejection, and URL byte limit; + - canonical standard base64 and decoded 256 KiB limit; + - UTF-8 decode failure and malformed JSON; + - exactly one seat/bid and exact nested keys; + - duplicated-field disagreement; + - nonfinite, negative, and wrong-type price; + - unknown descriptor version/type/tag type. + +- [ ] **Step 2: Run the focused tests and confirm at least one new adversarial vector fails in** + each implementation: + + ```bash + cargo test-fastly aps_renderer + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/aps/render.test.ts + ``` + +- [ ] **Step 3: Add `generate:aps-contract` and `check:aps-contract` scripts. The generator reads** + the neutral schema/corpus and writes both named generated validator files. The + check runs the generator in staleness mode and fails on any diff. The Node test + executes the exact ES5 file embedded by `aps.rs` in a `vm` for every corpus + vector; it is not allowed to substitute the TypeScript validator. + +- [ ] **Step 4: Implement `BidRenderSourceV1`/the equivalent Rust enum with exactly `aps`, `adm`,** + and `cache` members. Ensure APS has only `ApsRendererV1`; delete alternate APS + `adm`, `meta`, or debug reconstruction. Align upstream and descriptor bid ids to + 1–64 UTF-8 bytes with no NUL/control. Define shared + `RENDER_DIMENSION_MIN = 1` and `RENDER_DIMENSION_MAX = 4096`; apply the same + noninteger/nonpositive versus out-of-range distinction in Rust, TS, generated + ES5, ADM/cache sources, programmatic sizes, and later DOM construction. No + validator or adapter may clamp. + +- [ ] **Step 5: Rerun the focused commands plus:** + + ```bash + npm --prefix crates/trusted-server-js/lib run typecheck + npm --prefix crates/trusted-server-js/lib run lint + 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 + ``` + +### Phase 0 exit + +- The baseline is known. +- Strict TypeScript is a checked-in CI command. +- Rust, TypeScript, and embedded ES5 agree on every APS descriptor vector. +- No external observability or experiment artifact is planned or created. + +## Phase 1 — make the server APS path deterministic + +### Task 2: Harden APS admission and typed drop reasons + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-core/src/auction/provider.rs` +- Modify: `crates/trusted-server-core/src/auction/types.rs` +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs` + +- [ ] **Step 1: Add failing tests for missing, duplicate, and >64-byte upstream bid ids;** + malformed contextual data isolated to a bid when safe; missing/invalid + `creativeurl`; invalid `tagtype`; non-HTTPS/self-origin URL; exact 1–4096 + dimension membership and reason mapping; default-off and enabled script + creatives; invalid/nonfinite price; + and one invalid sibling beside one valid bid. + +- [ ] **Step 2: Replace stringly ad-hoc reasons with one typed APS/auction drop-reason enum used** + exhaustively by provider response parsing and publisher debug projection. Do not + add a persistence or external-event failure reason. + +- [ ] **Step 3: Validate per bid before constructing `ApsRendererV1`. Preserve the exact accepted** + AAX bid in the encoded projection and cross-check it against the descriptor. + +- [ ] **Step 4: Treat a provider currency that violates the existing auction/provider contract as** + `invalid_provider_response`. Do not introduce a currency-specific public failure, + add or change auction configuration, add currency requirements, or alter non-APS + behavior. A slot with zero eligible providers is exactly + `failed{reason:'slot_not_eligible'}`. + +- [ ] **Step 5: Run:** + + ```bash + cargo test-fastly integrations::aps + cargo test-fastly auction + cargo test-axum + ``` + +### Task 3: Make per-slot outcomes explicit and mediation provenance-safe + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs` +- Modify: `crates/trusted-server-core/src/auction/types.rs` +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/auction/provider.rs` +- Modify: `crates/trusted-server-core/src/auction/endpoints.rs` +- Modify: `crates/trusted-server-core/src/integrations/adserver_mock.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/core/auction.ts` +- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts` + +- [ ] **Step 1: Add failing server and TS parser tests proving every requested slot receives** + exactly one ordered `winner | no_bid | failed` decision. Cover provider launch, + transport, timeout, HTTP, parse, per-bid validation, mediation failure, selected + winner projection failure, one provider failure beside another provider winner, + all-provider no-bid, partial slots, duplicate/extra/missing decisions, and the + exact closed failure priority from the spec, including the direct + `identity_generation_failed` wire result. + + Add exact `BrowserAuctionProjectionV1` boundary cases: 0/256/257 results and bids; + auction id `^[A-Za-z0-9._:-]{1,128}$`, exact 12-character base64url candidate, + provider `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, upstream id 1–64 UTF-8 bytes, + slot 1–256 UTF-8 bytes with no NUL/control, exact unique `r1_` reservation, finite + nonnegative CPM, literal `USD`; targeting 31/32/33 entries, exact + `[A-Za-z0-9_]{1,20}` keys at 19/20/21 characters, + 39/40/41-scalar and 159/160/161-byte values, control/unpaired-surrogate rejection, + reserved `hb_adid`, duplicate joins, and accessors/prototypes. Measure canonical + schema-order/request-order/lexically-sorted-targeting JSON just below/at/above + `8 * 1024 * 1024` bytes. + +- [ ] **Step 2: Normalize every provider response to exactly one internal `ProviderSlotOutcome`** + per dispatched slot. Produce `AuctionDecisionSetV1` once in the orchestrator. + Add pure serializers/parsers for `/auction` + `ext.trusted_server.slot_results` and `BrowserAuctionProjectionV1`, exercised by + direct unit tests while preserving the current internal return API. Do not yet + wire them into production `/auction`, initial HTML, or page-bids; their exact + coordinated switch occurs in Task 19. A winner must join exactly one projected + bid by exact slot plus candidate id; no-bid/failed joins none. + + When a complete canonical projection exceeds 8 MiB, transactionally convert every + winner decision to `failed{reason:'winner_not_renderable'}`, emit zero projected TS + winner bids, retain existing no-bid/failed decisions in request order, and remove + the corresponding `/auction` TS `seatbid` entries. Never choose a first-fit subset + or emit a partial projection. Prove the reduced result is bounded and deterministic + under response-order permutations. + +- [ ] **Step 3: Introduce internal provenance `(provider_name, upstream_bid_id)` and a** + response-unique opaque 12-character base64url `candidate_id` from 9 CSPRNG bytes; + test eight response-local collision retries and terminal `internal_error`. Store + candidates before mediation; put the id only at + `ext.trusted_server.candidate_id`; require the mock/configured mediator to echo + exactly one known id. Take only the selected price/selection metadata from + mediation and restore every render, identity, dimension, currency, and + notification field from the stored source candidate. Reject missing/unknown/ + duplicate echoes, substitutions, and mediator-native render sources. + +- [ ] **Step 4: Preserve the repository's configured mediator selection and timeout fallback.** + Preserve direct highest-CPM selection, adding only the deterministic + `(provider_name, upstream_bid_id)` tie break. Add response-order permutation tests + and prove opaque ids/arrival order never break ties. + +- [ ] **Step 5: Define and test the exact `/auction` winner wire. Standard `bid.id` is the** + `r1_` renderer reservation; `bid.impid` maps exactly to the server slot; and + `bid.ext.trusted_server` has only `candidate_id`, `slot_id`, and + `render_source`. Require the four-way decision/candidate/impid/slot join. Reject + missing, duplicate, extra, or mismatched bids; APS/cache standard `adm`; and ADM + disagreement between standard `adm` and `render_source.adm`. TSJS renders only + the tagged source and never reconstructs it from standard fields. + + The producer and parser must both deny unknown keys and enforce the same projection + field/count/byte grammars before any slot, reservation, targeting, or bid mutation. + Boot maps malformed/oversized input to `abi_mismatch`; page-bids/direct admission + maps it to `invalid_response` with no partial state. + +- [ ] **Step 6: Do not modify `auction_config_types.rs`, `auction/config.rs`, settings, TOML, or** + auction documentation unless compilation reveals an existing type reference that + must be renamed for the tagged render-source union. This task adds no new + `winner_selection`, currency, or mediator-fallback requirement. + +- [ ] **Step 7: Run:** + + ```bash + cargo test-fastly orchestrator + cargo test-fastly formats + cargo test-fastly publisher + cargo test-axum + npm --prefix crates/trusted-server-js/lib test -- --run test/core/auction.test.ts + ``` + +### Task 4: Project one tagged render source and exact renderer identity + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.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/gpt.rs` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/core/config.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` + +- [ ] **Step 1: Add failing server tests for SSAT boot bids, page-bids, and `/auction`. Each must** + preserve the exact `BidRenderSourceV1`, `candidateId`, and server-minted renderer + reservation plus the finite nonnegative selected CPM that later becomes the + internal `WinnerContext`. Browser projection exposes `rendererReservationId`; + `/auction` uses the same value as standard OpenRTB `bid.id`. CPM is never copied + into a render descriptor or capability. + +- [ ] **Step 2: Add failing browser tests proving every TS-owned APS, ADM, and cache PUC source** + uses the `r1_` reservation byte-for-byte as GAM `hb_adid`. For Trusted Server + Prebid, replace the generated TS bid `adId` with that same reservation before + targeting; keep native Prebid `adId` untouched. Preserve PBS Cache UUID only as + `renderSource.cacheId` and the exact fetch query binding, never as bridge + authority. Add negative tests for truncation, fallback to upstream/cache ids, and + native-bid mutation. + +- [ ] **Step 3: Add Rust generation tests for `r1_` plus 22 unpadded base64url characters from** + 16 CSPRNG bytes, response-local uniqueness, eight collision retries, and + `identity_generation_failed`. Make projection choose identity from a tagged + enum/path decision, not an `or_else` chain. Reject invalid targeting before + serialization; never truncate. + +- [ ] **Step 4: Add the immutable `CacheFetchPolicyV1` boot contract. When cache rendering is** + enabled, project the trusted configured HTTPS base URL at + `tsjs.boot.cachePolicy`, validate/freeze it before integration-module preparation, and build + `fetchUrl` server-side with exactly one canonical `uuid` query. Test credentials, + query, fragment, origin/port/path mismatches, duplicate query keys, missing policy, + and configuration mutation after the navigation snapshot. This is cache render + correctness only; do not add a cache subsystem or runner caching. + +- [ ] **Step 5: Limit this task to wire projection and pre-mutation validation. Attempt-owned** + compare-and-restore targeting cleanup is implemented only after `RenderAttempt` + exists in Tasks 13 and 16. + + This task does not publish the new initial/page-bid projection or mutate a live + Prebid/GPT bid. Those production mutations begin atomically in Task 19 after the + reservation store and integration-module runtime exist. + +- [ ] **Step 6: Run:** + + ```bash + cargo test-fastly publisher + 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 + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-core/src/integrations/registry.rs` +- Modify: `crates/trusted-server-core/src/platform/http.rs` +- Modify: `crates/trusted-server-core/src/platform/test_support.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/platform.rs` +- Modify: `crates/trusted-server-adapter-fastly/Cargo.toml` +- Modify: `crates/trusted-server-adapter-axum/src/app.rs` +- Modify: `crates/trusted-server-adapter-axum/src/platform.rs` +- Modify: `crates/trusted-server-adapter-axum/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/platform.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/Cargo.toml` +- Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` +- Create: `crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml` +- Modify: `crates/trusted-server-adapter-spin/src/app.rs` +- Modify: `crates/trusted-server-adapter-spin/src/platform.rs` +- Modify: `crates/trusted-server-adapter-spin/Cargo.toml` +- Modify: `crates/trusted-server-adapter-spin/tests/routes.rs` +- Create: `crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml` +- Create: `crates/trusted-server-integration-tests/fixtures/configs/viceroy-aps-runner-proxy-template.toml` +- Modify: `crates/trusted-server-integration-tests/Cargo.toml` +- Create: `crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs` +- Create: `crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs` +- Modify: `crates/trusted-server-integration-tests/tests/common/mod.rs` +- Create: `crates/trusted-server-integration-tests/tests/environments/spin.rs` +- Modify: `crates/trusted-server-integration-tests/tests/environments/mod.rs` +- Modify: `crates/trusted-server-integration-tests/tests/environments/cloudflare.rs` +- Modify: `crates/trusted-server-integration-tests/tests/environments/fastly.rs` +- Modify: `crates/trusted-server-integration-tests/tests/parity.rs` +- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts` +- Create: `crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js` +- Create: `scripts/integration-tests-aps-runner-proxy.sh` +- Modify: `scripts/integration-tests-browser.sh` +- Modify: `.github/workflows/integration-tests.yml` + +- [ ] **Step 1: Write failing route and exact renderer-policy 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`. + +- [ ] **Step 2: 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/raw proxy policy and exact renderer headers are not + yet implemented on every adapter. + +- [ ] **Step 3: Define the bounded raw-proxy platform contract.** + + Add a dedicated request/response policy in `platform/http.rs` and adapter + implementations that: + - 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, + and redirect following disabled; + - exposes `ProxyResponseEvidenceV1`: status plus every occurrence of the three + security-relevant headers when the runtime preserves pairs, or the exact visibly + combined value when it does not. Combined values are never split and every + comma/list form fails the singleton grammar; erased/ambiguous evidence is + `unavailable`, never reconstructed; + - returns a bounded stream or bounded buffer with a five-second monotonic deadline + from dispatch through the final body byte, cancellation on timeout/overflow, and + generation-inert late continuations; and + - uses the common `APS_RUNNER_MAX_RESPONSE_BYTES = 8 MiB` cap. + + Cloudflare must inspect the initial Workers headers before its generic adapter + strips encoding/length; concatenated duplicates remain visibly combined and fail. + Spin must bypass `spin_sdk::http::send`, call the WASI HTTP outgoing handler with + supported request options, and poll both response and body stream against a + monotonic-clock total deadline. Axum and Fastly must enforce the same deadline, + evidence, redirect, encoding, and cap contract instead of their generic client + 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.** + + Drive each real transport boundary—including Cloudflare and Spin wasm and full + Fastly routes—against a controlled fictional upstream. Cover status other than + 200; redirects; stall and slow-drip total deadlines; late data after cancellation; + absent/duplicate/malformed/mismatched/over-limit `Content-Length`; + absent/identity/listed/other `Content-Encoding`; missing/duplicate/parameterized/ + rejected `Content-Type`; invalid UTF-8; exactly-at and one-byte-over 8 MiB bodies; + buffered and streamed overflow; byte-preserving success; stripped upstream + cookies/headers; and empty non-leaking `502 no-store` failures. Do not inject an + already-normalized core response in place of an adapter transport. + + Build a hermetic `aps_runner_proxy` runtime test, not a core fake. Its private + control socket selects the next fictional upstream response out-of-band; browser + requests cannot choose a scenario or target. The request entering core always + carries the compile-time APS logical URL. An integration-test-only platform + resolver maps only that exact logical origin to the loopback fixture below target + validation and immediately above the real adapter transport. Fastly uses a + generated Viceroy backend, Cloudflare uses a workerd service binding in the + dedicated Wrangler manifest, Spin uses the dedicated Spin manifest, and Axum uses + its real bounded client. Production constructors expose no resolver/override, and + release-build absence tests fail if the integration feature, fixture address, or + service binding is enabled or embedded. + + `scripts/integration-tests-aps-runner-proxy.sh` builds the actual Fastly + `wasm32-wasip1`, Cloudflare `wasm32-unknown-unknown`, and Spin `wasm32-wasip1` + artifacts with that integration-only transport seam; launches Viceroy, + `wrangler dev`/workerd, and `spin up`; waits for readiness; runs the identical + `aps_runner_proxy` corpus against each runtime with `--test-threads=1`; and always + terminates process groups. The test asserts the logical URL and Host remain the + fixed APS target, the fixture is loopback-only, and actual runtime header + normalization, cancellation/resource drop, streaming cap, and + dispatch-through-final-byte clock produce the expected response. + +- [ ] **Step 5: 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. + `scripts/integration-tests-browser.sh` accepts `TS_TEST_APS_V1=1` only to build that + non-release artifact; ordinary production dispatch stays unchanged until Task 19. + CI/release absence checks reject the integration feature/sentinel in a production + bundle, so this cannot become a hidden dual route. + Accept only status 200, the exact closed content-type/encoding/content-length + grammars from spec §3.6, a body within 8 MiB, and exact UTF-8 bytes. Relay accepted + bytes unchanged while replacing all headers with exactly the specified + JavaScript content type, wildcard CORS, cross-origin CORP, `nosniff`, and + 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.** + + The renderer validates/clears the fragment nonce, accepts one exact source-bound + parent port, validates the descriptor and kernel-captured publisher origin, and + resolves only its own absolute `/integrations/aps/runner.js`. Before loading the + script, queue the exact `prebid/creative/render` event with one-shot + `resolve`/`reject`. Set only `crossOrigin='anonymous'` and + `referrerPolicy='no-referrer'`; do not set integrity/SRI. Script `load` is + nonterminal progress. Proxy/CORS/script-load failure reports `runner_no_load`; + callback rejection reports `runner_failed`; callback success reports completion. + The renderer starts no completion timer—the kernel owns the only ten-second timer + 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.** + + Author a minimal local fixture that implements only the documented event and + queue/resolve/reject behavior. Assert it is neither a copy, transformation, nor + derivative of APS bytes. Use it for deterministic success, rejection, script-load, + 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.** + + ```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 + 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 + ``` + +- [ ] **Step 9: Commit the transport and renderer slice.** + + ```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" + ``` + +### Phase 1 exit + +- Valid APS bids survive mediation and projection without identity loss. +- Invalid bids fail with exact local reasons. +- Test-only adapter registries prove the same secure static renderer and live runner + proxy behavior through every actual transport without activating a dual production + route. +- Existing non-APS auction and publisher tests remain green. + +## Phase 2 — build one TSJS runtime + +### Task 6: Enforce layering and external-global ownership + +**Files:** + +- Modify: `crates/trusted-server-js/lib/eslint.config.js` +- Modify: `crates/trusted-server-js/lib/package.json` +- Create: `crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js` +- Create: `crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs` +- Create: `crates/trusted-server-js/lib/src/adapters/googletag.ts` +- Create: `crates/trusted-server-js/lib/src/adapters/prebid.ts` +- Create: `crates/trusted-server-js/lib/src/adapters/messaging.ts` +- Create: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Create: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing lint-rule tests for direct and aliased access through `window`,** + `globalThis`, and `self`, plus aliases of `googletag`/`pbjs`. Add allowed cases + inside adapters and kernel `window.tsjs`/messaging code. + +- [ ] **Step 2: Configure `import/no-restricted-paths` for the dependency direction in the** + source-shape diagram. Add a narrow, enumerated temporary allowlist for current + production files that still violate the target (`core/request.ts`, GPT/Prebid + integration files, and diagnostics files found by the initial lint inventory). + New files receive no exemption. Check the allowlist into the lint test and make + Task 22 fail if any entry remains. + +- [ ] **Step 3: Add adapter interfaces and no-op/fake constructors before moving behavior. Add a** + composition root that alone imports concrete adapters/services. Kernel files + accept interfaces and never construct downstream objects. At this task's end + production remains behaviorally unchanged, while new kernel/service files cannot + touch GPT or Prebid globals. + + The messaging interface must support synchronous capture-phase listener + installation. Reserve that hook as the first reversible core activation so the + final core can install the TS capability recognizer before integration-module + activation and before TS-owned GPT/Prebid injection, while leaving no listener live + during asynchronous preparation. + +- [ ] **Step 4: Ensure `.github/workflows/format.yml` continues to run lint and** + `.github/workflows/test.yml` runs typecheck. Enforcement begins with the narrow + allowlist and becomes repository-clean in Task 22. + +- [ ] **Step 5: Run:** + + ```bash + node --test crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Task 7: Implement disposal, terminal latch, and transactional integration modules + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/kernel/disposable.ts` +- Create: `crates/trusted-server-js/lib/src/kernel/integration_registry.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/disposable.test.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` + +- [ ] **Step 1: Write failing fake-timer tests for reverse-order exactly-once disposal, disposer** + failure isolation, registration after disposal, first-terminal-wins, duplicate + integration id, malformed/unknown manifest id, wrong 64-hex release, pending + capacity 16, prepare throw/async rejection/abort, detached continuation, + activation throw, duplicate `afterCommit`, shared deadline abort, and late + continuation. + +- [ ] **Step 2: Implement `DisposableStack` without relying on a browser proposal unavailable at** + the configured target. It must be synchronous at ownership boundaries; async side + work can observe its signal but cannot delay terminal disposal. + +- [ ] **Step 3: Implement the exact `BootManifestV1` contract and release-internal** + `_registerIntegration({id,release,prepare})` collection. All manifest entries are + required. Registration executes no module code. In manifest order, core awaits + only each returned preparation Promise; preparation may validate frozen config, + acquire interfaces, allocate inert private data, and register private-memory + disposers, but cannot touch globals/DOM, attach wrappers/listeners, inject/load, + start timers/network, invoke publisher code/stateful adapters, or detach work. + Quarantine wrong-release, unexpected, duplicate, and post-fallback bundles + before calling `prepare`. + +- [ ] **Step 4: Implement the synchronous activation barrier.** A prepared module + returns one synchronous `activate(ctx)`. Activation may install only + compare-restorable wrappers/listeners/observers/subscriptions, registering each + disposer before mutation. It may stage at most one `afterCommit` callback and + cannot yield, inject/load, start timers/network, drain a queue, or invoke + publisher callbacks. Activate in manifest order; on throw, unwind every + activated/prepared module in reverse order before fallback. Check the shared + monotonic deadline before and after every activation and immediately before + handoff: 9,999 ms may commit; 10,000/10,001 ms must unwind even if the timer task + has not run. Document/test that a permanently nonreturning same-thread function + is unpreemptable. A second `afterCommit` registration throws and becomes + `bundle_partial`. + +- [ ] **Step 5: Test post-commit behavior.** After all activations, publish the full + API, invoke staged callbacks in manifest order, then drain preload work. An + `afterCommit` throw disposes only that module, records a bounded local runtime + failure, and cannot roll back the kernel or create fallback. Race publisher GPT + calls and script/creative DOM activity before, during, and after a later module + failure; prove preparation is inert, activation is same-task, and no wrapper, + guard, script, request, listener, or timer survives a fallback commit. + +- [ ] **Step 6: Expose only frozen interfaces; keep mutable maps and owner tokens in closures.** + +- [ ] **Step 7: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/disposable.test.ts test/kernel/integration_registry.test.ts + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Task 8: Implement bootstrap ownership and the single runtime registry, dormant + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/kernel/runtime.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/runtime.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-js/lib/src/core/index.ts` +- Modify: `crates/trusted-server-js/lib/src/core/queue.ts` +- Modify: `crates/trusted-server-js/lib/src/core/log.ts` +- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts` +- Create: `crates/trusted-server-js/lib/test/core/queue.test.ts` +- Create: `crates/trusted-server-js/lib/test/core/log.test.ts` +- Create: `crates/trusted-server-js/lib/src/integrations/gpt/bootstrap_fallback.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts` +- Modify: `crates/trusted-server-core/src/integrations/gpt.rs` +- Modify: `crates/trusted-server-core/src/tsjs.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/package.json` +- Create: `crates/trusted-server-js/lib/scripts/print-release-id.mjs` +- Modify: `crates/trusted-server-js/build.rs` +- Modify: `crates/trusted-server-js/src/bundle.rs` +- Modify: `crates/trusted-server-js/src/lib.rs` + +- [ ] **Step 1: Add failing tests for field-wise initialization, existing publisher queue/config,** + two core loads, invalid manifests, wrong/duplicate/unknown integrations, + prepare/activate/after-commit failure at every checkpoint, a missing or hung + integration, the shared watchdog and monotonic activation checks, + owner-generation mismatch, late continuation after fallback, bundle after + fallback, and exactly-one committed owner. At each failure checkpoint assert the + exact immutable `abi_mismatch | bundle_partial` reason; FIFO draining despite a + throwing callback; queued and later `requestAds`; already-aborted signals; late + integration refusal without invoking module code; and zero runtime/service/adapter/ + listener/timer/port/iframe construction after the fallback commits. + + Exercise the queue boundary as a real Array: pushes before/during/at activation and + commit, retained ingress references, snapshot-versus-forward exactly once, nested + push ordering, `this === tsjs`, throw isolation, and non-callables. The final queue + has `length:0`, its own immediate `push`, and is frozen; native/borrowed mutators, + index/length assignment, deletion, and property definition in strict/sloppy callers + cannot retain work or change length. + +- [ ] **Step 2: Implement `unclaimed → installing → kernel` and** + `installing → failed → fallback`. Start the only ten-second watchdog immediately + before core injection; it covers registration, preparation, and activation for + every required integration. Combine the timer with `performance.now()`/the + injected monotonic clock checks before/after every activation and before + handoff. Abort completes synchronous reverse-order unwind before fallback. + Deferred bundles after fallback are rejected and cannot replace that generation. + Exercise this through the test-only composition harness; do not yet replace the + production bootstrap. + + Normalize `tsjs.que` to an actual ingress Array with a writable-false, + configurable-true property during preparation. After successful activation (or + after unwind for fallback), perform the spec §5.3 six-step synchronous handoff: + construct/freeze the final real Array, snapshot callable own data entries, clear + ingress and replace its `push` with a forwarder, redefine the public property + non-writable/non-configurable with all committed fields, run kernel `afterCommit` + callbacks, then drain the snapshot. No task/microtask may split the steps. + + The fallback is one terminal non-rendering shell, never a reduced runtime. Before + draining the queue it installs the final `requestAds` validator, immediate queue, + permanently refusing `_registerIntegration`, exact `version:'1.0.0'`/`releaseId`, + validating-then-refusing `addAdUnits`, local logger, immutable safe boot value, and + frozen non-enumerable `_internal` fallback record. It + constructs no runtime, registry, adapter, bridge, timer, listener, port, or + iframe. Batch membership comes only from exact server slot ids in the immutable + boot auction projection. Retain it only when the full §3.1–3.2 shape, grammar, + 256-slot, dimension, and 8 MiB bounds pass; otherwise substitute exactly + `{version:1,auction:{version:1,auctionId:'fallback',results:[]},bids:[]}` plus + creative/diagnostics disabled safe defaults. Known members resolve with the boot failure, unknown ids + resolve `slot_unresolved`, aborted known members cancel, and an omitted empty + projection resolves `slots:[]`. No valid call remains pending. + +- [ ] **Step 3: Make `build-all.mjs` emit all bundles with one fixed sentinel, compute 64** + lowercase SHA-256 hex over the canonical ordered ids plus sentinel-normalized + bytes, replace exactly one sentinel per bundle, and verify none remains. Embed + that release id in every bundle. Write exact generated + `crates/trusted-server-js/dist/tsjs-release-v1.json` with + `{version:1,releaseId,bundles:[{id,file}]}` in canonical bundle order; add + `npm run --silent print:release-id` to validate that file and print only its + 64-hex id. Extend `build.rs` generated metadata to include + the sentinel-normalized all-bundle `release_id`; expose it through `bundle.rs` and + the crate API beside bundle bytes/content hashes. Add Rust tests proving generated + metadata and every bundle carry the same id. Implement the pure + `BootManifestV1` serializer with exactly the enabled unique integration ids in actual + injection order and `required:true`, but do not emit it into production HTML yet. + Test changed logical bytes, reordered integrations, sentinel multiplicity, wrong + release, missing integration, and server/bundle disagreement. + +- [ ] **Step 4: Install one `Runtime`; the composition root keeps the mutable service** + registry and owner tokens in its closure, and integrations obtain frozen + interfaces only through exact-release preparation/activation contexts. + `tsjs._internal` is non-enumerable frozen status data only—never the registry. + + In test-only composition, activate the capture-phase bridge recognizer as the first + reversible core effect, followed by correctness GPT listeners and prepared modules. + Production bootstrap and manifest emission remain unchanged until Task 19. + +- [ ] **Step 5: Generate the proposed queue/boot-flags fallback artifact from** + `bootstrap_fallback.ts` and test its bytes and terminal behavior at every + checkpoint. Task 19 performs the one production replacement and adds the final + embedded staleness assertion; there is never a hand-maintained second fallback + implementation. + +- [ ] **Step 6: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/runtime.test.ts test/integrations/gpt/gpt_bootstrap.test.ts + cargo test-fastly release_id + cargo test-fastly + npm --prefix crates/trusted-server-js/lib run build + ``` + +### Task 9: Add runtime and navigation sessions + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/kernel/sessions.ts` +- Create: `crates/trusted-server-js/lib/src/kernel/identity.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/sessions.test.ts` +- Create: `crates/trusted-server-js/lib/test/kernel/identity.test.ts` +- Create: `crates/trusted-server-js/lib/src/services/projections.ts` +- Create: `crates/trusted-server-js/lib/test/services/projections.test.ts` +- Create: `crates/trusted-server-js/lib/src/services/context.ts` +- Create: `crates/trusted-server-js/lib/test/services/context.test.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/runtime.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing tests for RuntimeSession singleton ownership, navigation replacement,** + reverse-order disposal inventory, late old-generation callbacks, same DOM id on a + new navigation, timer/listener/port cleanup, and double disposal. + + Add deterministic-crypto issuer tests proving one `NavigationSession` obtains an + eight-byte CSPRNG prefix and combines it with one big-endian unsigned 64-bit ordinal + stored as two u32 words. Each `a1_` encodes those 16 bytes as 22 unpadded base64url + characters; ordinal increments exactly once, never wraps, and needs no issued-id + history. Unavailable/throwing crypto or ordinal exhaustion fails + `identity_generation_failed` before creating work. Separately prove `t1_` and `n1_` + encode 16 fresh CSPRNG bytes; their eight-draw collision/capacity behavior belongs + to Tasks 14 and 13. Never pass raw bytes or issued identities to logging/debug + callbacks. + +- [ ] **Step 2: Implement explicit owner APIs:** + - `RuntimeSession`: injected adapter interfaces and owner/disposer scopes; it + does not import concrete adapters or services; + - `NavigationSession`: aliases, intents, targeting ownership, batches, attempts, + and one internal immutable current auction projection; + - `AuctionContextRegistry`: runtime-owned, manifest-bounded contributor callbacks + registered with owner-scoped disposers; it snapshots contributions for one batch, + preserves manifest registration order and later-key precedence, freezes the + result, and isolates/logs one contributor throw without retaining its values; + - child scopes for `AuctionBatch` and `RenderAttempt`. + + Concrete slot maps, physical cycles, reservations, and the bridge listener are + services constructed in `composition/browser.ts` and disposed by the runtime + scopes through interfaces. + + Seed only the initial session from recursively frozen + `tsjs.boot.auctionProjection`; never mutate boot. A new SPA session begins with no + projection. Its page-bids controller deep-copies/freezes one exact current- + generation `BrowserAuctionProjectionV1`, reserves all projected slots against the + shared 256 cap (including already-admitted programmatic units), and commits slots + + projection atomically. Stale/duplicate/malformed/over-cap responses commit nothing + and never retain prior-navigation data. + +- [ ] **Step 3: Every callback captures an owner generation and verifies it before mutation.** + Provide test-only inventory snapshots; do not expose mutable production state. + + Keep the issuer injectable only through test composition. Production composition + always uses browser Web Crypto; no `Math.random`, counter, timestamp, publisher + input, or compatibility-form parser may mint a capability. + +- [ ] **Step 4: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/sessions.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/identity.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/services/projections.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/services/context.test.ts + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Task 10: Implement bounded adapters and readiness queues + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/prebid.ts` +- Create: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts` +- Create: `crates/trusted-server-js/lib/test/adapters/prebid.test.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts` +- Create: `crates/trusted-server-js/lib/test/adapters/messaging.test.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing tests for `present | pending | timed_out | incompatible`, late external readiness,** + per-operation timeout/removal, abort/disposal, queue capacity, command throw, + callback throw, exact message keys/version, zero/one/two ports, and port closure. + Race readiness immediately before/at/after the exact deadline. + +- [ ] **Step 2: Move global access behind injected adapter methods. `timed_out` describes one** + operation, not a permanent library state; `incompatible` describes only the + currently bound external object/stamp and later replacement may succeed. Each adapter + readiness queue holds at most 64 operations. Overflow fails the new operation + synchronously with `external_queue_full`; timed-out/aborted entries are removed + immediately and readiness drains live entries FIFO. Each operation expires with + `external_ready_timeout` exactly ten seconds from enqueue, independent of + `requestAds.timeoutMs` and the auction-fetch deadline. Dispatch versus expiry + races through one latch. + +- [ ] **Step 3: Centralize all message names, exact-shape guards, safe port extraction, and port** + disposal in the messaging adapter. Do not put reservation or lifecycle policy in + the adapter. + +- [ ] **Step 4: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/adapters + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Phase 2 exit + +- One runtime and registry exist across separately built IIFEs. +- Bootstrap/fallback ownership is transactional and generation-safe. +- Sessions and adapters own every external global and disposal boundary. +- No APS/GPT/Prebid production behavior has yet been switched without tests. + +## Phase 3 — move slot, auction, and render state into services + +### Task 11: Implement the slot registry and physical GPT cycle model + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/services/slots.ts` +- Create: `crates/trusted-server-js/lib/test/services/slots.test.ts` +- Create: `crates/trusted-server-js/lib/src/services/targeting.ts` +- Create: `crates/trusted-server-js/lib/test/services/targeting.test.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/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing tests for exact nonempty server slot ids at the 256-UTF-8-byte** + boundary with NUL/control rejection, combined 255/256/257 server plus + programmatic records, ad-unit codes, DOM aliases, alias + collision, GPT object identity, SRA, one active/one queued replacement, TS versus + publisher intent, `display()` under disabled initial load, same/opposite-class + supersession, ambiguous overlap, duplicate response identifiers, no timeout + re-arm, safe destroy/redefine, and navigation disposal. For disabled initial load, + prove TS `display()` only registers and exactly one + `refresh([slot],{changeCorrelator:false})` owns the intent; refresh unavailability/ + throw fails `gpt_request_failed`, while publisher display stays publisher-owned. + Include old completion + after navigation and before/after the replacement completion on the same DOM id. + Race `slotRequested` immediately before/at/after three seconds and + `slotRenderEnded` immediately before/at/after ten seconds from request start. + + Cover transactional destroy/redefine at every recovery site: throw/false destroy, + failed replacement definition, stale generation after define, and proof that no + second physical GPT slot or binding appears. Destroy failure retires/quarantines + the old identity and makes later TS work fail `gpt_request_failed` until publisher + destruction or reload. + +- [ ] **Step 2: Implement `SlotRecord`, navigation-local registration ordinals, and indexes.** + Reject missing/empty/over-limit server ids and exact server-id collisions before + indexing. Ad-unit and DOM alias resolution must produce exactly one record or + `slot_unresolved`; never normalize server identity or choose first registration. + Enforce one `MAX_ACTIVE_SLOT_RECORDS = 256` transaction across server projection + and later programmatic registration; navigation disposal releases all records. + +- [ ] **Step 3: Record intent before the adapter operation. Open a physical cycle only on** + `slotRequested`; close on `slotRenderEnded`; attribute only when exactly one live + compatible TS intent exists. Fail ambiguous TS ownership instead of guessing. + A request-capable GPT call with no `slotRequested` by three seconds fails + `gpt_request_timeout`; because no attributable cycle exists, immediately + invoke one adapter transaction that marks the old TS object retired, requires + successful `destroySlots([old])`, and only then defines/binds a replacement; or + place a publisher-owned object in permanent + page-lifetime quarantine until explicit publisher destruction/reload. Future GPT + events never release that request-timeout quarantine. An opened cycle with no + completion by ten seconds fails `gpt_completion_timeout`; only its matching real + completion may drain that exact cycle. Neither timeout starts fallback. + +- [ ] **Step 4: Expose narrow operations to integrations: register/adopt slot, record intent,** + handle GPT event, own/clear targeting, resolve exact slot, dispose navigation. + +- [ ] **Step 5: On navigation disposal, destroy/redefine only a TS-owned GPT slot object and keep** + the retired object in the `WeakMap` until late completion drains. Define a + replacement only when a current navigation needs it and the transactional + destroy succeeded. Quarantine an + open publisher-owned object until its real `slotRenderEnded`, publisher + destruction, or reload; new TS work fails `slot_quarantined`. No timeout or + navigation token re-arms a physical cycle. + +- [ ] **Step 6: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/services/slots.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/services/targeting.test.ts + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Task 12: Implement the bounded renderer reservation store + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/services/reservations.ts` +- Create: `crates/trusted-server-js/lib/test/services/reservations.test.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/sessions.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing tests for `r1_` reservation validation, exact identity lookup, duplicate** + insertion, atomic claim, duplicate simultaneous claim, consumed/stale/disposed + tombstones, the fixed 15-minute render boundary, the ten-second Prebid admission + lease, atomic selection promotion to a new 15-minute render expiry, unselected/ + aborted/selection-timeout tombstoning only through the original lease, union + capacity 320, suppression/refusal and contract-failure tombstoning of a PUC claim + against a pre-selection lease, refusal at capacity, no live eviction, and a late + request for the oldest unexpired id. Cover immutable finite/nonnegative + `WinnerContext{selectedCpm}`, exact Prebid-bid CPM equality, context preservation + across promotion and projection replacement, transfer into the attempt before + consumption, and context/source deletion from tombstones. Renderer-id generation/collision retry + belongs to Rust Task 4, not this browser store. + +- [ ] **Step 2: Move `apsPrebidRenderers` and consumed-id maps out of public globals into the** + runtime-owned service. Entries carry exact slot, tagged render source, navigation + generation, fixed expiry, state, attempt binding, and immutable winner context + while live. Source + `WindowProxy` is intentionally absent until the first valid PUC claim acquires + it. Consumption transfers the render source/context to the exact attempt before + replacing the entry. It never extends expiry; tombstones remain through original + expiry with only id/expiry/state/minimum suppression metadata. + +- [ ] **Step 3: Use one reservation type for every TS-owned APS, ADM, and cache PUC source.** + Validate the supplied server id but never generate it in the browser. Cache UUID + remains only `cacheId` transport state; upstream bid id remains provenance; + native Prebid ids never enter this store. Reject an id collision against any + live/tombstoned entry; lookup recognizes a TS id before detailed validation. + +- [ ] **Step 4: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/services/reservations.test.ts test/integrations/aps/render.test.ts + ``` + +### Task 13: Implement the RenderAttempt state machine and direct paths + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/services/render.ts` +- Create: `crates/trusted-server-js/lib/test/services/render.test.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/src/core/render.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing state-table tests for every valid transition and every invalid/replay** + transition. Race success, failure, timeout, caller abort, supersession, and + navigation disposal through one terminal latch. Add accepted-artifact promotion + races and prove terminal attempt disposal cannot remove a committed render. At + construction assert one exact navigation-unique `a1_` attempt id; fallback child + ids are distinct and bind their exact parent id. Test navigation-prefix failure, + ordinal exhaustion, disposal, and that neither ids nor issuer bytes reach logs. + + Add renderer-nonce live-registry tests at 255/256/257 entries, eight collision + draws, crypto failure, exact source/port/attempt/generation binding, disposal reuse, + and no tombstone/history set. Capacity maps `capability_registry_full`; exhausted + draws map `identity_generation_failed`. + +- [ ] **Step 2: Implement path-independent `RenderAttempt` ownership: state, exact slot,** + generation, exact `a1_` id, optional parent attempt id, tagged render source, + immutable `WinnerContext{selectedCpm}`, timers, ports, iframe, terminal result, + and disposer. Direct winner admission constructs the context from the exact + validated joined server winner; a PUC claim receives the same context from the + consumed reservation. + Obtain the id only from the NavigationSession issuer before registering work; an + issuance failure settles `identity_generation_failed` without DOM/global + mutation. Add `SlotOperation` above attempts so a primary and optional fallback + retain immutable child results while the operation exposes one final result and + `path`. + Transition methods—not callers—create and clear deadlines. Before acceptance, + atomically detach committed iframe/targeting/physical-slot metadata into one + slot/navigation-owned `CommittedRenderArtifact`; the attempt disposer removes + only uncommitted resources. On replacement, promote the new artifact, dispose + the prior artifact before publishing the new one, and rebase targeting ownership + without clearing the newer generation. Direct + iframe artifacts remove their DOM; PUC artifacts defer DOM ownership to GPT and + follow TS-owned destroy/redefine versus publisher-owned metadata-only rules. + +- [ ] **Step 3: Implement direct APS:** + - validate descriptor before DOM mutation; + - mint one exact `n1_` nonce from the 16-byte Web Crypto issuer and create the + inner channel immediately before insertion; bind the nonce to the exact attempt, + 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; + - 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; + - start the document deadline at iframe insertion and fail + `renderer_document_no_load` at three seconds; + - make the kernel the sole owner of the ten-second APS-completion deadline, + starting only at document acceptance; the static renderer owns no competing + timer; + - map proxy/CORS/script-load failure to `runner_no_load`, callback rejection or + ten-second callback silence to `runner_failed`, treat `runner_loaded` only as + progress, and accept only `TS APS Render Completed`; remove on failure/cancel. + +- [ ] **Step 4: Implement direct ADM through one shared constructor used later by the PUC owner.** + Use the exact ordered sandbox, no-referrer policy, integral 1–4096 source dimensions, + CSS sizing, zero border/margin, hidden overflow, display, scrolling, title, and + aria attributes from spec §4.5. Create the iframe detached, install one-shot + handlers/disposal first, assign exactly one complete `srcdoc`, and append once; + never append empty or set `src`. Accept only the exact current pending frame's + intended `srcdoc` load while its generation/latch remain current. Initial + `about:blank`, pre-assignment, removed/replaced frame, stale generation, + post-disposal, duplicate load, error, and five-second timeout cannot accept and + map to `adm_document_no_load`. + + Implement cache as a preceding bounded fetch. Require a frozen valid + `CacheFetchPolicyV1`; require `baseUrl`/`fetchUrl` at the 4,096-byte boundary and + the server-built HTTPS URL to have the exact base origin/port/path and exactly + one canonical `uuid` query equal to `cacheId`; use `redirect:'error'`, omit + credentials/referrer, require CORS and a + successful status, and enforce five seconds and 512 KiB. Parse only a JSON object + with required own bounded nonempty `adm`; optional `w`/`h` must appear together, + stay in 1–4096, and match; optional finite nonnegative `price` is ignored; unknown OpenRTB bid + keys are ignored; raw bodies, aliases, arrays/primitives, and wrappers fail. + Expand only the exact `${AUCTION_PRICE}` token as + `String(attempt.winnerContext.selectedCpm)`; leave `${AUCTION_PRICE:B64}` untouched + and never read response `price`, current projection, targeting, or a later winner. + Test delayed PUC cache after projection replacement, Prebid lease promotion, and a + 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 6: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/services/render.test.ts test/core/render.test.ts test/integrations/aps/render.test.ts + ``` + +### Task 14: Implement Universal Creative claim and owner-control channels + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/services/render.ts` +- Modify: `crates/trusted-server-js/lib/src/services/reservations.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts` +- Create: `crates/trusted-server-js/lib/src/services/puc_bridge.ts` +- Create: `crates/trusted-server-js/lib/test/services/puc_bridge.test.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` +- 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 + the exact JSON string + `{message:"Prebid Request",adId,adServerDomain}`, object/extended shapes, + zero/two ports, native id, live/tombstoned TS id, duplicate simultaneous claim, + replay, prior navigation, SafeFrame-shaped nesting, outer post failure, and every + ordering of early claim, nonempty/empty GAM, navigation, supersession, claim + deadline, and GPT-cycle deadline. Include caller abort before/after registration, + insertion, and document acceptance; lost/closed control channel before start and + after insertion; settlement-post throw; remote cleanup at 19,999/20,000/20,001 + ms; and proof accepted remote DOM remains while every uncommitted failure removes + exactly its owned iframe and settles the PUC Promise once. + +- [ ] **Step 2: Install exactly one capture-phase bridge dispatcher synchronously as the first** + reversible core activation, before integration-module activation and any + TS-owned GPT/Prebid injection; asynchronous preparation leaves no listener. It + multiplexes only `Prebid Request` and + `TS Render Owner Register`; no attempt installs another global listener. First + perform side-effect-free minimal extraction of own data + `message`/`adId`/optional `lifecycleTicket` from JSON strings or clone-safe plain + objects without invoking accessors. For the request branch, lookup before exact + parsing or port checks. Leave non-TS ids to native Prebid; for live/tombstoned TS + ids call `stopImmediatePropagation()` immediately, including object/extended + shapes and zero/two ports, then exact-parse and generically refuse/close invalid + requests. The first valid claim acquires + `MessageEvent.source` as the authoritative PUC `WindowProxy`; never precompute or + walk the SafeFrame ancestry. + +- [ ] **Step 3: Implement the two-condition join. An early claim buffers only source plus outer** + response port and discloses no render data. A nonempty GAM result starts the + three-second claim timer when claim is absent. Empty/disposal/supersession closes + the port and tombstones. When both are present, atomically revalidate/consume, + mint an exact `t1_` ticket from 16 Web Crypto CSPRNG bytes through the Task 9 + issuer, with at most eight total draws and fixed three-second TTL, and + reply with the exact ready outer schema. Bind it to the exact attempt id, + reservation id, PUC source, and navigation generation; issuance failure settles + `identity_generation_failed` before any ready response. Refusals use the exact + generic refused schema. + The outer response carries only owner kind/ticket plus the checked-in dynamic + owner, never ADM, APS descriptor, nonce, or final document port. + + Store live tickets plus tombstones in one runtime-owned capacity-320 registry. + Prune expired entries first, never evict an unexpired entry, preserve the original + three-second expiry on consumption/disposal, and map capacity to + `capability_registry_full` and eight-draw collision exhaustion to + `identity_generation_failed` before exposing any usable capability. + + Bound a claim-first path by the GPT request-start/completion deadlines from Task + 11 and the attempt deadline. Test boundary races and prove only an attributable + completion-timeout event drains its exact physical cycle; request-timeout events + never release publisher quarantine. Neither can revive the claim or start + fallback. + +- [ ] **Step 4: In the hidden dynamic renderer call PUC's supplied** + `h.sendMessage('TS Render Owner Register',{version:1,lifecycleTicket},callback)`; + never global `postMessage`. Assert the kernel sees the original captured PUC + source, exact auto-added `adId/message` keys, and one helper-created response + port. Test exact registered/refused responses, ticket TTL/atomic consumption, + wrong source, stale generation, replay, zero/two response ports, the owner's + three-second watchdog, helper disposer, and late response. + + Receive registration through the same dispatcher. Minimally lookup the ticket + map first; ignore unknown tickets, but suppress live/tombstoned TS tickets before + exact source/adId/attempt/generation/shape/one-port checks. Refuse and close + recognized invalid/replayed registration, keep ticket tombstones through their + original TTL, and prove attempt disposal removes ticket state without removing + the runtime dispatcher. Atomically consume the first valid use, invalidate on + timeout/failure/supersession/navigation/disposal, make duplicate/stale/late uses + inert, and prove ticket values and issuer bytes never reach logs. + +- [ ] **Step 5: On registration the kernel creates the owner-control channel, keeps one endpoint,** + and transfers exactly one endpoint in `TS Render Owner Registered`. For APS it + then sends exact `TS APS Start` with the descriptor/envelope plus exactly one + renderer-document port. For ADM it sends exact `TS ADM Start` and no port. Add + exact-shape tests for every start, insertion, document progress, render + completion/failure, ADM load/failure, and final owner settlement message and for + every wrong/extra key or port count. `OwnerSettlementV1` cancellation includes + exactly `caller_aborted | superseded | navigation_disposed`; every terminal + RenderOutcome is therefore encodable after registration. + +- [ ] **Step 6: For APS, make the owner create exactly one iframe using the immutable renderer** + sandbox constant, meet the one-second insertion deadline, and leave document and + completion timing to the kernel anchors from Task 13. For ADM/cache, call the + exact shared detached-iframe constructor from Task 13; report `TS Owner Inserted` + only after its one append, then report only the intended load/failure. Initial + blank, replacement, removal, duplicate, stale, disposed, error, and timeout races + cannot resolve the PUC Promise. Resolve/reject that Promise only from the kernel's + final settlement. The remote owner—not the kernel—owns that iframe: accepted + settlement promotes it, removes temporary handlers, closes the port, and + resolves once; failed/cancelled settlement removes it, removes handlers, closes, + and rejects once. Arm one fail-closed 20-second settlement/channel watchdog when + registration accepts the control port, before start; never rearm it on start. + Malformed control, `messageerror`, local disposal, silent loss, or expiry runs + remote cleanup but cannot report acceptance to the kernel. Direct-path iframe + cleanup remains kernel-owned. The kernel owns all terminal decisions; bidder ADM + receives no capability. Prove each channel creator/retained/transferred endpoint + is closed by success, refusal, timeout, cancellation, and navigation tests. + +- [ ] **Step 7: Run the shared protocol corpus through both global and port parsers. Enforce the** + 4,096-byte inbound JSON cap before parse; exact 25-character capabilities; + field-specific 256/2,048/4,096-byte limits; safe generations; 64 KiB dynamic + owner and 72 KiB successful outer-response limits; exact keys/prototypes; and + boundary-minus-one/boundary/boundary-plus-one multibyte, duplicate-key, malformed + encoding, accessor, and exact 1/4096 dimension cases. + +- [ ] **Step 8: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/services/puc_bridge.test.ts test/services/render.test.ts test/integrations/gpt/ad_init.test.ts + ``` + +### Task 15: Implement the exact public API, programmatic registration, and `requestAds` + +**Files:** + +- Create: `crates/trusted-server-js/lib/src/services/auction_batch.ts` +- Create: `crates/trusted-server-js/lib/test/services/auction_batch.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/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/core/registry.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/types.ts` +- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts` +- Modify: `crates/trusted-server-js/lib/src/index.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/core/registry.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/log.test.ts` +- Modify: `crates/trusted-server-js/lib/test/core/queue.test.ts` +- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add failing input tests for omitted/undefined options, null/array/non-object,** + foreign/accessor/unknown properties, invalid/duplicate/too-many slots, timeout + bounds, AbortSignal brand, exact server-slot-id selection, an unknown id beside a + valid sibling, ad-unit/DOM alias collisions, and input-order output. Prove an + omitted-slot call synchronously snapshots current NavigationSession registrations + by ordinal and excludes a later registration. Prove an omitted timeout uses + exactly 10,000 ms for only the shared auction fetch; readiness and renderer/GPT + deadlines neither inherit nor reset it. + +- [ ] **Step 2: Add failing public-surface and registration tests.** Assert the exact + kernel/fallback `TsjsApi` own properties, `version:'1.0.0'`, release equality, + recursively frozen `TsjsBootV1`, non-enumerable frozen status-only `_internal`, + permanently refusing `_registerIntegration`, diagnostics present only on kernel, + and no compatibility/placeholder/mutable-config aliases. Keep numeric `V1` only + on actually serialized boot/wire schemas; public helpers are `TsjsApi`, + `TsjsCommandQueue`, `TsjsLog`, and `TsjsDiagnostics`. Update the package barrel + to export only the final public names and remove old `AdUnit`/API aliases. + + Cover `addAdUnits` one/array, empty/257 units, unknown/accessor/prototype fields, + duplicate/colliding codes, malformed media/bids/params, encoded 256 KiB auction + body cap, 63/64/65-byte bidder names, integral dimensions at 0/1/4096/4097 with + exact `invalid_dimensions | dimensions_out_of_range`, and combined registry totals + 255/256/257. Registration is synchronous, all-or-nothing, navigation-scoped, and + assigns ordinals after server slots. Fallback fully validates then throws exact + `TsjsUnavailableError` without constructing a registry. + + Assert logger default level/methods, all valid levels, invalid-level throw without + mutation, bounded output, and missing/throwing console methods. Reuse Task 8's + frozen real-Array queue tests as the final public queue contract. + +- [ ] **Step 3: Implement `addAdUnits` before live mutation.** Validate the complete + input in deterministic field order, reserve capacity for the whole call, then + transactionally register exact programmatic `code` values into the same + navigation slot service. A code is a public registered slot id but never a GPT + path/DOM alias; successful units participate only in later direct-auction + snapshots and do not define/display/target GPT. + +- [ ] **Step 4: Replace the void/callback API with the exact Promise contract. Interpret every** + explicit entry only as an exact case-sensitive registered slot id—server or + programmatic—never a GPT ad-unit path, DOM id, or alias. Preserve explicit input order or omitted snapshot order in + the results and always return the exact server id. Input errors reject before + creating attempts. Unknown explicit ids resolve `slot_unresolved` without + blocking valid siblings. After attempt creation, operational/render failures + resolve as typed per-slot results. + +- [ ] **Step 5: Implement one `AuctionBatch` per fetch. Test partial overlap across concurrent** + calls, one child superseded, all children superseded, already-aborted caller, + later abort, response deadline, reversed response order, invalid response, + missing/duplicate/extra slot decisions, winner-to-bid join mismatch, server + failed decisions, and navigation disposal. Parse the exact standard `bid.id` / + `impid` plus exact three-key `bid.ext.trusted_server` contract and require the + decision/candidate/impid/slot join. Reject standard `adm` on APS/cache and any ADM + mismatch; never infer a render source from standard bid fields. + + Snapshot the runtime-owned auction-context contributors exactly once before request + serialization. Contributors are invoked in manifest registration order; later keys + retain the baseline precedence, one throw is locally logged/isolated, and module or + runtime disposal removes the contributor before the next batch. Navigation changes + do not duplicate or discard a document-scoped contributor. No integration imports + or mutates a module-global provider map. + +- [ ] **Step 6: Preserve error distinctions:** + `auction_timeout | network_error | http_error | invalid_response`. After parsing, + consume the server's exact per-slot decisions; only an explicit `no_bid` decision + is no-bid. Never infer no-bid from an absent/malformed sibling. + +- [ ] **Step 7: Exercise the exact `TsjsApi` through test-only composition. Keep the shipped public** + entry point unchanged until Task 19; Task 22 deletes the old callback/void overload + and declarations rather than keeping an alias. + +- [ ] **Step 8: Run:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/services/auction_batch.test.ts test/core/request.test.ts test/core/auction.test.ts test/core/index.test.ts + npm --prefix crates/trusted-server-js/lib test -- --run test/core/registry.test.ts test/core/log.test.ts test/core/queue.test.ts + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Phase 3 exit + +- Slot/cycle attribution, reservations, direct/PUC lifecycles, and auction batches + are service-owned and independently tested. +- Every created attempt has one terminal result under adversarial ordering. +- The new test-only public contract has no compatibility aliases; the shipped API + remains unchanged until the coordinated Task 19 switch. + +## Phase 4 — migrate integrations and remove duplicate state + +### Task 16: Prepare the GPT integration module over adapters, slots, and render services + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts` +- Modify: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts` +- Modify: `crates/trusted-server-js/lib/src/services/slots.ts` +- 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/composition/browser.ts` +- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts` + +- [ ] **Step 1: Add or preserve failing tests for early unconditional GPT subscriptions,** + publisher services already enabled, SRA, disabled initial load, one refresh path, + `changeCorrelator:false`, responsive ambiguity, collapsed-shell guard, targeting + ownership, page-bids, SPA replacement, retired TS-owned slot objects, + publisher-owned cycle quarantine/drain, old completion races, and direct + publisher GPT activity. Include external-readiness, request-start, and completion + deadline boundaries and accepted-artifact replacement/navigation ownership. + Add the exact handoff corpus: exact and hydration-renamed late `defineSlot`, + mismatch/ambiguity, publisher duplicate `display`, explicit/global refresh with + disabled initial load, unrelated slot/options preservation, ownership transfer + racing navigation, and proof that publisher work never starts TS fallback. + + Add deterministic fake-timer DOM-reconciliation tests at 249/250 ms and + 4,999/5,000 ms for first/final pass success, ambiguity, expiry-versus-commit latch, + ownership transfer during a pass, one and two successful rebinds, immediate + `reconciliation_capacity` on a third disconnect, and navigation disposal. Exercise + request-timeout, completion-timeout, navigation, and reconciliation through the + same destroy/redefine transaction. A throwing/false `destroySlots([old])` retires + and quarantines the old identity, defines no second physical slot, and returns + `gpt_request_failed`; a failed replacement leaves the slot unbound. A stale + generation destroys any just-created TS replacement and cannot bind it. Prove no + path destroys a publisher-owned slot. + +- [ ] **Step 2: Extract a GPT integration module used by test-only composition.** Its + `prepare(ctx)` reads only validated frozen boot data and creates closures; it + performs no GPT/global/DOM/script/timer/listener mutation. Its synchronous + `activate(ctx)` installs every reversible adapter interception and registers + disposers before mutation. Script injection or other irreversible startup is + staged in the module's single `afterCommit` callback. Move every GPT-global + call into `GoogletagAdapter` and all slot/cycle state into `slots.ts`, while + retaining the shipped entry-point behavior until Task 19. + +- [ ] **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`.** + 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 + slot's `setTargeting`, per-key `clearTargeting`, and clear-all calls. A private + reentrancy marker distinguishes TS calls; every publisher-originated mutation + invalidates the affected restoration chain before forwarding, including a + same-value write, without changing arguments, return value, throw behavior, or + ordering. + + Before a TS write, compare the actual GPT value to the current frame and discard a + stale chain rather than overwriting publisher state. Cleanup writes only when the + disposing frame is the current owner and the actual value still equals its + installed string. Removing a non-top frame performs no GPT call and rebases the + successor to the removed predecessor. Acceptance promotes frames into the + `CommittedRenderArtifact`; disposal of an older accepted artifact uses the same + non-top rebase. Test two equal-string generations under newer success, failure, + supersession, and older-artifact disposal, plus publisher different-value and + same-value set, per-key clear, clear-all, wrapper replacement, and throws at every + cleanup point. Never blind-clear. + + Implement the ordered publication transaction: validate source/slot/reservation, + register the live reservation, expose exact `hb_adid` and other targeting, record + GPT intent, then invoke the request. Any intervening failure tombstones the + 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** + 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:** + + ```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 run lint + npm --prefix crates/trusted-server-js/lib run typecheck + ``` + +### Task 17: Prepare Prebid and APS registration on the shared runtime + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/prebid_modules/aliases.d.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/prebid_modules/liveIntentIdSystem.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json` +- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts` +- Modify: `crates/trusted-server-js/lib/src/adapters/prebid.ts` +- Modify: `crates/trusted-server-js/lib/test/adapters/prebid.test.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.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/build-prebid-external.mjs` +- Modify: `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs` +- Modify: `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` + +- [ ] **Step 1: Add failing tests for the server `r1_` reservation replacing a Trusted Server** + Prebid bid's generated `adId` before targeting, exact equality with `hb_adid`, + exact ad-unit ownership, invalid descriptor, duplicate response, registration + capacity, navigation disposal, native non-TS bid/id preservation, and lifecycle + success/failure. Pin the exact content-addressed external artifact built from + lockfile-resolved Prebid.js 10.26.0 and its admission behavior: `not_admitted` + produces no bid/event/targeting state; throw and partial publication fail closed + at runtime; and all three map to the exact admission/contract failure reasons. + Cover selected versus losing TS bids, native winners, multiple ad units, exact + auction-id ownership, navigation/auction abort, missing/late `auctionEnd`, event + listener ordering before publisher callbacks, ten-second admission lease, and + capacity release. Prove a PUC request before selection is suppressed/refused, + tombstones the suppress-only lease, and maps to `prebid_contract_violation`. A + dependency-version change requires deliberate fixture review. + +- [ ] **Step 2: Extract a release-matched Prebid integration module used by test-only** + composition. `prepare(ctx)` is inert; `activate(ctx)` synchronously installs + only reversible adapter state and contributes at most one `afterCommit` + callback. Use the Prebid adapter for every global call and the RuntimeSession + reservation service for APS, ADM, and cache PUC entries. The adapter binds one + exact `pbjs` object plus its own recursively frozen artifact-stamp identity and + rechecks both on every operation. Missing or invalid bindings report + `incompatible` only for that readiness operation; later whole-object replacement + can satisfy later work. Keep shipped entry-point behavior unchanged until Task 19. + +- [ ] **Step 3: Remove `tsjs.apsPrebidRenderers`, Prebid function sentinels, and direct imports of** + GPT integration internals. Preserve eids and unrelated Prebid behavior through + existing tests. + +- [ ] **Step 4: Keep `aps/render.ts` responsible for descriptor/client renderer mechanics only;** + registry and lifecycle state live in services. + + Prepare and validate the complete bid, register the reservation, replace the TS + `adId`, then call the version-pinned adapter's single + `admitTrustedBid(preparedBid): admitted | not_admitted` boundary as the only + irreversible action. Atomic `not_admitted` and throw tombstone and settle + `prebid_admission_failed`; detected partial publication tombstones/suppresses, + settles `prebid_contract_violation`, and blocks the artifact gate. Never alter + native Prebid `adId` values. + + `admitted` enters `awaiting_prebid_selection` on a ten-second lease, not a render + attempt. The adapter's early synchronous `auctionEnd` listener queries exact + auction/ad-unit winners before publisher targeting callbacks, promotes only the + selected TS id and its immutable `WinnerContext` into a new 15-minute render + reservation/attempt, and tombstones all losing TS ids only through their original + short lease. Missing auction end records + `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 + 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 + calls the then-current real object's idempotent `processQueue()` at most once per + wrapper so every publisher callback runs exactly once even when the TS module is + absent or incompatible. + + Expose only one own, non-enumerable, non-writable, non-configurable + `__trustedServerArtifactV1` data property containing the exact recursively frozen + `ExternalPrebidArtifactV1`. Same-release/content duplicates reuse the object without + re-executing factories; a different valid release refuses the new wrapper without + disturbing the working object. Absent, accessor, inherited, malformed, and hostile + non-configurable descriptors never throw or stop publisher Prebid; they only make + TS readiness incompatible and emit at most one bounded warning. Verify all manifest + sort/uniqueness/UTF-8/count/alias/config/EID bounds and exact version 10.26.0. + + Emit exactly one 64-zero release sentinel, hash the sentinel-normalized JavaScript, + replace it with the lowercase SHA-256 artifact release id, and separately compute + the final-byte SHA-256/SRI. Assert no sentinel remains and do not require the + artifact release id to equal the TSJS release id. Black-box tests bind and recheck + 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:** + + ```bash + npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid test/integrations/aps + npm --prefix crates/trusted-server-js/lib run build:prebid-external + npm --prefix crates/trusted-server-js/lib test -- --run test/prebid-artifact-integration.test.mjs + ``` + +### Task 18: Prepare creative, diagnostics, and remaining integration modules + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/click.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/iframe.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/image.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/datadome/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/datadome/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/didomi/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/lockr/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/osano/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/segments.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts` +- 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` +- 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` +- Modify: `crates/trusted-server-js/lib/src/shared/beacon_guard.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/globals.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/origin.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/scheduler.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/script_guard.ts` +- Modify: `crates/trusted-server-js/lib/test/shared/async.test.ts` +- Modify: `crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts` +- Modify: `crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts` +- Modify: `crates/trusted-server-js/lib/test/shared/scheduler.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/click.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/helpers.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/image.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/osano/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/permutive/segments.test.ts` +- 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/build-all.mjs` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/trace_cookie.rs` +- Modify: `crates/trusted-server-core/src/integrations/datadome.rs` +- Modify: `crates/trusted-server-core/src/integrations/datadome/protection.rs` +- Modify: `crates/trusted-server-core/src/integrations/datadome/protection_scope.rs` +- Modify: `crates/trusted-server-core/src/integrations/didomi.rs` +- Modify: `crates/trusted-server-core/src/integrations/google_tag_manager.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-core/src/integrations/lockr.rs` +- Modify: `crates/trusted-server-core/src/integrations/mod.rs` +- Modify: `crates/trusted-server-core/src/integrations/osano.rs` +- Modify: `crates/trusted-server-core/src/integrations/permutive.rs` +- Modify: `crates/trusted-server-core/src/integrations/sourcepoint.rs` +- Modify: `crates/trusted-server-core/src/integrations/testlight.rs` +- 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. + +- [ ] **Step 2: Convert every remaining capability into a thin integration module.** Each + `_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 + 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 + `renderGuard` is true, with no baseline DOM rewrite. A still-loading document + receives one owned `DOMContentLoaded` rescan; an already-ready document stages + that scan in the module's single `afterCommit`. Disabled creative and + enabled-with-both-guards-false perform zero wrappers, observers, listeners, + scans, or DOM mutation. Disposal compare-restores only the exact installed + wrapper and clears owned DOM state once. + + Preserve sanitization as opt-in/default-off and rewriting as its independent + existing policy across direct, SSAT, cache, and auction paths. Cover dynamic-node + guards, sandbox attributes, font/CORS/body/base behavior, opaque-origin click + recovery through `/first-party/proxy-rebuild`, validated absolute HTTP(S), and + 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.** + `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 + enrichment replaces the pending record and captured ids without reordering. One + owned zero-delay task drains FIFO. Enforce callable-before-capacity validation, + 32-live-subscriber cap, idempotent unsubscribe, unsubscribe-before-delivery, + 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 + `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 + buffer. When inactive, require zero diagnostics-added listeners, DOM, timers, + observers, API, storage, or network work beyond the two correctness listeners. + Preserve exact physical-slot binding/replacement, per-slot monotonic request + numbers, callback truth/timing, frozen exports, Shadow DOM overlay, badges, SPA, + privacy, and non-interference. + + Bound the store to 64 slot objects, ten cycles per slot, and 128 callback issues. + Expose only `tsjs.diagnostics.gpt`, with `snapshot()` plus the shared 32-subscriber + limit. Public delivery uses a separate one-entry latest-snapshot notifier on one + owned zero-delay task; 0/1/2-update coalescing, captured ids, unsubscribe/disposal, + slow/throwing listeners, and callback-stack isolation are executable tests. No + 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 + 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 + readiness/API host and at-most-100 normalized segments; Sourcepoint optional SDK + plus GPP storage/marker lifecycle; and Testlight preexisting/later callbacks, + invalid entries, and throw isolation. Run unchanged pre-cutover fixtures beside + module-composed fixtures. Permutive and any other auction-context contributor + register only through the injected runtime service during activation and remove + that contribution through the pre-registered disposer; no import-time global + 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** + 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:** + + ```bash + npm --prefix crates/trusted-server-js/lib test + 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 publisher + ``` + +### Task 19: Complete lifecycle behavior and perform the coordinated production 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` +- Modify: `crates/trusted-server-js/lib/src/integrations/datadome/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/didomi/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/lockr/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/osano/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/testlight/index.ts` +- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- 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/integrations/registry.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- 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` +- Modify: `crates/trusted-server-core/src/integrations/sourcepoint.rs` +- 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: 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. + + Run old-surface and new-surface fixture tests immediately before the switch, then + require the entire suite green after it. Do not add a production selector, dual + manifest, or shape autodetection. The temporarily unused server routes and old + declarations are deleted in Task 22 before release. + +- [ ] **Step 6: Run:** + + ```bash + 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 build + 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 + ``` + +### Phase 4 exit + +- 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. +- All Vitest and production-bundle tests pass. + +## Phase 5 — browser conformance, deletion, and release readiness + +### Task 20: Build the hermetic browser race matrix + +**Files:** + +- Modify: `crates/trusted-server-integration-tests/browser/playwright.config.ts` +- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts` +- Create: `crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts` +- Create: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts` +- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts` +- Modify: `crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts` +- Modify: `crates/trusted-server-integration-tests/browser/tests/nextjs/navigation.spec.ts` +- Modify: `crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts` +- 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` + +- [ ] **Step 1: Extend Playwright projects to Chromium, Firefox, and WebKit for the focused APS** + conformance files. Keep the broader existing suite's browser matrix unchanged + unless runtime permits expansion. + + Extend the clean-checkout browser script's `TS_BROWSER_PROJECTS` input so it + installs the selected engines and forwards Playwright arguments with + `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 3: Implement every spec §7.2 browser-observable race as grouped tables with exact** + terminal, DOM, targeting, listener, port, timer, and network assertions: + - PUC claim/join: simultaneous duplicate requests; live/tombstoned/native ids; + wrong source/slot, altered id, SafeFrame-shaped nesting, claim before/after + attributable nonempty or empty GAM, navigation/supersession on both sides, and + replay at tombstone expiry; + - capability/channel ownership: ticket/nonce capacity and eighth-draw collision, + zero/one/two transferred ports, registration before/at/after deadline, caller + abort before/after registration/insertion/document acceptance, channel loss, + settlement-post throw, owner watchdog versus late response, and exactly one + `OwnerSettlementV1` plus Promise settlement. The remote owner removes only an + uncommitted iframe at 20 seconds; accepted DOM survives; + - APS/ADM/cache documents: renderer load/error/removal/replacement, runner + acknowledgement/failure/timeout, exact 1/4096 dimensions in Rust/TS/embedded ES5/ + cache/PUC DOM, ADM initial `about:blank` versus intended `srcdoc`, and proof that + only the current intended navigation can accept. Cache expansion uses only + `String(attempt.winnerContext.selectedCpm)` and leaves `${AUCTION_PRICE:B64}` + untouched; + - runtime/bootstrap: prepare reject/abort, activation throw at every checkpoint, + 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; + - 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 + `addAdUnits`, explicit and omitted `requestAds` snapshots, unknown/colliding ids, + concurrent partially overlapping batches, aborts/timeouts, logger/error behavior, + and attempt-prefix/ordinal exhaustion without issued-id retention; + - GPT/targeting: readiness, request-start, and completion on both deadline sides; + SRA ordering, duplicate response ids, old navigation callbacks, handoff and + disabled-initial-load suppression, DOM reconciliation at 249/250 and + 4,999/5,000 ms, two-success cap, throw/false destroy, no second physical slot, + publisher ownership, and identical-string targeting generations plus same-value/ + different-value set, per-key clear, clear-all, non-top rebase, and artifact + promotion/disposal races; + - Prebid: missing/stub/late/duplicate/older/partial artifacts; exact 10.26.0 and all + stamp/manifest caps; same-release reuse, different-release refusal, hostile own + property shapes, watchdog versus late module, `pbjs`/stamp replacement, exact + prepared-bid admission and non-publication, bidder aliases/user-ID/EID coverage, + selection/loser/timeout/abort behavior, refresh exclusions, native bid/queue + survival, and proof that the external artifact has no TS auction/render behavior; + - creative/diagnostics/integrations: every creative policy/boot/automatic-guard/ + opaque-click case; render-trace ordering/enrichment/200-history/200-pending/32- + subscriber/async-delivery cases with no event alias; GPT diagnostics 512-fact, + 64-slot, 10-cycle, 128-issue, 32-subscriber, latest-snapshot and inactive-zero- + effect cases; and every remaining integration alone/maximal with startup failure, + disposal, matcher, storage/consent, callback, and cross-isolation cases; and + - transport/protocol: every message/body/count/string boundary at minus-one/exact/ + plus-one with multibyte and malformed encodings, plus the complete runner-proxy + redirect, media type, encoding, content-length, streamed-size, slow-drip, header- + stripping, byte-preserving, and empty non-leaking failure corpus through all + actual adapters, including Cloudflare and Spin wasm evidence. + +- [ ] **Step 4: Assert DOM/network/lifecycle outcomes directly. The suite must run with no** + external analytics or persistence service. + +- [ ] **Step 5: Run:** + + ```bash + ./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 + TS_BROWSER_FRAMEWORKS=nextjs \ + TS_BROWSER_PROJECTS=chromium,firefox,webkit \ + ./scripts/integration-tests-browser.sh \ + tests/shared/aps-renderer.spec.ts \ + tests/shared/aps-puc-lifecycle.spec.ts \ + tests/shared/tsjs-runtime.spec.ts \ + --project=chromium --project=firefox --project=webkit + ``` + +### Task 21: Add and pass the attested real-GAM test-network suite + +**Files:** + +- Create: `crates/trusted-server-integration-tests/browser/tests/shared/aps-real-gam.spec.ts` +- Create: `crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts` +- Create: `crates/trusted-server-integration-tests/browser/playwright.real-gam.config.ts` +- Create: `crates/trusted-server-integration-tests/fixtures/configs/aps-real-gam.template.toml` +- Create: `.github/workflows/aps-real-gam.yml` +- Do not create a second runbook; this plan contains the release gate and commands + +- [ ] **Step 1: Add a manually dispatched `aps-real-gam.yml` job using the protected GitHub** + environment `aps-real-gam` and a required `workflow_dispatch` string input + `release_id`, plus required `evidence_id` and `previous_artifact_id` inputs used + only for attestation/rollback evidence. Its exact environment contract is + `TS_REAL_GAM_PAGE_URL`, `TS_REAL_GAM_AUTH_HEADER`, and + `TS_REAL_GAM_EXPECTED_RELEASE_ID`; the first two are protected secrets and no + value is checked in. The template contains fictional placeholders only. This job + is not added to ordinary PR CI, but a successful run for the exact release id is + a mandatory cutover artifact. + + The dedicated Playwright config has no local `globalSetup`, `globalTeardown`, + Viceroy, Docker, or WASM dependency. It runs only the remote real-GAM spec against + `TS_REAL_GAM_PAGE_URL`; importing the shared local config is forbidden by a test. + +- [ ] **Step 2: Cover SSAT APS-PUC, Trusted Server Prebid APS-PUC, page-bids APS-PUC, direct APS,** + direct ADM/cache, attributable empty fallback, SRA, refresh, SPA navigation, and + collapsed-shell resize. + +- [ ] **Step 3: Add negative fixtures for wrong id/source, invalid descriptor, no outer claim,** + no owner registration, no document acknowledgement, and APS runner failure. + Exercise the live fixed-target proxy and assert route, DOM, nested-iframe, exact + lifecycle callback, and terminal outcomes. There is no runner digest/version + check: mutable APS bytes are not TS source or release identity. + +- [ ] **Step 4: Capture browser console, GPT events, sanitized network metadata, DOM snapshots,** + screenshots, and sanitized traces under `test-results/`, `playwright-report/`, + and `real-gam-evidence/`. Disable response-body/HAR embedding for the APS runner + and creative resources and strip any such bodies if the browser tool records them + despite configuration. Before upload, inspect every archive/trace/HAR and fail if + it contains a runner or creative response body, authorization value, account id, + descriptor, or lifecycle capability; metadata such as URL/status/timing is + allowed. Upload one artifact named + `aps-real-gam-` with the actual GitHub run id and 30-day retention. + Pass/fail comes from + exact request/DOM/lifecycle assertions. + +- [ ] **Step 5: Require a clean pass in Chromium, Firefox, and WebKit before cutover. If the GAM** + test network itself cannot support a browser, record that as a release blocker + instead of silently weakening the criterion. + +- [ ] **Step 6: Provide and verify the exact manual equivalent from the browser package:** + + ```bash + TS_REAL_GAM_PAGE_URL=... \ + TS_REAL_GAM_AUTH_HEADER=... \ + TS_REAL_GAM_EXPECTED_RELEASE_ID=... \ + npm --prefix crates/trusted-server-integration-tests/browser exec -- \ + playwright test --config=playwright.real-gam.config.ts \ + tests/shared/aps-real-gam.spec.ts \ + --project=chromium --project=firefox --project=webkit + ``` + +### Task 22: Delete final legacy surfaces and enforce absence + +**Files:** + +- Modify: `crates/trusted-server-js/lib/package.json` +- Modify: `crates/trusted-server-js/lib/eslint.config.js` +- Create: `crates/trusted-server-js/lib/scripts/check-architecture.mjs` +- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts` +- Modify: `crates/trusted-server-js/lib/src/core/index.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Delete: `crates/trusted-server-js/lib/src/core/context.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/aps/render.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/globals.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/didomi/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts` +- Modify: `crates/trusted-server-js/lib/build-prebid-external.mjs` +- Modify: `crates/trusted-server-js/lib/test/core/request.test.ts` +- Delete: `crates/trusted-server-js/lib/test/core/context.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/creative/helpers.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts` +- Modify: `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs` +- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- 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` +- Modify: `crates/trusted-server-core/src/integrations/sourcepoint.rs` +- 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-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-core/src/auth.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- 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-adapter-axum/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-spin/tests/routes.rs` +- Modify: `crates/trusted-server-integration-tests/tests/parity.rs` +- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts` +- Modify: `docs/guide/integrations/aps.md` +- Modify: `docs/guide/auction-orchestration.md` +- Modify: `docs/guide/configuration.md` +- Modify: `docs/guide/creative-processing.md` +- Modify: `docs/guide/integration-guide.md` +- Modify: `docs/guide/integrations/prebid.md` +- Modify: `docs/guide/integrations/didomi.md` + +- [ ] **Step 1: Add an absence test/search for:** + - `globalThis.tscreative`, `globalThis.tsCreativeConfig`, `installGuards`, + `setConfig`, and `getConfig`; + - legacy `window.__tsjs_*` runtime flags; + - callback/void `requestAds`, `renderAdUnit`, `renderAllAdUnits`, mutable generic + config, `TsjsApiV1`, and every old public declaration/alias; + - `tsjs.renders`, `renderLog`, `renderSeq`, `tsjs:adRendered`, + `tsjs.gptDiagnostics`, and old GPT diagnostics flags/expandos; + - `__tsRenderGeneration` and `__tsRenderBid`; + - `tsjs.apsPrebidRenderers`; + - the module-global core context-provider map and integration imports of + `registerContextProvider`/`collectContext`; + - integration-owned GPT/Prebid function sentinels; + - duplicate `Prebid Request` listeners and refresh wrappers; + - empty catches in migrated paths; + - `PAGE_BIDS_LEGACY_PATH`, `/__ts/page-bids`, and the JS retry/fallback marker; + - the unversioned `/integrations/aps/renderer` route; + - APS `pub_id` deserialization alias and its compatibility documentation; + - any APS runner asset, copied body, version/digest/metadata/license record, + updater/downloader, SRI/integrity attribute, generated runner artifact, offline + fallback, positive `/integrations/aps/runner/v1.js` route, or runner-cache + requirement; + - any enabled `TS_TEST_APS_V1`, integration-only upstream resolver, loopback + fixture address, Wrangler service binding, or Spin/Viceroy proxy-test manifest + reference in a production build/release artifact; + - every temporary architectural lint allowlist entry. + + For `window.__tsjs_*`, assert absence in all shipped JavaScript, including the pure + generated Prebid external artifact. For every former integration configuration + emitter/consumer, separately assert the exact immutable `tsjs.boot.*` replacement + in server output, integration consumers, fixtures, and current guides; deleting an + emitter without migrating its value is a test failure. + + Scope the executable search to shipped source, current guides, tests, scripts, + and workflows; exclude historical `docs/superpowers` designs/plans because this + work does not rewrite separate completed specifications. The enumerated file list + above is the current baseline hit inventory and must be updated if Task 0 finds + another in-scope hit. + +- [ ] **Step 2: Delete unreachable old paths, compatibility declarations,** + branches, and test-only production exports. Keep only `tsjs.que`, `tsjs.boot`, + the exact `TsjsApi` public surfaces, `_registerIntegration`, and the frozen + status-only `_internal` surface described by the spec. Do not expose the + service registry or a second integration-registration name. + +- [ ] **Step 3: Make `/__ts/page-bids`, the unversioned renderer path, unknown renderer versions,** + and `/integrations/aps/runner/v1.js` local unknown-route responses, never aliases. + Keep only `/_ts/page-bids`, static `/integrations/aps/renderer/v1`, and live + fixed-target proxy `/integrations/aps/runner.js`. Update route/parity tests and + APS/configuration guides to those hard-cutover surfaces. The absence test must + distinguish the required negative `runner/v1.js` assertion from a forbidden + positive handler. + +- [ ] **Step 4: Add an executable absence script to `package.json` and CI, build every** + integration combination used by server fixtures, and rerun all TS and adapter + route tests. + +### Task 23: Add deterministic bundle, browser-time, and retained-heap gates + +**Files:** + +- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs` +- Read: `crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json` +- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts` +- Modify: `.github/workflows/test.yml` +- Modify: `.github/workflows/integration-tests.yml` + +- [ ] **Step 1: Consume, but do not regenerate, the pre-change artifact captured in Task 0. Fail** + minimal/reference/maximal deterministic gzip or Brotli growth above 5% unless a + separate review explicitly updates the baseline. + +- [ ] **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. + +- [ ] **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 + WebKit remain correctness-only and do not emit synthetic heap equivalents. + +- [ ] **Step 4: Keep these gates separate from render correctness: performance cannot convert a** + failed conformance test to a pass. + +- [ ] **Step 5: Run the gate from a clean checkout through the same fixture-preparation script** + and write a separate measured artifact; never overwrite the Task 0 baseline: + + ```bash + TS_BROWSER_FRAMEWORKS=nextjs \ + TS_BROWSER_PROJECTS=chromium \ + TSJS_PERF_MODE=gate \ + TSJS_PERF_OUTPUT=crates/trusted-server-integration-tests/browser/test-results/tsjs-performance-current.json \ + ./scripts/integration-tests-browser.sh \ + tests/shared/tsjs-performance.spec.ts --project=chromium + node crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs + ``` + +### Task 24: Run final repository verification and assemble the cutover evidence + +**Files:** + +- Modify: `.github/workflows/test.yml` +- Modify: `.github/workflows/integration-tests.yml` +- Modify: `.github/workflows/aps-real-gam.yml` +- Do not create another plan/design/runbook; workflow artifacts are the evidence + +- [ ] **Step 1: Run formatting:** + + ```bash + cargo fmt --all -- --check + npm --prefix crates/trusted-server-js/lib run format + npm --prefix docs run format + ``` + +- [ ] **Step 2: Run Rust correctness and lint for every adapter:** + + ```bash + 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 + 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 + cargo clippy --manifest-path crates/trusted-server-integration-tests/Cargo.toml --all-targets -- -D warnings + ``` + +- [ ] **Step 3: Run TypeScript and bundle verification:** + + ```bash + npm --prefix crates/trusted-server-js/lib ci + npm --prefix crates/trusted-server-js/lib run typecheck + npm --prefix crates/trusted-server-js/lib run lint + npm --prefix crates/trusted-server-js/lib test + npm --prefix crates/trusted-server-js/lib run build + npm --prefix crates/trusted-server-js/lib run build:prebid-external + npm --prefix crates/trusted-server-js/lib run check:aps-contract + npm --prefix crates/trusted-server-js/lib run check:architecture + node --test crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs + node crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs + npm --prefix docs run lint + npm --prefix docs run build + ``` + +- [ ] **Step 4: Run three clean-checkout workflows for the exact release commit. Add** + `workflow_dispatch` with required `evidence_id` and `release_id` inputs to + `test.yml`; that workflow executes the complete format/typecheck/lint/Vitest/ + bundle/Rust-test/clippy matrix from steps 1–3 and uploads its command logs plus + validated release id. The integration workflow owns adapter startup/artifact + setup and executes the full integration suite plus focused + Chromium/Firefox/WebKit APS files. The protected real-GAM workflow runs for the + same ref/release id. + + Add manual `evidence_id`, `release_id`, and `previous_artifact_id` inputs where + relevant and include the evidence id in each workflow's `run-name`. After the + checked build, derive `RELEASE_ID` only from Task 8's validated generated + manifest. For the repository's Fastly production target, the authoritative prior + immutable artifact is the one active Fastly service version immediately before + dispatch; query it through the pinned Fastly CLI and require exactly one active + version. Pass both values explicitly; an empty/ambiguous value is a blocker. + Dispatch only a pushed branch verified to resolve to `RELEASE_SHA`, capture each + exact run id through the Task 0 dispatch helper, wait with `--exit-status`, and + verify every `headSha`, conclusion, and release-id attestation: + + ```bash + RELEASE_REF="$(git branch --show-current)" + RELEASE_SHA="$(git rev-parse HEAD)" + RELEASE_ID="$(npm --prefix crates/trusted-server-js/lib run --silent print:release-id)" + test -n "$RELEASE_REF" + test -n "$RELEASE_ID" + test -n "$FASTLY_SERVICE_ID" + PREVIOUS_FASTLY_VERSION="$(fastly service version list \ + --service-id "$FASTLY_SERVICE_ID" --json | \ + jq -er '[.[] | select(.active == true)] | if length == 1 then .[0].number else error("expected one active Fastly version") end')" + PREVIOUS_ARTIFACT_ID="fastly-service-version:$PREVIOUS_FASTLY_VERSION" + git fetch origin "$RELEASE_REF" + test "$RELEASE_SHA" = "$(git rev-parse "origin/$RELEASE_REF")" + test -n "$PREVIOUS_ARTIFACT_ID" + EVIDENCE_ID="aps-tsjs-cutover-$RELEASE_SHA" + QUALITY_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \ + test.yml "$RELEASE_REF" \ + evidence_id="$EVIDENCE_ID" \ + release_id="$RELEASE_ID")" + INTEGRATION_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \ + integration-tests.yml "$RELEASE_REF" \ + evidence_id="$EVIDENCE_ID" \ + release_id="$RELEASE_ID" \ + previous_artifact_id="$PREVIOUS_ARTIFACT_ID")" + REAL_GAM_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \ + aps-real-gam.yml "$RELEASE_REF" \ + evidence_id="$EVIDENCE_ID" \ + release_id="$RELEASE_ID" \ + previous_artifact_id="$PREVIOUS_ARTIFACT_ID")" + for RUN_ID in "$QUALITY_RUN_ID" "$INTEGRATION_RUN_ID" "$REAL_GAM_RUN_ID"; do + gh run watch "$RUN_ID" --exit-status + test "$RELEASE_SHA" = "$(gh run view "$RUN_ID" --json headSha --jq .headSha)" + test success = "$(gh run view "$RUN_ID" --json conclusion --jq .conclusion)" + done + ``` + + All three exact runs must conclude `success`; every evidence manifest must report + the same embedded release id and commit SHA, and integration plus real-GAM + artifacts must report the same prior artifact id. The dispatch helper or a + post-download manifest check verifies those fields against `RELEASE_ID`, + `RELEASE_SHA`, and `PREVIOUS_ARTIFACT_ID`; a run-name alone is not evidence. An + unavailable protected environment is a blocker. + +- [ ] **Step 5: Audit the final diff:** + - only planned source/test/build/runbook surfaces changed; + - no old/new compatibility path remains; + - no new external observability, persistence, billing, or experiment artifact; + - no descriptor/capability/account/creative payload is logged; + - no empty catch or unowned timer/listener/port/iframe in migrated paths; + - every one of the 144 pinned `rc/july` files maps through all 38 live rows to the + same 23 ledger ids, with no unmapped/dead/gap result; + - no integration-module preparation performs observable work, no activation yields, + and no post-fallback callback can revive the kernel; + - the public surface is `TsjsApi` only; numeric suffixes remain only on serialized + versioned boot/wire/artifact schemas; and + - no unrelated integration behavior was refactored. + +- [ ] **Step 6: Extend the workflows to upload `aps-tsjs-quality-`,** + `aps-tsjs-cutover-`, and `aps-real-gam-` artifacts, + substituting actual GitHub values. Include exact command logs, sanitized + Playwright reports/traces, route parity output, corpus/staleness output, bundle/ + performance reports, release id, commit SHA, run id, conclusion, and where + applicable the prior deployable artifact id. Run the Task 21 pre-upload scrub on + every browser artifact and fail if APS runner/creative bodies, secrets, + descriptors, or capabilities are present. GitHub Actions artifacts for those + three successful runs are the sole evidence location; do not create another + repository document. + +## Cutover procedure + +Use the existing deployment mechanism; this plan adds no router or experiment +infrastructure. + +1. Deploy to pre-production and rerun renderer-route, direct, PUC, refresh, SRA, and + SPA smoke tests. +2. Confirm the final artifact contains only the new runtime/API and that server and + TSJS bundles belong to the same ordinary release. +3. Hold an exclusive production deployment window from Task 24 evidence capture + through cutover. Immediately before deployment, re-query the active Fastly + version with the same exact command and require it to equal the attested + `PREVIOUS_FASTLY_VERSION`: + + ```bash + CURRENT_FASTLY_VERSION="$(fastly service version list \ + --service-id "$FASTLY_SERVICE_ID" --json | \ + jq -er '[.[] | select(.active == true)] | if length == 1 then .[0].number else error("expected one active Fastly version") end')" + test "$CURRENT_FASTLY_VERSION" = "$PREVIOUS_FASTLY_VERSION" + ``` + + Any mismatch blocks deployment and requires fresh evidence for the new prior + artifact; never roll back to a stale attestation. + +4. Perform one binary production cutover. Immediately verify: + - existing service availability/error/latency health is normal; + - APS renderer endpoint smoke passes; + - direct APS and one GAM/PUC APS smoke pass; + - no CSP/security console regression; + - no non-APS cache/ADM, native Prebid, refresh, SRA, or SPA regression. + +5. Hold before cutover if evidence is missing. After cutover, roll back the complete + artifact immediately on a TS code, request, CSP/security, or non-APS regression; + do not add percentage routing or dual-pool infrastructure. For Fastly, rollback + reactivates the exact attested prior immutable version: + + ```bash + fastly service version activate \ + --service-id "$FASTLY_SERVICE_ID" \ + --version "$PREVIOUS_FASTLY_VERSION" + ``` + +6. Treat a live-runner incident separately because binary rollback cannot restore + older APS-owned bytes. If the proxied runner is unavailable, incompatible, or + produces suspect completion behavior, disable `[integrations.aps]` using the + existing configuration mechanism. Verify that new APS bids are not admitted and + both reserved APS routes return local `404 no-store` without publisher fallback. + Keep APS disabled until the controlled Chromium/Firefox/WebKit real-browser + conformance gate passes again; do not vendor or pin a runner as containment. +7. Monitor existing operational signals for 24 hours and rerun the focused + real-browser smoke suite. +8. Confirm again that the deployed artifact contains no development selector or + compatibility path; Task 22 made this a pre-release absence gate. + +## Completion criteria + +The plan is complete only when: + +1. All five render flows pass exact hermetic and real-GAM lifecycle assertions, and + every created attempt settles exactly once under the mandatory race matrix. +2. Rust, TypeScript, embedded ES5, programmatic registration, cache expansion, and + DOM validation agree on the exact descriptor grammar and 1–4096 dimensions while + preserving invalid-versus-out-of-range reasons. +3. Initial and SPA projections enforce all grammar/count/UTF-8/8 MiB bounds + transactionally; SPA state lives only in `NavigationSession`, boot stays frozen, + and over-cap projections reduce all winners to `winner_not_renderable` without TS + projected bids or `/auction` seatbids. +4. Server reservations are the sole PUC authority, attempt/ticket/nonce issuers are + bounded as specified, and every live reservation/attempt retains the immutable + `WinnerContext` used for cache price expansion instead of response, targeting, or + current-projection data. +5. GPT physical-cycle ownership, exact handoff, two-success DOM reconciliation, + transactional destroy/redefine, and the owner-and-value targeting journal pass all + publisher-mutation and same-string generation races without a second physical + slot or blind clear. +6. PUC capture, owner-control, direct/remote iframe cleanup, accepted-artifact + promotion, and the 20-second owner watchdog obey their exact ownership boundaries; + native Prebid requests remain untouched. +7. One runtime owns all integration modules, sessions, slots, projections, auction- + context contributors, reservations, batches, targeting frames, timers, listeners, + observers, ports, and renderer iframes. Module preparation is inert, activation is + synchronous and reversible, commit is atomic, and post-commit work cannot expose a + partial kernel. +8. The kernel and fallback expose their exact `TsjsApi` own surfaces, semantic + version/release identity, immutable boot, actual-Array queue semantics, logger, + programmatic registration, Promise `requestAds`, and diagnostics presence. No + `TsjsApiV1` or placeholder/callback alias exists. +9. The pure external Prebid.js 10.26.0 artifact independently drains publisher work, + implements the exact frozen artifact stamp and duplicate/conflict rules, binds + `pbjs` plus stamp identity, and contains no TS auction, admission, render, + targeting, refresh, global flag, or TSJS release coupling. +10. Render trace and GPT diagnostics expose only the exact bounded frozen asynchronous + APIs; no correctness callback runs publisher diagnostics code, no legacy event or + alias remains, inactive GPT diagnostics has zero incremental side effects, and + creative guards auto-install from exact frozen boot data with both-false zero DOM + effects. +11. Every one of the 144 pinned `rc/july` files maps through 38 live mappings to all + 23 ledger ids, and the complete GPT, Prebid, APS, creative, diagnostics, shared- + helper, remaining-integration, and browser parity corpora pass alone and in the + maximal manifest. +12. Static renderer and live fixed-target runner-proxy status, exact headers, + bounded/deadline behavior, and fail-closed evidence parsing are proven through + all four actual adapter transports; no APS runner bytes/version/digest/license/ + SRI/updater/fallback/cache requirement exists in TS source or release evidence. +13. Legacy globals, expandos, sentinels, duplicate listeners/wrappers, mutable + creative/diagnostics surfaces, old routes, old declarations, and compatibility + shape detection are absent after the hard cutover. +14. Non-APS cache/ADM, native Prebid, publisher GPT, SRA, refresh, SPA, creative, + notification, and every other integration regression suite passes without an + unrelated feature rewrite. +15. Format, lint, typecheck, adoption/architecture/absence checks, all adapter tests + and clippy targets, Vitest, bundle/artifact builds, Playwright, size, browser-time, + and retained-heap gates pass in attested clean-checkout quality, integration, and + real-GAM runs for the exact release SHA and release id. +16. The binary cutover and 24-hour monitor complete or the exact prior immutable + artifact is restored cleanly; no runtime selector, percentage router, or dual + protocol is introduced. +17. The emergency APS-disable path stops admission and returns local `404 no-store` + for both reserved APS routes; binary rollback is never represented as restoring + mutable upstream runner bytes. +18. No analytics, persistence, billing, experimentation, deployment-routing, or new + external observability requirement was introduced. 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 78a31b9b7..d4cb142bc 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 @@ -1,1252 +1,3137 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 10 — closes the ninth review round (assignment-id - trusted path, control-build event parity, affinity lifetime, two-phase - bridge ack, missing-slot termination, materialized cycle denominator, - publisher-bound affinity token, admin route family, billing estimator - source, independent RC attestation) and re-inlines the alternatives and - risk registers so the document is genuinely self-contained. +- **Status:** revision 27 — hard-cutover contract with complete `rc/july` TSJS + adoption - **Date:** 2026-08-04 -- **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed - GAM shells"). All file:line citations refer to this commit. -- **Inputs:** three code audits; design reviews of revisions 1–8; open issues - #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997. -- **Normative gates:** Appendix A ships with this design; changes require - reviewed decision records. -- **Adoption stance for the baseline APS fixes (`248fe9558`):** contracts - adopted (MessageChannel handshake semantics, collapsed-shell remediation, - consolidated bridge branch), implementations rebuilt inside the target - architecture; the baseline browser tests pass unmodified as the - conformance pin. - -## 0. Release policy: coordinated hard cutover - -- One release: server, TSJS bundles, config, HTML under one **`release_id`**. - No N/N−1; in-flight clients may fail at cutover — accepted and stated. -- **Exact release matching**; mismatch is a refusal. -- **Config is a release-time, content-verified input.** `format_version` - exact-match. **`config_hash` = SHA-256 over the exact fetched envelope - bytes**, bound through compiled release metadata (or a signed manifest - with an embedded verification key); the binary verifies at startup and - fails loudly on mismatch. Publish order: blob, then manifest. Rollback = - redeploy the previous release with its own verified config; binding - prevalidated. -- Assets: embedded only; hashed pathnames for cache identity; unknown hash → - `410`, `no-store`. -- **Authenticated sticky affinity.** The router sets `ts-rel` on HTML - responses — format - `r1.......`: - `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp`; `phost` = normalized - publisher host; `release ^[a-z0-9._-]{1,40}$` from the allowlist; - `cohort ∈ {canary, control}`; `assignment_id` = 32-hex CSPRNG (the - pseudonymous **randomization unit**, §8); `sig` = unpadded base64url - HMAC-SHA-256 over the domain-separated length-prefixed input - `"ts-affinity-v1" || u32be(len)||field …` over **every** field - including `phost` (so a valid token replayed against another publisher - host fails); keys owned/rotated by the routing layer (active + previous, - retained ≥ the affinity lifetime + skew); constant-time verification; - test vectors checked in. Attributes `Secure; HttpOnly; SameSite=Lax; -Path=/`. Cache keys use the post-validation release label only. -- **Affinity lifetime covers the experiment.** `exp` TTL is set to the - **maximum experiment window** in force (default 14 days — the billing - gate's 7 d + 7 d extension), not 24 h, so a canary visitor never crosses - over to control mid-experiment. Renewal on any HTML response before - expiry re-signs the **same `assignment_id` and `cohort`** with a fresh - `exp` (a browser session's arm is fixed for the experiment). Expired - tokens are distinguished from forged ones (expired = valid sig, past - `exp`) and, during an active experiment, expired-but-valid tokens are - renewed to their original arm rather than reassigned. -- **assignment_id reaches telemetry only by a trusted server path.** The - cookie is `HttpOnly`; the client never reads it and never sends - `assignment_id`. The router **strips any inbound assignment/cohort - header**, validates `ts-rel`, and injects trusted internal metadata - (`X-TS-Cohort`, `X-TS-Assignment`, `X-TS-Release`) on the proxied - request; the client-events handler stamps those onto every row - server-side. Ingest rejects any client-supplied assignment/cohort field. - Tests: spoofed inbound header stripped; wrong-arm; null-rate; cookie - expiry mid-session; cross-pool. -- **State-dependent routing defaults:** during canary, valid tokens route by - their binding; invalid/forged → control + reissue; expired-but-valid → - renewed to their arm (above). **Forward cutover ("weight 100%") - explicitly retires the old release:** for HTML **and** non-HTML request - families, stale/invalid/old-release-bound tokens are reassigned/routed to - the active release — 100% means 100%. **Rollback symmetrically retires - the new release:** still-valid canary bindings are overridden on every - request family, not merely defaulted. -- **CSP-report affinity never depends on cookies:** the renderer is - sandboxed without `allow-same-origin` (`aps/render.ts:4`), so its - browser-generated reports are cross-origin to the publisher endpoint and - carry no cookie. Release/cohort identity is encoded in the - **server-generated report path's `policy_id`** (minted per - `{release, policy version, cohort}`, registered in the header manifest); - the router routes `/_ts/csp-reports/` by that registry. -- Beacon and trace-auth transports use `credentials: "same-origin"` so the - affinity cookie routes them; handlers derive no identity from cookies. -- **Canary/control measurement (closing the empty-control-arm gap):** the - control pool runs an **observation-only control build** — baseline render - _decisions_, but with **every measurement-only lifecycle hook the - Phase-3 gates read**. The control build implements a normative - **event-parity contract**: it emits `request_cycle_started`, - `attempt_started`, `bridge_request`, `bridge_response_sent`, - `renderer_document_loaded`/`adm_document_loaded`, and `render_terminal` - at the **same timing points** as the canary build, populated from - baseline behavior (e.g. its own `slotRenderEnded`, its own bridge - responses) — only the render-decision code differs, never the event - definitions or their emission points. Both arms therefore populate the - funnel and latency gates identically. Arms write to **arm-specific - datasources**; the union view stamps a trusted `deployment_pool` from the - write identity. The event-parity contract is itself gated by an **A/A - test** (canary-build vs canary-build) that must show zero metric - movement before any A/B canary begins. -- Router weight over sticky cohorts is the sole activation primitive; flags - are in-pool emergency kill switches. Cutover = weight 100% + CDN purge; - rollback = weight back + re-purge. The affinity acceptance test covers - HTML, assets, APIs, beacons, and CSP reports (via path identity). - -## 1. Problem statement - -APS demand is fully integrated server-side, yet APS creatives do not appear -reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim, -the `hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived -review; the pattern is the finding: **multiple independent failure points, -most failing silently**, with no client→server signal about which fired. -The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths, -duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches) -is the same problem structurally. - -### Non-goals - -- No change to the APS OpenRTB endpoint contract (including its deliberate - absence of `nurl`/`burl`, §G4d). -- No rewrite of the decoupled Prebid.js strategy. -- No backward compatibility (§0); replacement surfaces in §7.4. - -## 2. Why APS does not render — evidence - -Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer` -Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d) -renders an APS descriptor without GAM. - -### 2.1 Admission - -| # | Failure | Where | -| --- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | -| A1 | A configured `[auction].mediator` discards every direct-provider bid; APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` | -| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` | -| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` | -| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and `ts-debug` exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` | - -### 2.2 Identity - -| # | Failure | Where | -| --- | --------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| B1 | GAM caps key-values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge match, no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` | -| B2 | Two id universes: SSAT keys on the APS bid id; the client adapter on Prebid's `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` | - -### 2.3 Render - -| # | Failure | Where | -| --- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` | -| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` | -| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` | -| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` | -| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated. | `gpt/index.ts:1729` | -| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` | -| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` | - -### 2.4 Observability - -Zero client→server reporting; a bid that never painted is byte-identical -server-side to one that painted. - -### 2.5 Failure → signal mapping (normative) - -The **tester cookie** (non-security, `tester_cookie.rs:3`) gates **debug -content** (`tsjs.boot.debug`, response `ext.trusted_server.debug` — same -class as the existing `ts-debug` comment). The **diagnostic credential** -(§5.3) gates **telemetry volume**. Neither crosses into the other's role. - -| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface | -| ------- | ---------------------------------------------------- | ------------------------------------- | ------------------------------- | -| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary | -| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary | -| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary | -| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` | -| B1/B2 | `bridge_request{matched:false}` (§6.8) | join via trace | console warn | -| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn | -| C2 | `render_terminal{failed, renderer_document_no_load}` | `ts_ops_counters` | console warn | -| C3 | `render_terminal{failed, bridge_id_mismatch}` | join via trace | console warn | -| C4 | `render_terminal{failed, descriptor_invalid}` | schema corpus CI | console warn | -| C5 | — (fixed at baseline) | — | — | -| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn | -| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn | - -## 3. The GPT and baseline reality - -1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead - in production. -2. #922 merge loss (`0dc9b19a9`); PR #997 is the apparent replacement. -3. TS refreshes never pass `changeCorrelator: false`. -4. `enableSingleRequest()` called blind after publisher `enableServices()`. -5. Responsive-resolution ambiguity silently skips slots. -6. Three independent `pubads().refresh` wrappers. -7. GPT has no cancellation, no per-refresh identity, no completion-order - guarantee; `slotRenderEnded` = code injected; `responseIdentifier` - identifies responses only. -8. `display()` under disabled initial load creates no request — for any - caller (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`). -9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled` - (`gpt/index.ts:1091`). -10. Baseline `248fe9558`: MessageChannel APS-PUC handshake - (`aps.rs:65-125`, `aps/render.ts:415-437`), collapsed-shell resize - (`gpt/index.ts:217`), C5 consolidated, real-PUC browser test. -11. The bridge keeps consumed-id tombstones (`gpt/index.ts:1527`). -12. The tester cookie is not a security control (`tester_cookie.rs:3`). -13. Fastly constructs application state per request (`app.rs:146`); its - platform counter is a 60 s window with separate lookup/increment - (`rate_limiter.rs:40`). -14. The baseline auction client collapses every failure into an empty - array (`core/auction.ts:185-224`). -15. A single `/auction` request can carry several slots; concurrent calls - race today (`request.ts:31`) and share one fetch. - -## 4. Design gates - -### G1 — Trace identity, attempts, sampling, correlation - -- EC-derived auction ids (`publisher.rs:3237`) are never ingested. - Initial-HTML telemetry precedes page JS (`telemetry.rs:148`, - `publisher.rs:2452`); correlation is minted by whoever acts first: the - server for `nav_gen 0` (trace + signed authorization in `tsjs.boot`); - the client afterwards via `X-TSJS-Trace-Id` on page-bids (GET) and the - `/auction` POST, echoed back with - `ext.trusted_server.trace = {trace_id, auth, auction_id}`. -- **Attempt identity (closing the parent/child collision):** every render - attempt mints a client-side **`attempt_id`** (8-char `[a-z0-9]`, - CSPRNG) and carries nullable **`parent_attempt_id`** (fallback children - reference their parent). **The canonical attempt key is - `(publisher_domain, trace_id, attempt_id)`** — `attempt_id` alone (≈ 2.8 - T values) is not globally unique at production volume, so - `ts_render_attempts_v` keys and parent lookups use the full triple. - Uniqueness within a trace is guaranteed by a **per-trace live-set - collision check with retry** (the `NavigationSession` holds the issued - set; a collision re-draws). The tuple - `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key only**. - Exactly one terminal event per attempt key (G4c) is a tested invariant. -- **Deterministic keyed sampling:** first 8 bytes of - `HMAC-SHA-256(sampling_key, trace_id)` as u64 BE; `sampled` iff - `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` finite in `[0, 1]`. -- **Cross-tier join:** equality key = the server telemetry - **`auction_id`** (UUID; the client column is `Nullable(UUID)` and - ingest validates canonical UUID syntax). Generations are client-side - only. **Join grains are explicit, and the slot grain goes through a - dedicated summary view.** `auction_events_raw` has multiple - `row_kind=slot` rows per slot (bid, provider, drop, selection), so a - `auction_id + slot` join multiplies rows; funnels therefore join against - **`ts_auction_slot_summary_v` — one row per `(auction_id, -canonical_slot)`** built from the `selection_summary` slot rows. - Auction-level joins hit the one totals/summary row per `auction_id`; - bid/drop/provider joins carry their real grain and are opt-in. **Client - `slot` and server `slot_id` use the same canonical normalization** - (lowercased, `-container` suffix stripped — the resolution rule of - `gpt/index.ts:112-138`), applied on both sides and tested. -- Cache-privacy invariant: traces/authorizations only in per-request - auction-bearing responses; such HTML is `private, no-store`. -- Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry - `{nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?, -auction_id?, t_rel_ms?}`. **`t_rel_ms`** (time, relative, in - milliseconds) is a bounded monotonic duration (`performance.now()` - truncated to u32 ms, relative to - navigation start) — the latency gates' basis; `received_at` is ingest - time and is never used as event time. `flow` is closed: - `ssat | prebid | page_bids | direct | fallback | system`. -- Traces are navigation-scoped; attempt counts key on `attempt_id`. - -### G2 — Render identity - -- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte - (`publisher.rs:3355`, `gpt/index.ts:1772`). Markup bids: existing - fallback chain. Renderer-only bids: server-minted token - `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min, - one-time consumption. -- **Reservation store:** live registrations + tombstones (consumed / - stale / disposed) share one structure, **union capacity 320**; expired - entries pruned; **unexpired entries never evicted**; at capacity, new - registration refused with `registry_full`. Late prior-navigation - requests always meet suppression until original TTL (preserving - `gpt/index.ts:1527`). Test: >320 registrations, late oldest-id request. -- Client-Prebid keeps Prebid's `adId`; one store serves both paths. - Non-APS cache-path byte-identity regression tests. - -### G3 — Runtime ABI (exact-release) - -- IIFE-per-bundle with inlined imports (`build-all.mjs:46`, - `bundle.rs:23`); imports never share state (defect: - `core/context.ts:11` vs `permutive/index.ts:102`). Kernel only in - `tsjs-core`; `tsjs._internal = {release_id, registry}` frozen after - boot; core services constructed at boot; plugins via - `definePlugin({id, release, install})` with build-generated `release`; - `registry.get` succeeds only on equality; mismatch quarantines - (`abi_mismatch`/`bundle_partial`) loudly. -- **Boundary enforcement:** `import/no-restricted-paths` for layering, - plus a **custom scope-aware ESLint rule** for external-global access — - standard `no-restricted-properties`/`no-restricted-syntax` cannot - follow arbitrary aliasing, so the custom rule tracks member access to - `googletag`/`pbjs` through `window`/`globalThis`/`self` **and - same-file const aliases**; anything cleverer (cross-module smuggling) - is caught by review, and the claim is scoped to exactly that. Adapters - are the only access to external ad-tech globals; kernel/messaging - necessarily touch `window.tsjs` and `postMessage`. - -### G4 — Render lifecycle - -**G4a — Physical request cycles.** Intents (both classes, one causal -queue): any `display()` under disabled initial load is retired at -issuance regardless of caller; hindsight zero-request intents expire at -2 s with `intent_no_request`; **any later request-capable intent — same -class or opposite — supersedes a pending uncertain intent**, and if the -uncertain one could still be in flight, the next `slotRequested` is -ambiguous → quarantine. Cycles open only on `slotRequested` (causal -head; SRA = one per slot per batch) and close on `slotRenderEnded` -(`responseIdentifier` dedups drain). **Each attributable `slotRequested` -emits exactly one `request_cycle_started` event** — the materialized -denominator the fill and attribution gates divide by (`attempt_started` -is not equivalent: an attempt can fail before producing a physical -request). One outstanding TS cycle per slot; one queued replacement. -Attribution requires exactly one outstanding TS cycle and no overlap; -otherwise `cycle_unattributable`, fail closed. **No timeout re-arm** — -re-arm only on count-based drain, safe TS-owned destroy/redefine, or page -end; unissued intents are NavigationSession children; physical state is -RuntimeSession. Deterministic-harness CI + -the release-gating real-GAM suite (Appendix A.3). - -**G4b — Two-phase acknowledgement (the nonce is minted by the claim, not -carried in the request).** The baseline PUC request is `{message, adId}` - -- a `MessagePort` — it cannot carry a nonce or generations, and the nonce - can only exist **after** a claim succeeds. So the protocol is two phases: - -* **Phase 1 — claim (on the incoming `Prebid Request`):** validate source - ownership (§6.8), the reserved `adId` against the reservation store, and - the internal current `nav_gen`/`refresh_gen` **held in the kernel** - (not in the message); on success, **mint the per-attempt 128-bit CSPRNG - nonce**, bind notifications, reply over the `MessagePort` (the renderer - depends on this reply to settle its PUC promise), and — for the APS - path — hand the nonce to the renderer document in the response. -* **Phase 2 — acknowledgement (later document/runner messages):** validate - source, nonce, token, `nav_gen`, `refresh_gen`. Navigation/supersession - invalidates the nonce; late acks → `stale_navigation`. Deadlines: - document 3 s, runner 10 s, adm 5 s. - -Per render path: - -1. **APS-PUC** (baseline transport): the Phase-1 reply travels over the - `MessagePort`; the response also transfers the freshly minted nonce - into the renderer document (`ports.length` checks, exact-key replies, - one-shot latch, port close); the document then posts authenticated - `renderer_document_loaded` and the accepted/failed result to the top - window (Phase 2). -2. **Generic ADM/cache-PUC:** **the acceptance observation lives in the - trusted owner, not the creative document** — the owner observes its - own iframe's `load`/`error` events and emits `adm_document_loaded`; - the nonce never enters the bidder realm (an injected reporter would - hand bidder-controlled code the acceptance credential and let it - trigger `burl` early — revision 8's reporter is withdrawn). -3. **Direct APS:** the kernel is the frame parent; the baseline - parent-postMessage branch is already kernel-observed. -4. **Direct ADM/cache:** as (2), owner-observed `load`/`error`. - -**G4c — Honest observations; one terminal event.** Observations: -`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded}`, -`renderer_document_loaded`, `runner_loaded`, `runner_failed`, -`adm_document_loaded`. **The terminal is one discriminated event: -`render_terminal{outcome: accepted | failed | no_bid | cancelled, -reason?}` — exactly one per `attempt_id`** (replacing separate -accepted/fail events the schema could not reconcile). A parent attempt -whose GAM cycle ends empty emits `render_terminal{failed, gam_empty}` -**before** its fallback child starts. Post-acceptance runner failure is -an observation only (no billing-failure event exists — no honest -producer). No observation claims paint. The baseline resize -(`gpt/index.ts:217`) stays a sanctioned, guarded exception. - -**G4d — Notifications.** APS carries neither `nurl` nor `burl` -(`aps.rs:839`) — excluded entirely. For carrying paths: bind per flow -(PUC: owned matched bridge claim; direct: validated render start — -server must preserve + macro-expand the URLs (`formats.rs:423` omits), -client must parse + https-validate (`core/auction.ts:43` drops); -fallback: attributed parent `gam_empty` immediately before child -render). `nurl` at bind; `burl` at `accepted`; idempotency key -`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. -**Dispatch mechanics (normative):** macros are expanded server-side -only; the client fires exactly one -`fetch(url, {method: "GET", mode: "no-cors", credentials: "omit", -redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})` -and awaits its settlement. **There is no `Image()` fallback** — a -detached image request would construct a separate credentialed, -referrer-bearing request outside this privacy contract; a request that -rejects (construction error) or resolves to a network failure emits -`notification_sent{result: failed}` and stops. No retries. Every -dispatch emits -`notification_sent{kind, notif_id, result: queued | failed}` with the -**server-minted `notif_id`** (12-char token delivered with the bid). -Duplicates: hermetic exactly-once proof + production detection alarm + -billing reconciliation (lossy telemetry cannot prove a zero). - -**G4e — Fallback.** Opt-in; child attempt (own `attempt_id`, -`parent_attempt_id`, `flow = fallback`); renders only after the parent's -attributed `render_terminal{failed, gam_empty}`; publisher-initiated or -unattributable never triggers; timeouts never render. - -**G4f — Direct `/auction` lifecycle and the AuctionBatch.** A single -`/auction` fetch may serve several slots, so cancellation is -batch-aware: an **`AuctionBatch`** owns the fetch (its `AbortController`) -and the child `RenderAttempt`s. Supersession cancels **children -individually** (`render_terminal{cancelled}`); the fetch aborts only -when every child is dead or the batch times out or its navigation -disposes; every response bid is filtered through the **currently live** -child identity before any effect. Tests: partial overlap, full overlap, -timeout, navigation disposal, reversed responses. The auction client -returns a discriminated result — `{ok: bids[]} | {error: -"auction_timeout" | "network_error" | "http_error" | "invalid_response"}` -(§3.14); only a parsed empty response is `no_bid`. **After the batch -processes every response bid, each still-live child with no valid -matching winner is terminated `render_terminal{no_bid}`** — a response -carrying only slot X never leaves slot Y's child nonterminal, so -`RequestAdsResult` always settles. Public API: -`tsjs.requestAds(options): Promise`, -`RequestAdsResult = {traceId, slots: [{slot, outcome, reason?}]}`, -settling when every child attempt is terminal. - -**G4g — Mid-attempt configuration, honestly scoped.** Attempts snapshot -configuration at creation. **Commit = the earliest irreversible action** -(first of: notification dispatch, `bridge_response_sent`, first DOM -insertion), with the generation/kill check immediately before each. -**The kill switch's delivery is page-bound:** already-loaded pages have -no push channel, so live switch state travels only on responses the -page later fetches — page-bids and `/auction` responses carry -`ext.trusted_server.switches`, and new HTML carries current state. The -guarantee is therefore scoped: the switch affects attempts created -after the page received switch state; SSAT attempts on already-loaded -pages are unaffected by design, and the spec says so rather than -implying a live channel that does not exist. - -### G5 — Deployment contracts - -- Config verification per §0. Assets pre-materialized at release - publication (config is a release-time input); serving is lookup-only; - unknown vector = build error; unknown hash = `410 no-store`; exact - match = `public, max-age=31536000, immutable`. -- **Internal route families — five** (renderer, client-events, - CSP-report, `/_ts/trace-auth`, and the **admin issuance family** - `/_ts/admin/*` — diagnostic-credential and probe-authorization, §5.3): - all dispatch before auth/EC/publisher/integration filters (`app.rs:709` - orders these wrong today); all methods + version prefixes reserved - locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`; no - publisher fall-through, `adapter-spin app.rs:804`); no - body/cookie/authorization forwarding to the publisher. **Per-family - origin/auth policy:** client-events + trace-auth strict normalized - same-origin; CSP admits opaque/`null` origins with path identity + - limits; renderer is a public GET validated by version/path; **the admin - family sits behind operator authentication with its own rate limits and - exists on all adapters that expose an admin plane** (elsewhere it is - absent, and diagnostic/probe modes are unavailable there — stated, not - implied). All five families appear in the four-adapter parity matrix. -- Ingest routes in all four adapters; Fastly has real sinks; others - accept-count-drop (DR-5). §5.6 schemas deploy before writers. - -## 5. Observability - -### 5.1 Wire payload and field matrix +- **Baseline:** `origin/rc/july` @ `905984e62` ("Prevent APS renderer document + clipping"), including `248fe9558` (PUC/MessageChannel and collapsed-shell + rendering) and `ed38f3e13` (PUC overflow prevention). File-and-line references + and commit hashes describe that baseline and are evidence, not permanent API + contracts. +- **Compatibility:** this is a coordinated hard cutover. No backward-compatible + aliases, dual APIs, or N/N-1 browser/server protocol are required. +- **Decision:** this document covers APS render correctness, the TSJS architecture + needed to make that correctness durable, and preservation or explicit + architectural replacement of every TSJS concept present on the baseline. It + does not add an external telemetry system or release experimentation. + +## 0. Scope and constraints + +### 0.1 Goals + +1. An accepted APS bid renders through every supported Trusted Server path: + SSAT/GPT, the Trusted Server Prebid adapter through GAM Universal Creative, + SPA page bids, and direct `/auction` rendering. +2. Render ownership, identity, and completion are explicit. Races, stale SPA + work, ambiguous GPT events, duplicate creative requests, and timeouts settle + deterministically instead of failing silently. +3. TSJS has one runtime kernel, bounded state, explicit lifetimes, and enforced + dependency boundaries. Integration bundles cannot accidentally create + independent copies of shared state. +4. The Rust auction result, publisher projection, TypeScript parser, Prebid + registration, GPT targeting, Universal Creative bridge, and direct renderer + agree on one APS descriptor and identity contract. +5. Security boundaries are testable: untrusted creative messages cannot claim a + different slot or attempt, replay a consumed capability, or revive work from a + prior navigation. +6. Existing non-APS rendering behavior remains correct unless this design + explicitly replaces a shared lifecycle surface. +7. Every TSJS behavior on the exact `rc/july` baseline is either preserved, + rebuilt behind the new architecture, or explicitly superseded by a named and + tested replacement contract. No TSJS behavior may disappear silently merely + because its old global, wrapper, bootstrap, or carrier is deleted. + +### 0.2 Non-goals + +- No change to analytics/telemetry schemas, durable data systems, billing, + experimentation, or deployment routing. Those belong to separate designs. +- No change to the APS upstream OpenRTB endpoint contract, including APS's + deliberate absence of `nurl` and `burl`. +- No rewrite of Prebid.js itself or of the decoupled Prebid strategy. +- No refactor of unrelated integration internals. They receive only the thin + registration/bootstrap changes required by the new TSJS runtime, plus any + mechanical disposal or adapter injection needed to preserve their baseline + behavior. + +Existing local render tracing, GPT diagnostics, logging, counters, debug output, +and telemetry integrations remain functional through the cutover. They may move +behind the runtime event bus or the final diagnostics namespace, but their +observable concepts and non-interference guarantees are in scope. Correctness must +not depend on a new event reaching an external sink. Any new analytics contract +requires a separate design. + +### 0.3 Architectural rules + +- Make invalid states unrepresentable where practical and reject them at the + boundary otherwise. +- Every asynchronous operation belongs to a runtime, navigation, auction batch, + or render-attempt lifetime and has a deterministic disposer. +- Every render attempt reaches exactly one terminal result in memory. +- A timeout cancels or fails only the object it owns; shared work is aborted only + when no live child still needs it. +- Ad-tech globals are accessed only through adapters. +- Cross-window messages are versioned, exact-shaped, capability-bound, and + source/port checked. +- No correctness path waits for logging, telemetry, notification delivery, or any + other side effect unrelated to rendering. + +### 0.4 `rc/july` TSJS concept-adoption contract + +The exact baseline is the whole observable TSJS system at +`origin/rc/july@905984e62`, not only the three commits after this design branch's +merge base. It includes `crates/trusted-server-js/lib/src/**`, its build and test +tooling, the TSJS behavior embedded in `gpt_bootstrap.js` and +`gpt_diagnostics_bootstrap.js`, and the browser tests that exercise those sources. +The in-spec executable manifest in §0.5 records that tree and commit. If the local +`origin/rc/july` ref moves, implementation stops before code +changes, diffs the old and new tips across those paths, and updates this ledger and +its tests deliberately. A moving branch is never absorbed implicitly. + +Each ledger entry has one of these dispositions: + +- **Preserve:** retain the observable behavior and its failure semantics. +- **Rebuild:** retain the outcome but replace the old mechanism with the runtime, + adapter, service, or integration module named here. +- **Supersede:** deliberately replace an old mechanism with a stricter named + contract. The ledger must state the behavioral change and prove either that the + new owner makes the old compensation unnecessary or that the new terminal + failure is complete, bounded, and preferable to a partial second runtime. +- **Exclude:** keep the existing feature untouched because it is not TSJS work; + this disposition cannot be used for a file under the TSJS baseline inventory. + +Hard cutover authorizes removal of old mechanisms and names. It does not authorize +silent loss of a ledger outcome. An observable outcome may change only through an +explicit **Supersede** entry that names the old and final behavior, gives the +architectural reason, and has boundary tests for the replacement contract. A source +deletion is complete only when its ledger entry has a passing replacement contract +or that explicit supersession proof. + +| ID | Baseline TSJS concept | Disposition and final owner | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RCJ-CORE-01` | Core config/context, callback queue, auction parsing, direct request/rendering, SPA generation checks, and shared helpers continue to serve every enabled integration. | **Rebuild:** kernel, services, and composition root; exact behavioral corpus runs before and after the switch. | +| `RCJ-CORE-02` | Programmatic ad-unit registration can drive direct `/auction`; core also exposes version/queue, placeholder render helpers, mutable generic config, and the local logger. | **Preserve/supersede:** §5.4 defines the exact final API; typed registration/request APIs and immutable config replace placeholders/mutable config; logger methods/default remain, while invalid levels now throw without mutation instead of being retained with warn fallback. | +| `RCJ-BOOT-01` | The edge-injected `gpt_bootstrap.js` duplicates initial-load tracking, slot handoff, hydration scheduling, GPT definition/targeting/display/refresh, and can render initial ads without the main TSJS bundle. | **Rebuild/supersede:** the committed runtime owns all normal GPT behavior. Missing/partial bundles intentionally settle through the terminal non-rendering fallback in §5.3; no bootstrap may construct a degraded GPT runtime. | +| `RCJ-TRACE-01` | Render tracing records one honest impression timeline, bounded history, current-slot state, DOM stamps/badges, local overlay, no stale auction attribution, and emits `tsjs:adRendered`. | **Rebuild/supersede:** lifecycle diagnostics subscriber and `tsjs.diagnostics.renderTrace`; its subscription replaces the mutable globals and CustomEvent, and no integration writes trace state directly. | +| `RCJ-GPT-01` | A TS fallback and a later publisher `defineSlot` share one physical GPT slot and one initial request; ownership transfer prevents later TS destruction. | **Rebuild:** GPT adapter plus slot service handoff record; no integration-owned function sentinels or duplicate wrappers. | +| `RCJ-GPT-02` | Responsive/hydrated slot resolution chooses the unique active placement, recovers DOM replacement, and never silently chooses an ambiguous sibling. | **Rebuild:** navigation-scoped aliases plus runtime-owned DOM binding/reconciliation. | +| `RCJ-GPT-03` | Native publisher GPT calls, service state, SRA, disabled initial load, refresh options, targeting cleanup, and publisher-owned slots retain their native semantics. | **Preserve/Rebuild:** the sole GPT adapter owns interception and event fan-out; publisher activity never becomes TS-owned work. | +| `RCJ-GPT-04` | A TS-owned PUC response may resize only its authenticated still-collapsed ordinary 1×1 GAM shell, never unrelated, anchor, fixed, sticky, or already-expanded frames. | **Preserve/Rebuild:** current render attempt owns one guarded resize after a response is successfully posted. | +| `RCJ-PREBID-01` | The publisher-specific artifact is pure Prebid.js; the Trusted Server shim is a separate TSJS integration module, and the external bundle remains independently useful if that module fails. | **Preserve/Rebuild:** external artifact plus Prebid adapter/integration module; TS code is not vendored into the external Prebid artifact. | +| `RCJ-PREBID-02` | Missing, late, duplicate, older, or partial Prebid artifacts fail safely: publisher queues drain, TS refresh handling is not installed without a real API, and installation is idempotent. | **Preserve/Rebuild:** artifact watchdog plus release-matched module transaction and bounded readiness queue. | +| `RCJ-PREBID-03` | Adapter manifests distinguish module names from registered bidder codes/aliases; client-side bidder coverage, user-ID modules, EIDs, native bids, and publisher callbacks keep working. | **Preserve:** typed artifact contract and black-box artifact tests; TS-owned bid identities alone are replaced. | +| `RCJ-PREBID-04` | Configured GAM-path exclusions remove only matching slots from the synthetic Prebid refresh auction while clearing stale TS keys and retaining every slot/options in the GPT refresh. | **Preserve/Rebuild:** one refresh policy in the Prebid integration module over the GPT adapter; global, explicit, mixed, all-excluded, and fail-open path cases remain exact. | +| `RCJ-APS-01` | First-class APS OpenRTB admission, typed descriptor projection, direct rendering, Trusted Server Prebid-adapter rendering, and PUC rendering remain supported. | **Preserve/Rebuild:** Rust admission plus the shared render lifecycle described in §§3–4. | +| `RCJ-APS-02` | `bid.meta`, generated Prebid `adId`, upstream bid-id fallback, and old `hb_adid` precedence carried APS identity through lossy boundaries. | **Supersede:** the server-minted `r1_` reservation is the only TS PUC authority; native Prebid IDs and PBS Cache UUIDs remain byte-preserved for their own purposes. | +| `RCJ-APS-03` | PUC uses one-use ports, APS callbacks—not script load—determine success, renderer tombstones are bounded, and lifecycle callbacks cannot corrupt later attempts. | **Preserve/Rebuild:** bridge dispatcher, owner-control channel, reservation service, and terminal latch. | +| `RCJ-APS-04` | The PUC document, renderer document, and descendant creative receive the winning dimensions without default margins, scrollbars, overflow, or clipping. | **Preserve:** exact CSS/DOM sizing contract in §4.4 and three-level browser assertions. | +| `RCJ-CREATIVE-01` | Auction creative sanitization remains opt-in/default-off, rewriting retains its existing independent setting, and every delivery path observes the same configured processing boundary. | **Preserve:** creative integration module and server processing; this design does not silently enable sanitization or broaden rewriting. | +| `RCJ-CREATIVE-02` | Opaque-origin click recovery accepts only validated absolute HTTP(S) navigation, persists the validated URL, rejects non-network schemes, and keeps creative sandbox isolation. | **Preserve/Rebuild:** creative integration module over shared origin/DOM helpers, with unit and real-browser sandbox coverage. | +| `RCJ-CREATIVE-03` | `tscreative.installGuards/setConfig/getConfig`, `tsCreativeConfig`, automatic install, click-guard default-on, and render-guard default-off control the creative browser guards. | **Preserve/supersede:** `CreativeBootV1` retains the defaults and the integration module auto-installs transactionally; mutable/install command globals are deleted and immutable `tsjs.boot.creative` is the only inspection/config surface. | +| `RCJ-DIAG-01` | GPT runtime diagnostics reports raw GPT observations, exact slot binding/replacement, request cycles/timing, bounded export, overlay/badges, and no lifecycle interference. | **Preserve/Rebuild:** diagnostics integration module consumes the GPT adapter event stream and exposes `tsjs.diagnostics.gpt`; it never installs a second GPT control wrapper. | +| `RCJ-INT-01` | DataDome, Didomi, Google Tag Manager, Lockr, Osano, Permutive, Sourcepoint, and Testlight retain their current proxy guards, configuration, consent/segment, queue, and timing behavior. | **Preserve:** thin transactional integration modules plus complete pre/post-cutover black-box suites; internal feature behavior is otherwise unchanged. | +| `RCJ-INT-02` | Shared script, beacon, DOM-insertion, scheduling, origin, and async helpers retain per-integration matching and failure isolation. | **Rebuild where shared:** helper factories with integration-owned configuration; one module failure cannot unwind another integration module or publisher code. | +| `RCJ-QUAL-01` | Lint covers production source, tests, scripts, diagnostics, and build code; TypeScript and artifact checks cover the actual shipped combinations. | **Preserve/strengthen:** full-package lint/typecheck plus architecture, maximal-bundle, generated-artifact, browser, and retained-heap gates. | + +The commit clusters that exposed these concepts include the render-trace series +starting at `966c8569c`; GPT recovery/handoff/responsive/native-behavior commits +`4f45974e5`, `9b1985c8b`, `340d1efb4`, `0fdd13e7d`, `ca678fe69`, and +`b200be53c`; Prebid decoupling/resilience and refresh commits `001ad385c`, +`cdff89706`, `f3dc6ba70`, `60a85e661`, and `a007bd0d0`; creative hardening +commits `1929dc83a`, `fde835110`, `9b21ba450`, `20977105f`, `1db074d4b`, and +`3d9e2b693`; GPT diagnostics `11a4a7d25`; full-package lint `941473407`; APS +admission/rendering commits from `f916ddf90` through `a08bebfbd`; and the final +PUC/sizing chain `248fe9558`, `ed38f3e13`, `905984e62`. The executable tree +inventory, not this illustrative hash list, is the completeness authority. + +### 0.5 In-spec baseline mapping manifest + +To keep this a one-file design, the completeness manifest is embedded here instead +of creating another repository artifact. The implementation's first contract test +extracts the `rcjuly-tsjs-manifest-v1` JSON block, enumerates each directory +`includeRoot` plus every exact mapped file at the pinned commit with `git ls-tree`, +and requires every enumerated file to match at least one `exact`, `prefix`, or +`prefixes` mapping. A path receives the union of every matching row. Every +file under `lib/src` must receive at least one non-`RCJ-QUAL-01` id. A mapping that +matches no pinned path also fails, preventing stale rows. Moving the baseline runs +this check before implementation and requires an explicit manifest/ledger update for +every new, removed, or renamed path. + +```json rcjuly-tsjs-manifest-v1 +{ + "version": 1, + "baseline": "905984e62a0858c53d9f0ff6dd3a1bf190cf311d", + "includeRoots": [ + "crates/trusted-server-js/lib", + "crates/trusted-server-integration-tests/browser/tests" + ], + "mappings": [ + { + "exact": [ + "crates/trusted-server-js/lib/.gitignore", + "crates/trusted-server-js/lib/.prettierignore", + "crates/trusted-server-js/lib/.prettierrc.json", + "crates/trusted-server-js/lib/eslint.config.js", + "crates/trusted-server-js/lib/package-lock.json", + "crates/trusted-server-js/lib/package.json", + "crates/trusted-server-js/lib/tsconfig.json", + "crates/trusted-server-js/lib/vite.config.ts", + "crates/trusted-server-js/lib/vitest.config.ts" + ], + "ids": ["RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/build-all.mjs", + "crates/trusted-server-js/lib/src/index.ts" + ], + "ids": ["RCJ-CORE-01", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/build-prebid-external.mjs", + "crates/trusted-server-js/lib/test/build-prebid-external.test.mjs", + "crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs" + ], + "ids": ["RCJ-PREBID-01", "RCJ-PREBID-02", "RCJ-PREBID-03", "RCJ-QUAL-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/core/", + "ids": ["RCJ-CORE-01"] + }, + { + "exact": ["crates/trusted-server-js/lib/src/core/log.ts"], + "ids": ["RCJ-CORE-02"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/src/core/config.ts", + "crates/trusted-server-js/lib/src/core/index.ts", + "crates/trusted-server-js/lib/src/core/registry.ts", + "crates/trusted-server-js/lib/src/core/request.ts" + ], + "ids": ["RCJ-CORE-02"] + }, + { + "exact": ["crates/trusted-server-js/lib/src/core/trace.ts"], + "ids": ["RCJ-TRACE-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/shared/", + "ids": ["RCJ-CORE-01", "RCJ-INT-02"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/aps/", + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/creative/", + "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/gpt/", + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-GPT-04"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/", + "ids": ["RCJ-DIAG-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/src/integrations/prebid/", + "ids": [ + "RCJ-PREBID-01", + "RCJ-PREBID-02", + "RCJ-PREBID-03", + "RCJ-PREBID-04" + ] + }, + { + "prefixes": [ + "crates/trusted-server-js/lib/src/integrations/datadome/", + "crates/trusted-server-js/lib/src/integrations/didomi/", + "crates/trusted-server-js/lib/src/integrations/google_tag_manager/", + "crates/trusted-server-js/lib/src/integrations/lockr/", + "crates/trusted-server-js/lib/src/integrations/osano/", + "crates/trusted-server-js/lib/src/integrations/permutive/", + "crates/trusted-server-js/lib/src/integrations/sourcepoint/", + "crates/trusted-server-js/lib/src/integrations/testlight/" + ], + "ids": ["RCJ-INT-01", "RCJ-INT-02"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/core/", + "ids": ["RCJ-CORE-01", "RCJ-CORE-02", "RCJ-QUAL-01"] + }, + { + "exact": ["crates/trusted-server-js/lib/test/core/trace.test.ts"], + "ids": ["RCJ-TRACE-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/shared/", + "ids": ["RCJ-INT-02", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json" + ], + "ids": ["RCJ-APS-01", "RCJ-APS-03", "RCJ-QUAL-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/aps/", + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/creative/", + "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/gpt/", + "ids": [ + "RCJ-BOOT-01", + "RCJ-GPT-01", + "RCJ-GPT-02", + "RCJ-GPT-03", + "RCJ-GPT-04" + ] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/", + "ids": ["RCJ-DIAG-01"] + }, + { + "prefix": "crates/trusted-server-js/lib/test/integrations/prebid/", + "ids": [ + "RCJ-PREBID-01", + "RCJ-PREBID-02", + "RCJ-PREBID-03", + "RCJ-PREBID-04" + ] + }, + { + "prefixes": [ + "crates/trusted-server-js/lib/test/integrations/datadome/", + "crates/trusted-server-js/lib/test/integrations/didomi/", + "crates/trusted-server-js/lib/test/integrations/google_tag_manager/", + "crates/trusted-server-js/lib/test/integrations/lockr/", + "crates/trusted-server-js/lib/test/integrations/osano/", + "crates/trusted-server-js/lib/test/integrations/permutive/", + "crates/trusted-server-js/lib/test/integrations/sourcepoint/" + ], + "ids": ["RCJ-INT-01", "RCJ-INT-02", "RCJ-QUAL-01"] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/gpt_bootstrap.js"], + "ids": ["RCJ-BOOT-01", "RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03"] + }, + { + "exact": [ + "crates/trusted-server-core/src/integrations/gpt_diagnostics.rs", + "crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js" + ], + "ids": ["RCJ-DIAG-01"] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/gpt.rs"], + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-GPT-04"] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/prebid.rs"], + "ids": [ + "RCJ-PREBID-01", + "RCJ-PREBID-02", + "RCJ-PREBID-03", + "RCJ-PREBID-04" + ] + }, + { + "exact": ["crates/trusted-server-core/src/integrations/aps.rs"], + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "exact": [ + "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/mod.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" + ], + "ids": ["RCJ-INT-01", "RCJ-INT-02"] + }, + { + "exact": ["crates/trusted-server-core/src/trace_cookie.rs"], + "ids": ["RCJ-TRACE-01"] + }, + { + "exact": ["crates/trusted-server-core/src/tsjs.rs"], + "ids": ["RCJ-CORE-01", "RCJ-CORE-02", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts" + ], + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-DIAG-01"] + }, + { + "prefix": "crates/trusted-server-integration-tests/browser/tests/", + "ids": ["RCJ-CORE-01", "RCJ-INT-01", "RCJ-QUAL-01"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts" + ], + "ids": ["RCJ-DIAG-01"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/nextjs/navigation.spec.ts" + ], + "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts" + ], + "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"] + }, + { + "exact": [ + "crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts" + ], + "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"] + } + ] +} +``` +## 1. Problem statement and evidence + +APS demand is integrated server-side, but APS creatives do not render reliably. +Four serial fixes—the `bid.meta` carrier, decoupled shim, `hb_adid` fallback, and +the baseline PUC/collapsed-shell fix—each repaired one edge while leaving other +independent failure points. The common failure is architectural: identity and +state are copied across loosely coordinated server, GPT, Prebid, PUC, and iframe +code, and many failures are swallowed. + +The TSJS library has the same structural problem: two large GPT/Prebid modules, +duplicated ES5 and TypeScript behavior, imports in separately built IIFEs that do +not share module state, global expandos, multiple GPT wrappers, and asynchronous +work with no common owner. + +### 1.1 Supported flows + +| Flow | Auction source | Render owner | APS route | +| --------- | ----------------------------------------------- | ------------------------ | ------------------------------------------------------- | +| SSAT | `tsjs.boot.auctionProjection` | GPT integration | GAM Universal Creative → TS bridge → APS renderer | +| Prebid | Trusted Server Prebid adapter | Prebid + GPT integration | GAM Universal Creative → TS bridge → APS renderer | +| Page bids | `/_ts/page-bids` | GPT integration | GAM Universal Creative → TS bridge → APS renderer | +| Direct | `/auction` | render service | TS-owned iframe → APS renderer | +| Fallback | opt-in child of an attributable empty GAM cycle | render service | direct APS or direct ADM, according to the returned bid | + +### 1.2 Known failure surfaces + +| Area | Failure | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Admission | A configured mediator can discard direct-provider bids; scripts can be rejected by policy; strict dimensions and APS response validation can drop bids without a useful local reason. | +| Identity | PBS Cache UUID, upstream APS bid id, Prebid `adId`, GAM `hb_adid`, DOM id, and server slot id are different identities and have been conflated. GAM targeting values are capped at 40 characters. | +| GPT | `display()` under disabled initial load does not request; event listeners can be installed too late; multiple refresh wrappers and concurrent requests race; SafeFrame obscures frame ancestry. | +| Bridge | A `Prebid Request` can be duplicated, replayed, sent by a wrong frame, or arrive after navigation. A bare bid id is not enough to establish ownership. | +| Renderer | The opaque sandbox cannot observe HTTP failure; descriptor validation exists in Rust, TypeScript, and embedded ES5; CSP or runner loading can fail after the iframe loads. | +| Direct auction | One fetch can contain several slots, but current cancellation and result handling are not batch-aware; failures collapse to an empty array. | +| Bootstrap | Server bootstrap and bundle initialization can both believe they own runtime setup; a hung bundle can race the no-bundle fallback. | +| Lifecycle | There is no shared definition of attempt, ownership, supersession, terminal completion, or disposal. | + +## 2. Required behavior + +### 2.1 Outcome contract + +Each render attempt has one of four terminal outcomes: + +```ts +type RenderFailureReason = + | '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' + +type RenderOutcome = + | { outcome: 'accepted' } + | { outcome: 'no_bid' } + | { outcome: 'failed'; reason: RenderFailureReason } + | { + outcome: 'cancelled' + reason: 'caller_aborted' | 'superseded' | 'navigation_disposed' + } ``` -{ v: 1, traces: [ { trace_id, auth, events: [ - { nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?, - auction_id?, t_rel_ms?, t, ...fields } ] } ] } + +`accepted` means the path-specific completion authority reported success: + +- APS: the sandboxed renderer document accepted the descriptor and the queued APS + `prebid/creative/render` event invoked its success callback. APS owns the runner and + its promise that it invokes this callback only after committing the nested creative + iframe; Trusted Server cannot independently prove that promise for mutable upstream + bytes. Loading the runner script alone is not acceptance. +- Direct ADM/cache: the TS-owned iframe fired its first `load` before timeout. +- PUC ADM/cache: the TS-authored PUC owner reported its owned iframe's first + `load` over the bound owner channel. + +It does not claim that pixels were viewable. `slotRenderEnded{isEmpty:false}` is +evidence that GAM injected a creative, not evidence that APS completed. + +`no_bid` is reserved for an explicit, successfully parsed server auction decision +with no valid winner for that slot. An attributable GPT +`slotRenderEnded{isEmpty:true}` is +`failed{reason:'gam_empty'}` so an opt-in fallback can name its exact parent cause. +Network, timeout, HTTP, parse, descriptor, bridge, and renderer failures are not +converted to `no_bid`. + +Every attempt owns one terminal latch. All competing callbacks, ports, iframe +events, timers, aborts, and navigation disposal race through that latch. The first +valid terminal transition wins, disposes attempt resources, and makes every later +signal inert. + +### 2.2 Identity model + +The implementation keeps these identities distinct: + +| Identity | Purpose | Rules | +| ----------------------- | -------------------------------- | -------------------------------------------------------------- | +| server slot id | auction and publisher projection | exact, case-sensitive, 1–256 UTF-8 bytes, no NUL/control | +| programmatic slot id | direct-auction registration | validated `code`; same bound; exact request/result identity | +| DOM/container alias | locating a page element | separate collision-detecting index; ambiguous aliases fail | +| upstream bid id | provider provenance | 1–64 UTF-8 bytes, no NUL/control, unique in provider response | +| candidate id | mediator round-trip | server-minted opaque 12-character token; never an ordering key | +| PBS Cache UUID | cache transport lookup | preserved byte-for-byte as `cacheId`; never bridge authority | +| native Prebid `adId` | non-TS Prebid renderer lookup | untouched for native bids; never entered in the TS store | +| renderer reservation id | every TS-owned PUC capability | `r1_` plus 22 base64url characters; exact `hb_adid`/TS `adId` | +| attempt id | in-page lifecycle ownership | `a1_` plus 22 base64url characters; navigation-unique | +| lifecycle ticket | cross-window capability | `t1_` plus 22 base64url characters; one-use and attempt-bound | +| renderer nonce | renderer-document capability | `n1_` plus 22 base64url characters; one-use and attempt-bound | + +Every TS-owned PUC source—APS, inline ADM, or cache—receives a renderer reservation +id, and that id is copied exactly to GAM `hb_adid`. For the Trusted Server Prebid +adapter bid, it also replaces the TS bid's generated `adId` before targeting; +native Prebid bids are untouched. The server creates the id from 16 CSPRNG bytes +encoded as unpadded base64url and prefixed with `r1_`; it retries a response-local +collision at most eight times, then fails the bid with +`identity_generation_failed`. The browser rejects a collision with any live/ +tombstoned reservation as `reservation_collision`. Cache UUID and upstream/provider +bid id stay inside the tagged render source/provenance and are never fallback bridge +credentials. + +Attempt ids require no unbounded issued-id set. At `NavigationSession` creation the +browser obtains eight CSPRNG bytes with `crypto.getRandomValues` and keeps one +unsigned 64-bit attempt ordinal as two 32-bit words. For each new attempt it +increments the ordinal, concatenates the navigation prefix with the big-endian +ordinal, base64url-encodes those 16 bytes without padding, and prefixes `a1_`. The +fixed navigation prefix plus never-reused ordinal guarantees navigation-local +uniqueness. Prefix-generation failure or ordinal exhaustion refuses the new attempt +with `identity_generation_failed`; the ordinal never wraps. The active-attempt index +contains at most one attempt per admitted slot and is therefore capped by +`MAX_ACTIVE_SLOT_RECORDS = 256`; terminal settlement removes the strong entry, while +generation plus the nonreused ordinal makes stale callbacks inert without retaining +old ids. + +One runtime-owned capability registry stores lifecycle tickets and their tombstones +with a shared capacity of 320. Before minting it prunes entries whose fixed +three-second lifetime has expired; unexpired entries are never evicted. A ticket is +16 fresh CSPRNG bytes encoded as the fixed `t1_` form and checked against every live/ +tombstoned ticket. A collision retries at most eight total draws, then fails the +attempt with `identity_generation_failed`. Capacity exhaustion before a successful +draw refuses the outer PUC response and fails the attempt with +`capability_registry_full`. Consumption/disposal replaces the live entry with a +tombstone carrying the same original expiry; it never extends the lifetime. + +Renderer nonces use the same eight-draw CSPRNG/collision rule in a separate live +registry capped at 256, at most one `n1_` value per active attempt. They need no +tombstone: the exact frame, port, attempt id, and generation remain mandatory, and +attempt disposal closes the channel and removes the live nonce before any later +attempt can act. Nonce capacity fails the attempt with `capability_registry_full`; +collision exhaustion is `identity_generation_failed`. Neither registry falls back +to timestamps, `Math.random`, truncation, or eviction. + +### 2.3 Slot registry and bounded reservations + +One runtime-scoped slot service owns the registry: + +- `WeakMap` for GPT object identity; +- exact registered-slot-id index covering server and programmatic registrations; +- exact GPT ad-unit-code index; +- separate DOM alias index that rejects collisions; +- active render reservation map plus consumed/stale tombstones; +- request-intent and active-cycle state per slot. + +`MAX_ACTIVE_SLOT_RECORDS` is 256 across server-projected and programmatically +registered records in one `NavigationSession`. The immutable server projection is +validated against that total before the kernel commits; an oversized projection is +an `abi_mismatch` boot failure. `addAdUnits` reserves capacity for its whole input +before mutation and rejects the whole call with +`AdUnitRegistrationError{code:'registry_capacity'}` when the remaining capacity is +insufficient. Navigation disposal synchronously removes every record and secondary +index owned by that navigation; reservations/tombstones retain only their separate +bounded lifecycle below. + +Registration rejects a missing, empty, or greater-than-256-UTF-8-byte server or +programmatic slot id before indexing and assigns each valid slot a monotonically +increasing, navigation-local ordinal. An exact registered-slot-id collision is +rejected rather than overwritten. GPT ad-unit-code and DOM-alias indexes retain +collision state: a lookup must resolve exactly one record, and zero or multiple +matches fail `slot_unresolved`; registration order is never used to choose among +collisions. + +The reservation map and tombstones share a capacity of 320. An SSAT/page-bid render +reservation has a fixed 15-minute lifetime measured by the runtime's monotonic clock +from browser registration; consumption does not extend it. A Prebid bid awaiting +client-side selection instead receives a ten-second admission lease. Exact selection +atomically promotes that lease to a render reservation with a new fixed 15-minute +lifetime measured from promotion; this occurs before targeting can expose the id and +is the only expiry replacement. An admission lease is suppress-only and cannot +satisfy a PUC claim; a request carrying that id before selection is refused, +tombstones the lease, and records `prebid_contract_violation`. Unselected, aborted, +or selection-timed-out entries +become tombstones only through their original ten-second lease expiry. Expired +entries are pruned. +Unexpired entries are never evicted because eviction would allow a late creative +request to escape TS ownership. At capacity, registration fails with +`registry_full`. A consumed, stale, or disposed reservation remains a tombstone +until its original expiry and never produces a second response. While live, each +admission lease or render reservation records its exact slot, render source, +immutable `WinnerContext{selectedCpm}`, navigation generation, expiry, and state. +`selectedCpm` is copied from the fully validated selected projected bid and is +finite and nonnegative. Prebid admission verifies that the frozen bid's `cpm` is +exactly this stored value, and selection promotion preserves the same context +rather than reconstructing it from Prebid. A successful PUC claim transfers the +context into the `RenderAttempt` before replacing the live entry with a tombstone. +The tombstone discards the render source and winner context and retains only the id, +original expiry, terminal state, and minimum suppression metadata. Neither the +descriptor nor any capability contains CPM. + +Ids are unique across all live/tombstoned entries, so lookup identifies one entry +and then requires its exact active slot, cycle, and generation. The first compatible +PUC claim acquires its source and winner context; a live or tombstoned TS id is +suppressed before detailed validation. + +Direct `/auction` rendering does not round-trip through a PUC reservation. Its exact +winner join creates the `RenderAttempt` with an immutable +`WinnerContext{selectedCpm}` copied from that same validated projected winner before +any rendering or cache fetch. Thus direct and PUC paths have the same CPM authority +without inventing a bridge capability for direct rendering. + +The current `__tsRenderGeneration`, `__tsRenderBid`, and function-sentinel +expandos are removed. + +### 2.4 GPT request-cycle ownership + +GPT events describe physical requests and are not promises. The runtime therefore +tracks request intent separately from physical cycles: + +1. A TS operation records an intent before calling `display()` or `refresh()`. +2. A TS operation never treats `display()` as request-capable while initial load is + disabled. It uses `display()` only to register the slot, then invokes exactly one + `refresh([slot], {changeCorrelator:false})`; the request intent and three-second + start deadline attach only to that refresh. If the adapter cannot invoke refresh + or the call throws, the attempt fails `gpt_request_failed`. A publisher-owned + `display()` remains publisher activity and starts no TS attempt or fallback. +3. A physical cycle opens only on `slotRequested` and closes on the corresponding + `slotRenderEnded`. +4. One TS-owned cycle may be outstanding per slot, with at most one queued + replacement. +5. Attribution requires exactly one live compatible intent. Overlap or a later + request-capable intent that makes ownership ambiguous fails the affected TS + cycle with `cycle_unattributable`; it is never guessed from timing. +6. `responseIdentifier` deduplicates completion but is not an ownership token. +7. A cycle is re-armed only by counted completion or safe TS-owned + destroy/redefine—never by a timeout or navigation disposal that merely hopes GPT + has finished. + +`slotRequested` and `slotRenderEnded` listeners are installed unconditionally +before any TS display/refresh call. SRA opens one logical cycle per participating +slot. Publisher-initiated GPT activity remains publisher-owned and cannot trigger +TS fallback. + +An operation waiting for GPT or Prebid readiness has its own fixed ten-second +deadline measured from enqueue and fails `external_ready_timeout`; public +`requestAds.timeoutMs` never shortens or extends it. After a request-capable +`display()` or `refresh()` is +invoked, `slotRequested` must arrive within three seconds or the attempt fails +`gpt_request_timeout`. Once `slotRequested` arrives, its matching +`slotRenderEnded` must arrive within ten seconds or the attempt fails +`gpt_completion_timeout`. A timeout tombstones the reservation, closes owned ports, +and settles the attempt, but does not pretend the physical GPT cycle completed. + +At `gpt_request_timeout`, no attributable physical cycle exists. The adapter +immediately invokes the transactional TS-owned destroy/redefine contract in §5.7 +and permanently retires the old object. Failure defines no replacement and leaves +the path quarantined; later TS work fails `gpt_request_failed`. A publisher-owned +object enters page-lifetime quarantine and cannot +accept new TS work until the publisher explicitly destroys that object or the page +reloads; no later `slotRequested` or `slotRenderEnded` may release that quarantine +because it cannot be attributed to the timed-out invocation. At +`gpt_completion_timeout`, the already-open exact physical cycle stays retired or +quarantined until its matching real completion, safe TS-owned destroy/redefine, +publisher destruction, or reload. Late GPT events only drain an already attributable +completion-timeout cycle; they cannot revive an attempt, start fallback, or re-arm a +request-timeout quarantine. + +Navigation disposal does not manufacture a GPT completion. If the open slot is +TS-owned, the adapter invokes the §5.7 transaction; a replacement is defined only +when the current navigation still needs it and exact destruction succeeded. The +retired object remains in the runtime `WeakMap` until its late completion drains and +can never be matched to a replacement. Destroy failure leaves no second object and +quarantines later TS work. If it is publisher-owned, the physical cycle +is quarantined: new TS work for that GPT object fails `slot_quarantined` until the +matching `slotRenderEnded`, publisher destruction, or full page reload. There is no +timeout-based re-arm. In particular, an old completion after navigation but before +the replacement's completion settles only the retired/quarantined cycle. + +### 2.5 SPA and concurrent work + +- `RuntimeSession` survives SPA navigations and owns the global lifetime plus + injected adapter/service disposers. Runtime-scoped slot and reservation services + own the slot-object map, bridge listener, reservations/tombstones, and physical + GPT cycle state; kernel code knows them only through interfaces. +- `NavigationSession` owns route-specific slot aliases, request intents, auction + batches, attempts, timers, targeting history, and one internal immutable current + auction-projection snapshot. +- A new navigation atomically replaces the prior `NavigationSession`; disposal + cancels its live attempts and prevents late callbacks from mutating the new one. +- The initial session seeds its internal projection from the recursively frozen + `tsjs.boot.auctionProjection`. A later SPA session begins with no current + projection. Its page-bids controller accepts only one exact, fully validated + `BrowserAuctionProjectionV1` for the current navigation generation, deep-copies + and freezes it, transactionally registers all projected slots against the shared + 256-slot cap, then commits it to the session. A stale, duplicate, malformed, or + over-cap response commits no slot, targeting, bid, or projection and cannot retain + the prior navigation's data. Programmatic registrations admitted before that + response count against the same transaction. The immutable public boot object is + document-generation input and is never rewritten into a mutable current-state + carrier. +- An `AuctionBatch` owns one `/auction` fetch and one child attempt per requested + slot. Supersession cancels children individually. The shared fetch is aborted + only when every child is terminal, the caller aborts all children, the batch + response deadline expires, or navigation disposes. +- After a parsed response is processed, every still-live child receives the exact + server decision for its slot; missing, duplicate, or inconsistent decisions are + `invalid_response`, never inferred as `no_bid`. + +### 2.6 Fallback + +`SlotOperation` owns the public per-slot result and one primary `RenderAttempt`. +Fallback is opt-in and, when eligible, becomes a second child attempt. It may start +only after an attributable TS-owned GAM cycle terminates empty. Publisher-owned, +ambiguous, timed-out, quarantined, or stale cycles never trigger fallback. Each +child has its own immutable terminal result and local history. The operation +settles once: with the primary result when no fallback runs, or with the fallback +child result and `path:'fallback'` after the primary `gam_empty`. A child never +overwrites its parent or sibling. + +## 3. APS wire and server contracts + +### 3.1 One descriptor + +The only APS render descriptor is: + +```ts +interface ApsRendererV1 { + type: 'aps' + version: 1 + accountId: string + bidId: string + creativeId?: string + tagType: 'iframe' | 'script' + creativeUrl: string + width: number + height: number + aaxResponse: string +} + +interface AdmRenderSourceV1 { + type: 'adm' + version: 1 + adm: string + width: number + height: number +} + +interface CacheRenderSourceV1 { + type: 'cache' + version: 1 + cacheId: string + fetchUrl: string + width: number + height: number +} + +type BidRenderSourceV1 = ApsRendererV1 | AdmRenderSourceV1 | CacheRenderSourceV1 + +interface CacheFetchPolicyV1 { + version: 1 + baseUrl: string +} ``` -| `t` | fields | allowed `flow` | -| -------------------------- | --------------------------------------------- | ----------------------- | -| `request_cycle_started` | slot | ssat, prebid, page_bids | -| `bid_received` | slot, id_kind, source | render flows | -| `targeting_set` | slot, id_kind | render flows | -| `attempt_started` | slot, source? | render flows | -| `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids | -| `bridge_response_sent` | slot, source | ssat, prebid, page_bids | -| `render_terminal` | slot, outcome, reason?, source? | render flows | -| `gam_nonempty` | slot | ssat, prebid, page_bids | -| `gam_empty` | slot | ssat, prebid, page_bids | -| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | ssat, prebid, page_bids | -| `renderer_document_loaded` | slot | render flows | -| `runner_loaded` | slot | render flows | -| `runner_failed` | slot, reason | render flows | -| `adm_document_loaded` | slot | render flows | -| `fallback_start` | slot | fallback | -| `notification_sent` | slot, kind (`nurl`\|`burl`), notif_id, result | render flows | -| `client_queue_overflow` | dropped (count) | system | -| `heartbeat` | probe_run_id, expected_seq, adapter, target | system | - -Render flows = `ssat | prebid | page_bids | direct | fallback`. -`attempt_started` carries the attempt's `t_rel_ms` baseline and its -`source` is **optional** (direct attempts start before a response selects -a source); the latency metric is `render_terminal{accepted}.t_rel_ms − -attempt_started.t_rel_ms` per attempt. **`render_terminal.source` is -present iff a render source was actually bound** — absent for -`no_bid`/`cancelled` and every pre-winner outcome (`auction_timeout`, -`network_error`, `http_error`, `invalid_response`) as well as the -pre-source reasons (`gpt_absent`, `pbjs_absent`, `slot_unresolved`, -`intent_no_request`, `abi_mismatch`, `registry_full`, `bundle_partial`); -the generated per-event/per-reason validity matrix (§6.7) encodes -source-presence by whether a source was bound, not by a hand-list. -Reason enum: `renderer_document_no_load`, -`runner_no_load`, `runner_failed`, `descriptor_invalid`, -`invalid_dimensions`, `dimensions_out_of_range`, `bridge_id_mismatch`, -`cycle_unattributable`, `intent_no_request`, `stale_navigation`, -`bridge_claim_timeout`, `gam_empty`, `no_render_source`, -`slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`, -`fallback_cancelled`, `abi_mismatch`, `registry_full`, -`currency_mismatch`, `auction_timeout`, `network_error`, `http_error`, -`invalid_response`, `adm_document_no_load`. - -### 5.2 Transport, batching, and budgets (limiter-consistent) - -- Batches are capped by **both** 64 events **and** 12 KiB encoded - payload (headroom under the 16 KiB ingest cap); a flush drains the - queue as up to **4 sequential batches**. -- Cadence: flush every **10 s** and on `visibilitychange`/`pagehide`. - Worst-case honest traffic per tab: 6 flushes/min × ≤ 4 batches = ≤ 24 - requests/min transient, typically ≤ 6. -- **Budgets derived from that worst case:** client-side trace budget - ≤ 8 batches/min sustained (excess coalesces into the next flush); - ingest per-address budget **60 req/min, burst 120** (≈ 5 active tabs - plus pagehide bursts). The limiter can no longer reject honest - steady-state traffic by construction; tests cover sustained - single-tab, multi-tab (5), diagnostic pre-upgrade buffer flush, and - pagehide bursts. -- Transport: `fetch(..., {keepalive: true, credentials: -"same-origin"})`; `pagehide` fallback `sendBeacon(url, new -Blob([json], {type: "application/json"}))`. Queue bound 256; overflow - uses the out-of-band saturating counter + one coalesced - `client_queue_overflow` in the next flush (never enqueued into a full - queue). - -### 5.3 Signed authorizations - -**Trace authorization** `v1...[.].` (`auth` -ingest bound 256 bytes; all other strings 64): - -- `kid ^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store; - previous keys retained ≥ 24 h; missing key with the feature enabled → - loud first-use failure. -- `exp` canonical decimal; ±60 s skew; ≤ 15 min future. `mode`: - `sampled | unsampled | diagnostic | probe`. **`dexp` is present iff - `mode = diagnostic`** — the immutable diagnostic ceiling, signed into - the token, set at upgrade to the credential's absolute expiry. - **Every renewal of a diagnostic token re-derives - `exp = min(now + 15 min, dexp)` and preserves `dexp`; past `dexp`, - renewal fails** — diagnostic access is bounded by the credential - forever, not just at upgrade (closing the indefinite-renewal hole). - Tests: renewal-before-expiry capped, repeated renewal to the ceiling. -- `sig` = unpadded base64url HMAC-SHA-256 over - `"ts-trace-auth-v1" || u32be(len(origin)) || origin || -u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode || -u64be(exp) [|| u64be(dexp)]`; constant-time compare; per-group - rejection at ingest. `unsampled` transmits nothing and is rejected if - carried. **Probe issuance protocol:** the probe runner authenticates - to `POST /_ts/admin/probe-authorization` (admin auth + CSRF) and - receives a batch of pre-signed probe-mode tokens tagged - `probe_run_id`; probe traffic is never sampled out and excluded from - product metrics by mode. -- Renewal: `GET /_ts/trace-auth` presenting the current still-valid - token in `X-TSJS-Trace-Auth`; re-signs same trace + mode (+ `dexp`). - **Diagnostic upgrade** is the sole mode transition: - `POST /_ts/trace-auth/upgrade` presenting token + credential. - -**Diagnostic credential** `d1....` — full byte-level -spec with vectors: `oh` = first 16 hex chars of SHA-256 of the -externally visible origin (scheme+host+port, UTF-8); `sig` = unpadded -base64url HMAC-SHA-256 over `"ts-diag-cred-v1" || u32be(len(origin)) || -origin || u64be(exp)`; same kid charset, key strength, rotation, and -≥ 24 h previous-key retention; ±60 s skew; absolute expiry ≤ 60 min; -constant-time compare; replayable short-lived bearer by design (bounded -by expiry + origin). Issued `POST /_ts/admin/diagnostic-credential` -(admin auth, CSRF: same-origin + custom header). Transport: `#tsdiag=` -fragment → read synchronously, cleared via `history.replaceState`, held -in memory only (RuntimeSession); pre-upgrade events buffer locally -(bounded 256) and flush after upgrade. Forgery, wrong-origin, -replay-past-expiry tests. - -Lazy cached initialization applies to every secret-backed component; -failure with the feature enabled is that feature's loud error path. - -### 5.4 Ingest and rate limiting - -- `POST /_ts/client-events`: `application/json` only; no - `Content-Encoding`; `204`, `no-store`; never echoes input. Pre-parse: - body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 (`auth` ≤ 256 B); - `trace_id ^[0-9a-f]{32}$`; `attempt_id ^[a-z0-9]{8}$`; `auction_id` - canonical UUID; integers `[0, 2³¹)`; `t_rel_ms` u32. -- Same-origin (client-events, trace-auth): `Sec-Fetch-Site: -same-origin` else normalized `Origin` equality; absent both → - drop-and-count. -- Limiter (per §5.2 budgets): trait `ClientEventLimiter`; Axum real - token bucket (60/min, burst 120), map ≤ 65,536; Cloudflare/Spin - best-effort ≤ 4,096/instance; TTL 10 min, cleanup on access + sweep; - at capacity reject unseen identities (expired always reclaimable); - unknown address → shared bucket 6/min; Fastly platform 60 s window at - limit 120 (approximation; overshoot bounded only by in-flight - concurrency — documented by the synchronized-burst test, no numeric - multiple claimed). Limiter unavailable → drop early with `204`. - Trusted address per adapter (Fastly platform IP; Axum rightmost XFF - after `trusted_proxy_hops`, absent → socket peer; CF - `CF-Connecting-IP`; Spin platform). Trace-auth and CSP routes carry - their own buckets (10/min, burst 20). - -### 5.5 Sinks, canonical views, per-sink authenticated probes - -- Event key `(publisher_domain, trace_id, seq)`; canonical views - `ts_client_events_v` (dedup) and `ts_render_attempts_v` (**keyed by - `attempt_id`**); the arm-union views stamp `deployment_pool` from the - write identity (§0). Dashboards/alerts query canonical views only. -- The Fastly sink is fire-and-forget (`tinybird.rs:153`) — - **per-datasource authenticated probes**. **Every probe-capable table - carries `{probe_run_id, expected_seq, adapter, target}`** (added to - `ts_csp_reports` and `ts_ops_counters` below, not only - `ts_client_events`), so loss queries distinguish runs, retries, resets, - and adapters. **These four fields are cryptographically bound to the - issued probe authorization** — the admin-issued probe token - (`POST /_ts/admin/probe-authorization`, §5.3) signs `probe_run_id`, - `adapter`, and `target`, and the ingest handler stamps the row from the - verified token rather than trusting submitted values. A secret-derived - CSP probe `policy_id` is a bearer capability for _routing_ only; it is - never treated as proof of authentic run metadata. **Persistence gates - are scoped to sink-backed adapters** (DR-5); accept-count-drop adapters - get HTTP-parity gates only. Freshness = probe lag ≤ 5 min; loss = - `expected_seq` gaps < 0.1%; alert owner: release owner's on-call. -- **Alert-delivery drill:** a synthetic canary page injects a known - failure class at a known rate; the failure-detection alert must fire - within one hour — dashboards and alert latency are tested, not - assumed (Appendix A row). - -### 5.6 Physical schemas (deployed before writers) - -- **`ts_client_events`**: `received_at DateTime64, publisher_domain -LowCardinality(String), release_id String, deployment_pool -Enum(canary|control), assignment_id Nullable(FixedString(32)), -trace_id FixedString(32), mode Enum(sampled|diagnostic|probe), nav_gen -UInt32, refresh_gen UInt32, seq UInt32, flow -Enum(ssat|prebid|page_bids|direct|fallback|system), attempt_id -Nullable(FixedString(8)), parent_attempt_id Nullable(FixedString(8)), -auction_id Nullable(UUID), t_rel_ms Nullable(UInt32), event Enum(§5.1), -slot Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8), -source Nullable(Enum), reason Nullable(Enum), outcome -Nullable(Enum(accepted|failed|no_bid|cancelled)), action -Nullable(Enum), kind Nullable(Enum), notif_id Nullable(FixedString(12)), -result Nullable(Enum), dropped Nullable(UInt32), probe_run_id -Nullable(String), expected_seq Nullable(UInt32), adapter -Nullable(Enum), target Nullable(Enum)`. Sorting key `(publisher_domain, -received_at, trace_id, seq)`; TTL 30 days; sink batch cap 512; startup - validation; sink-unavailable → accept-count-drop. -- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`): - add nullable `trace_id`, `mode`, `release_id`; - `row_kind Enum(slot|totals|overflow)`; `bid_drop {row_kind, provider -Nullable(LowCardinality(String)), slot Nullable(String), reason -Enum(AuctionDropReason), width Nullable(UInt16), height -Nullable(UInt16), count UInt32}` (32 slot-rows + one overflow row whose - `count` = actual dropped bids); `selection_summary {row_kind, slot -Nullable(String), winner_source Nullable(Enum(mediator|direct|none)), -winner_provider Nullable(String), candidates_direct UInt16, -candidates_mediator UInt16, dedup_hits UInt16, currency_rejected -UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` (8 - slot-rows + one totals row that survives truncation; saturating - counters `0xFFFF`/`0xFFFFFFFF`). - - **`AuctionDropReason` (closed, exhaustive over baseline producers, - one shared typed enum — no string literals):** - `script_rendering_disabled, invalid_dimensions, -dimensions_out_of_range, missing_render_source, invalid_creative_url, -unsupported_tagtype, render_payload_too_large, -unexpected_response_shape, currency_mismatch, floor_rejected, -provenance_invalid, duplicate_demand, missing_bid_id, -duplicate_bid_id, bid_id_too_large, empty_seatbid, -empty_seatbid_bids, unknown_impid, invalid_price, -unsupported_media_type, creative_id_too_large, -renderer_extension_serialization_failed, no_render_source, -lost_to_higher_bid, unsupported_currency, missing_request_context, -overflow` — covering every baseline producer at `aps.rs:740-929` - (incl. `empty_seatbid_bids` at `:875`, `unsupported_currency`, and - the response-level `missing_request_context`, whose totals/error-row - disposition is `row_kind = totals` with a null slot) and - `formats.rs:408-419`. `currency_mismatch` and `unsupported_currency` - are **distinct** (config-vs-response mismatch vs an unsupported - currency code). Producers emit a **shared typed enum, no string - literals**, so the **compile-time exhaustiveness test is real**: every - variant maps to a producer and every producer to a variant. -- **`ts_csp_reports`**: `received_at DateTime64, publisher_domain -LowCardinality(String), release_id String, policy_id -LowCardinality(String), cohort LowCardinality(String), directive_bucket -Enum(script|style|frame|img|connect|font|media|worker|other), -source_bucket Enum(https_host_allowlisted|data|blob|inline|eval|other), -count UInt32, probe_run_id Nullable(String), expected_seq -Nullable(UInt32), adapter Nullable(Enum), target Nullable(Enum)`; - sorting key `(publisher_domain, received_at, policy_id)`; TTL 30 days. - Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256, nesting ≤ 4, - both media types with separate validators, unused fields discarded, own - limiter bucket. **Caps with values:** 10,000 reports/hour/publisher and - 1,000/hour/cohort; overflow increments the `csp_overflow` ops counter - (dropped reports counted, never parsed further). -- **`ts_ops_counters`**: `received_at DateTime64, publisher_domain -LowCardinality(String), release_id String, counter -Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked| -ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged| -csp_overflow|probe), value UInt64, probe_run_id Nullable(String), -expected_seq Nullable(UInt32), adapter Nullable(Enum), target -Nullable(Enum)`; sorting key `(publisher_domain, received_at, counter)`; - TTL 90 days. -- **Sink plumbing:** one generic multi-target Tinybird sink trait; - `RuntimeServices` (`platform/types.rs:158`) gains handles for - client-events, CSP, and ops targets beside the auction sink; each - target has its own dataset + token settings. -- **Settings:** `[telemetry.client_events] collection_enabled, -sink_enabled, sample_rate, api_host, dataset, token_secret, -secret_store, max_body_bytes`; `[telemetry.csp_reports]` and - `[telemetry.ops_counters]` (same transport shape); - `[telemetry.trace_auth] secret_store, active_kid, previous_kids, -sampling_key_secret`; `[telemetry.diagnostic] secret_store, -active_kid`; `[telemetry.probe]` admin-issued run configuration. -- APS parsing returns structured drop observations - `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values); - > 8192 → `dimensions_out_of_range` unclamped. - -### 5.7 Modes and SLIs - -Production (sink-backed): deterministic sampling (0.10). SLIs: pipeline -availability (per-sink authenticated probe freshness ≤ 5 min, loss -< 0.1%); failure detection (≥ 1% of sampled render attempts visible -within one hour at ≥ 10,000/hour) — **verified by the injected-failure -alert drill**, not only by probe persistence. Diagnostic: -credential-gated, `dexp`-bounded, unsampled, full stream + console -mirroring + debug envelopes. - -### 5.8 Server-side drop surfacing - -Bounded structured summary whenever any bid is dropped; `bid_drop` + -`selection_summary` rows; `ts-debug` comment; tester-gated structured -`debug` on page-bids and `/auction`. Startup warnings: APS + -`allow_script_creatives = false`; mediator + direct providers without -explicit `winner_selection`. - -## 6. APS delivery fixes - -### 6.1 Mediation - -Seven rules, arrival-independent, one shared helper across both -lifecycles: (1) required `[auction].currency` (APS + non-USD = startup -error; Prebid validates at parse, `prebid.rs:2433`); (2) candidate -identity `source_candidate_id = (provider_name, upstream_bid_id)` with -the upstream id **required, ≤ 64 chars, unique** — missing/duplicate/ -oversized → `bid_drop{missing_bid_id | duplicate_bid_id | -bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` is a -server-minted 12-char `^[a-z0-9]{12}$` CSPRNG wire echo with in-auction -collision retry, **never an ordering key**, and the mediator's echoed -value is validated against the issued set on return (an echo matching no -issued id is `provenance_invalid`); (3) mediator echoes -`ext.trusted_server.candidate_id` — resolves → forwarded candidate, -provenance `mediator`, **price authoritative from the mediator, every -render-source and notification field from the stored candidate, deal -fields out of scope**; any render-source difference → mediator-native; -unresolvable echo → discarded + counted (`provenance_invalid`), -mediator-native and direct candidates stay eligible; (4) floors both -populations, echoes dedup twins; (5) **total order** decoded CPM desc → -provenance rank (mediator first) → `source_candidate_id` asc; (6) -required `winner_selection` (`mediator_only`: timeout → no winners -unless `mediator_timeout_fallback = "direct"`; `merge_highest_cpm`: -timeout → direct-only); (7) `selection_summary` reporting. - -### 6.2 Dimensions - -Exact membership (`aps.rs:675`); structured `bid_drop{invalid_dimensions, -w, h}`; "request the sizes you accept." - -### 6.3 Script creatives - -Default `false`, loud (§5.8); **DR-2 is a deployment decision** (enable -with security approval, or accept a quantified excluded share and gate -Phase 3 on it). - -### 6.4 / 6.5 - -Render identity per G2; fallback per G4e (child attempt). - -### 6.6 Renderer endpoint - -Unconditional route in every adapter (provider stays config-gated); -startup auth-pattern validation; `/integrations/aps/renderer/v1` -embedded, served `public, max-age=31536000, immutable` with a -checked-in per-version header manifest (headers frozen with bytes); -canary versions `no-store`; unknown version 404 `no-store`; -three-message ack (G4b-1); aggregate route counters in -`ts_ops_counters`; CSP rollout three instruments (enforced discovery / -report-only tightening / enforced-cohort relaxation on a short-lived -canary version, gated on runner acceptance, violation rate, render -failure, with a kill switch); **policy identity in the server-selected -`policy_id` path** (also the release/cohort carrier, §0); bucketed -aggregation only with the §5.6 caps; never a sole rollback signal; -three-browser capture (`playwright.config.ts:16`). - -### 6.7 One descriptor schema - -Tagged `BidRenderer` envelope (`types.rs:188-211`); wire-schema -crate/xtask (no core↔js cycle, `Cargo.toml:45`) generates JSON-Schema, -TS parser, ES5 inline fragment, the §5.1 validity matrix, and fixtures; -staleness CI; semantic validators handwritten; outer tolerance only; -exact AAX projection; shared positive + adversarial corpus through all -three validators. - -### 6.8 Bridge hardening - -Order (normative — preserving the baseline stolen-capability defense, -`gpt/index.ts:1584-1637`, **plus the read-only lookup the altered-id -signal needs**): - -1. parse `e.data` (bare catch → return); -2. **read-only source→active-slot lookup for every Prebid Request** — if - the resolved slot expects a different ad id, emit - `bridge_request{matched: false}` (the B1 signal) **without responding - and without suppressing native Prebid** (a truncated `hb_adid` is not - TS-reserved, so suppression would be wrong and the old order could - never produce the signal); -3. identify a TS-reserved ad id (live registry or tombstone, G2); -4. if TS-reserved: `stopImmediatePropagation()` before validation; -5. validate source ownership (known slot-root `WindowProxy` map; - sender's parent chain to depth 5; never scanning the frame tree); -6. validate nonce, token, `nav_gen`, `refresh_gen` (G4b); -7. respond, or refuse with `bridge_id_mismatch`. - -Non-TS ids are otherwise untouched. Stolen-token test: neither TS nor -native Prebid responds. Listener order: real-browser assertion. - -## 7. TSJS target architecture - -### 7.1 Layering +`BidRenderSourceV1` is the only browser render-source union. A selected internal +Rust `Bid` carries exactly one corresponding enum member; separate optional +creative, renderer, and cache fields are removed. APS markup is not smuggled +through `adm`, `meta`, or debug fields. Each tagged object rejects unknown keys. +Limits are defined once and shared by the Rust producer, TypeScript parser, and +embedded renderer validator: + +- nonempty `accountId` and optional nonempty `creativeId`, each at most 1,024 + UTF-8 bytes; +- nonempty `bidId` of at most 64 UTF-8 bytes; +- numeric, finite, integral `width` and `height`, each in the inclusive shared + `RENDER_DIMENSION_MIN = 1` through `RENDER_DIMENSION_MAX = 4096` CSS-pixel range; +- `creativeUrl` at most 4,096 UTF-8 bytes, HTTPS, no credentials, and not the + publisher origin; +- canonical standard-base64 `aaxResponse`, decoded size at most 256 KiB; +- exactly one decoded seat and one decoded bid; +- decoded bid id, dimensions, `creativeurl`, and `tagtype` exactly match the + duplicated descriptor fields; +- finite, nonnegative decoded price. + +A checked-in schema/corpus is the cross-language conformance source. Rust, +TypeScript, and the ES5 renderer validator run the same positive and adversarial +vectors. CI fails when generated ES5/schema output is stale. Semantic validation +that cannot be represented in JSON Schema remains in small handwritten validators +covered by the same corpus. + +For `adm`, markup is nonempty and at most 512 KiB. For `cache`, `cacheId` is the +exact validated PBS Cache UUID. When cache rendering is enabled, the server emits +one trusted `CacheFetchPolicyV1` at `tsjs.boot.cachePolicy` before core; `baseUrl` is +the same immutable configuration snapshot used for auction projection and is an +absolute HTTPS URL of at most 4,096 UTF-8 bytes with a host and fixed nonempty path +but no credentials, query, or fragment. Core validates and freezes it before any +integration module prepares. A cache source without a valid boot policy is `descriptor_invalid` +and is never fetched. + +`fetchUrl` is constructed server-side from that exact base URL, is at most 4,096 +UTF-8 bytes, and has exactly one query parameter, +`uuid=`; it has no credentials, fragment, or other query +parameter. The browser parses both values and requires exact origin, +port, and pathname equality with the frozen base, empty username/password/hash, +exactly one `uuid`, decoded UUID equality with `cacheId`, and canonical search text +`?uuid=${encodeURIComponent(cacheId)}`. It then fetches with `redirect:'error'`, +`credentials:'omit'`, and `referrerPolicy:'no-referrer'`, enforces CORS, a +five-second deadline, a 512 KiB body limit, successful HTTP status, and a JSON +object response. The response requires an own, nonempty `adm` string of at most +512 KiB. Optional `w` and `h` must occur together as integral numbers within the +same 1–4096 range and must equal the source dimensions. Optional `price` must be a +finite nonnegative number but is never rendering authority. Other OpenRTB bid keys +are allowed and ignored; raw markup bodies, arrays/primitives, `width`/`height` +aliases, accessors, and alternate wrapper shapes are rejected. The response need +not echo `cacheId` because the exact request URL is its transport binding. +`${AUCTION_PRICE}` in the ADM is expanded only as +`String(attempt.winnerContext.selectedCpm)` from the context transferred by the +consumed reservation or installed by exact direct-winner admission, never from the +cached response's `price`, current projection, targeting, or a later winner. Only +the exact token is replaced; +`${AUCTION_PRICE:B64}` remains untouched. The resulting ADM enters the direct ADM +lifecycle. +Transport, status, or shape failure is respectively `cache_network_error`, +`cache_http_error`, or `cache_invalid_response`; none becomes `no_bid`. + +### 3.2 Per-slot auction decisions + +Every server auction entry point produces exactly one decision for every requested +slot, in request order: + +```ts +type AuctionSlotFailureReason = + | 'auction_disabled' + | 'consent_denied' + | 'slot_not_eligible' + | 'provider_timeout' + | 'provider_error' + | 'invalid_provider_response' + | 'mediation_failed' + | 'winner_not_renderable' + | 'identity_generation_failed' + | 'internal_error' + +type SlotAuctionDecisionV1 = + | { slot: string; outcome: 'winner'; candidateId: string } + | { slot: string; outcome: 'no_bid' } + | { slot: string; outcome: 'failed'; reason: AuctionSlotFailureReason } + +interface AuctionDecisionSetV1 { + version: 1 + auctionId: string + results: SlotAuctionDecisionV1[] +} + +interface BrowserAuctionProjectionV1 { + version: 1 + auction: AuctionDecisionSetV1 + bids: Array<{ + candidateId: string + slot: string + provider: string + upstreamBidId: string + cpm: number + currency: 'USD' + targeting: Record + rendererReservationId: string + renderSource: BidRenderSourceV1 + }> +} +``` +`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 +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 +`^[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 +exactly one bid with the same slot/candidate and non-winners have none; +`rendererReservationId` uses the exact unique `r1_` form from §2.2; provider matches +`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`; and upstream bid ids are 1–64 UTF-8 bytes with +no NUL or ASCII control. CPM is a finite nonnegative number and currency is exactly +`USD`. + +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 +nonempty, contains at most 40 Unicode scalar values and 160 UTF-8 bytes, and contains +no NUL or ASCII control. The producer applies the same rules. A winning candidate +that cannot be projected becomes `winner_not_renderable`. + +Aggregate overflow has one exact server outcome. The producer first constructs and +measures the complete canonical projection. If it exceeds 8 MiB, it transactionally +converts every `winner` result in that auction to +`failed{reason:'winner_not_renderable'}`, emits no projected winner bids, and retains +the existing no-bid/failed results in request order. For `/auction`, the corresponding +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 +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. + +Wrong type, nonfinite, fractional, zero, or negative render dimensions are +`invalid_dimensions`; an otherwise integral dimension outside 1–4096 is +`dimensions_out_of_range`. This exact distinction and range apply in the Rust +producer, TypeScript projection/source parser, ES5 APS renderer validator, +programmatic banner sizes, cache `w`/`h`, PUC/renderer DOM construction, and every +CSS/attribute layout assertion. No adapter may clamp an accepted dimension. + +Each provider response is normalized internally to exactly one +`ProviderSlotOutcome`—candidate, no-bid, or typed failure—for every slot dispatched +to that provider. A successful response that omits a dispatched slot is a provider +no-bid; launch, transport, timeout, HTTP, parsing, validation, and mediation errors +are failures for the affected dispatched slots. Final aggregation is deterministic: + +1. Pre-dispatch gating returns `auction_disabled`, `consent_denied`, or + `slot_not_eligible` directly. A slot with zero eligible providers is exactly + `failed{reason:'slot_not_eligible'}`, never a no-bid. Provider currency rejection + is `invalid_provider_response`; there is no separate render-layer currency reason. +2. A selected deliverable candidate is `winner`, even if another provider failed. +3. Failure to mint a unique renderer reservation for the selected candidate is + `failed{reason:'identity_generation_failed'}`. Any other failure to validate or + project the selected candidate is `failed{reason:'winner_not_renderable'}`. +4. With no winner, the slot is `no_bid` only when at least one provider was + dispatched and every eligible/dispatched provider + completed successfully with no candidate. +5. With no winner and any provider or mediation failure, the slot is `failed` with + the first applicable reason in this closed priority order: `internal_error`, + `mediation_failed`, `invalid_provider_response`, `provider_error`, + `provider_timeout`, `consent_denied`, `auction_disabled`, `slot_not_eligible`. + `winner_not_renderable` and `identity_generation_failed` are selected directly by + rule 3 and do not participate in multi-provider priority. Completion order is + irrelevant. + +`/auction` keeps ordinary OpenRTB winners in `seatbid` and places the decision set +at `ext.trusted_server.slot_results`. Every TS winner bid has this exact nested +extension; unknown keys inside `trusted_server` are invalid: + +```ts +interface TrustedServerOpenRtbBidExtV1 { + candidate_id: string + slot_id: string + render_source: BidRenderSourceV1 +} ``` -kernel/ boot, config, queue, event bus, log, beacon, sessions -adapters/ googletag.ts, pbjs.ts, messaging.ts ← only access to external ad-tech globals -services/ slots (registry+handoff), auction client, render engine, consent -integrations/ gpt, prebid, aps, creative, datadome, … + +The bid's standard `id` is the server-minted renderer reservation id from §2.2; +the provider's upstream id remains provenance and, for APS, the descriptor's +`bidId`. The bid's standard `impid` must equal the request impression id mapped to +`slot_id`. A winner decision joins by exact `slot`, `candidateId`, `impid`, and +`slot_id` to exactly one bid. A no-bid or failed decision joins none. Missing, +duplicate, extra, or mismatched joins make the entire response invalid to TSJS. +TSJS renders only `render_source`; it never infers a source from standard OpenRTB +fields. If a bid also carries standard `adm`, it is permitted only for an ADM source +and must equal `render_source.adm` byte-for-byte; an `adm` on APS/cache or a mismatch +is invalid. `/_ts/page-bids` returns `BrowserAuctionProjectionV1`, and initial HTML +stores that same value at `tsjs.boot.auctionProjection`. They do not carry a second +legacy `{slots,bids}` interpretation. The deprecated `/__ts/page-bids` endpoint and +its JS fallback are deleted at cutover. + +### 3.3 APS response admission + +APS response handling is deterministic and reports typed local drop reasons: + +- upstream bid id is required, bounded to 64 UTF-8 bytes, and unique within the + provider response; +- malformed `contextual`, missing `creativeurl`, invalid tag type, invalid URL, + invalid dimensions, disallowed script, and malformed price are rejected per bid + where safe; one bad bid does not discard unrelated valid bids; +- dimensions must exactly match one requested size; values are never clamped; +- script creatives remain default-off and require an explicit security-approved + setting; iframe creatives remain supported by default; +- the validated AAX projection used in `aaxResponse` is derived from the accepted + bid, not re-parsed from a later lossy structure. + +Drop reasons must remain visible in the existing debug/log surfaces for all auction +entry points. This is not a new external telemetry contract. + +APS configuration accepts only canonical `account_id`; the `pub_id` deserialization +alias and its integer coercion are deleted at the hard cutover. + +### 3.4 Mediation provenance + +This work preserves the configured mediator's existing candidate selection and +timeout fallback behavior. It changes only the unsafe reconstruction boundary for +renderer-bearing source candidates: + +1. Candidate provenance is `(provider_name, upstream_bid_id)`. Missing, duplicate, + or oversized upstream ids are rejected. +2. Every candidate receives a response-unique, opaque, server-minted 12-character + base64url `candidate_id` from 9 CSPRNG bytes. Generation retries a + response-local collision at most eight times, then fails the affected slot with + `internal_error`; the id is never an ordering key. +3. The mediation request carries it only at + `ext.trusted_server.candidate_id`. A mediator-selected source candidate must echo + exactly one known id. Missing, unknown, or duplicate echoes are + `mediation_failed` and cannot borrow render data from another candidate. +4. The resolved candidate takes only the mediator-selected price and existing + selection metadata from the mediator. Provider, upstream id, render source, + dimensions, currency, and notifications come from the stored source candidate. + Mediator-native render sources are rejected as out of scope. +5. Direct no-mediator selection keeps the repository's current highest-CPM rule; + an exact CPM tie is resolved deterministically by provider name and upstream bid + id. An APS bid explicitly declaring a non-USD currency is invalid. This design + adds no auction currency or winner-selection configuration requirement. + +### 3.5 Publisher projection and GAM targeting + +The publisher bid projection carries `renderSource: BidRenderSourceV1` intact on +every path, plus the exact `candidateId` and `rendererReservationId`. Initial HTML +stores the document-generation input at +`tsjs.boot.auctionProjection: BrowserAuctionProjectionV1`. The initial +`NavigationSession` seeds its internal current projection from that immutable boot +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. +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. + +Targeting cleanup is owner-and-value checked, not value-only. One runtime-owned +journal stack per physical GPT slot and targeting key records an internal owner id, +the exact installed string, and the predecessor value/owner for every TS write. +The sole GPT adapter observes each live slot's `setTargeting` and `clearTargeting` +calls and uses a closure-private reentrancy marker for TS-originated writes. Before +forwarding any publisher-originated mutation it invalidates the affected key's TS +restoration chain—or every chain for clear-all—regardless of whether the publisher +writes the same string. This bookkeeping never changes the publisher call's +arguments, return, throw, or order. +Before a write, a mismatch between the actual GPT value and the current TS frame +means publisher code changed the key; the runtime drops its restoration chain and +preserves that publisher value. Otherwise the new attempt pushes a distinct owner +frame before setting the value, even when its string equals the predecessor's. + +Supersession, empty render, terminal failure, and navigation disposal mutate GPT +only when the disposing frame is current owner and the actual string still equals +its installed string. A current provisional frame then restores its immediate live +predecessor, or the original publisher value/absence. Disposing an older frame below +a newer owner performs no GPT write and rebases the successor to the removed frame's +predecessor. Acceptance promotes the new attempt's frames into its +`CommittedRenderArtifact`; disposal of the prior accepted artifact uses that same +non-top rebase. Thus two generations installing an identical string remain distinct: +newer success cannot be cleared by older disposal, newer failure can reveal the +still-live older value, and a publisher mutation is never overwritten. + +Publishing a TS-owned PUC bid is one ordered transaction. The browser first +validates the winner, tagged source, slot join, and server-minted reservation id and +prepares all targeting/bid objects without exposing them. It then inserts the +reservation as live in the bounded store. Store capacity or collision therefore +fails before a creative can observe the id. + +For SSAT/page-bids, only after successful insertion may it expose that same id as +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. + +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. +The TS-owned `PrebidAdapter` exposes one internal version-pinned +`admitTrustedBid(preparedBid)` boundary: + +```ts +interface PreparedTrustedBid { + readonly auctionId: string + readonly adUnitCode: string + readonly bid: Readonly<{ + requestId: string + adId: string + cpm: number + width: number + height: number + ad: '' + ttl: 300 + creativeId: string + netRevenue: true + currency: 'USD' + bidderCode: string + meta: Readonly<{ + advertiserDomains: readonly string[] + tsAuctionId: string + tsBidId: string + tsAdmHash?: string + }> + }> +} + +interface PrebidAdapter { + admitTrustedBid( + preparedBid: Readonly + ): 'admitted' | 'not_admitted' +} ``` -Kernel imports nothing above it; adapters import kernel only; services -import kernel + adapters; integrations import kernel + services, never -each other; stateful services via the G3 registry only; enforced per -G3's two rule families. - -### 7.2 Adapters - -`present | pending | timed_out` per external global; `timed_out` -non-terminal; queued operations carry their own timeouts and expire with -disposition reasons. - -### 7.3 Slot registry service - -Kernel-owned; `WeakMap` + div-id index; -ownership, adoption, handoff claims, responsive resolution, the G4a -causal intent queue (NavigationSession for unissued intents) and -cycle/drain state (RuntimeSession), targeting history. No expandos -(`__tsRenderGeneration`/`__tsRenderBid` deleted). - -### 7.4 Final global surface (hard cutover) - -| Legacy surface (removed at cutover) | Final shape | -| --------------------------------------- | --------------------------------------------------------------------- | -| `window.tsjs.que` | `window.tsjs.que` — unchanged | -| `globalThis.tscreative` | `tsjs.creative.*` | -| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` | -| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) | -| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` | -| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) | -| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` | -| `tsjs._internal` | kernel registry (G3), frozen after boot | -| (new, public) | `tsjs.definePlugin({id, release, install})` | - -Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que -||= []; tsjs.boot ||= {}`; `publisher.rs:3665`'s clobber fixed); -transactional ownership `unclaimed → installing → kernel | fallback` -with an owner-generation counter; kernel installs inert and flips at one -commit point; throws unwind to `failed`; the 10 s watchdog aborts the -owner-generation-scoped controller, completes the shared unwind, then -atomically transitions `failed → fallback`; late continuations and -disposers validate the owner generation and self-discard; a bundle -arriving after fallback committed defers (`bundle_partial`). Tests: -throws per checkpoint and hung-resume-after-fallback. - -### 7.5 Messaging module - -All `postMessage` through one module: versioned envelopes, name -constants, G4b nonces, §6.8 validation. Minimal module in Phase 1; full -migration in Phase 4. - -### 7.6 Plugins and sessions - -`tsjs.definePlugin({id, release, install})`; **no plugin-level dispose -hook** — `ctx.onDispose` only, exactly-once reverse order. -`install(ctx): void | Promise` with `ctx.signal`, unwind on -throw/reject/abort, per-disposer isolation, disposer-after-disposal -invoked immediately, pending capacity 16 / 10 s → `bundle_partial`, -release mismatch quarantined before install. Sessions: `RuntimeSession` -(bridge listener + reservation store, history hook, pbjs subscriptions, -adapters, beacon queue, cycle/drain state, in-memory diagnostic -credential), `NavigationSession` (trace + auth + renewal timer, -attempts, aliases, unissued intents, targeting history), -`RenderAttempt`/`AuctionBatch`; enumerable disposal inventories. No -empty `catch`; console logging retained (paired `warn` with the beacon -reason; `debug`-level delivery/security failures promoted). - -### 7.7 Bootstrap - -`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays -recorded calls; the no-bundle fallback is generated from the same -TypeScript source (pinned by `gpt.rs:1174-1179`), activated per §7.4. - -### 7.8 GPT correctness carried with the restructure - -Unconditional early `slotRequested`/`slotRenderEnded` subscription -(replacing `gpt/index.ts:1091`'s gate); restore #922/#997 (DR-3); -`changeCorrelator: false` (configurable); `enableSingleRequest()` only -when services are not already enabled; ambiguous responsive resolution -emits `render_terminal{failed, slot_unresolved}` alongside its warning. - -### 7.9 Decomposition targets - -| Today | Target | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index | -| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics | -| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory | -| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` | -| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal | - -### 7.10 Performance (reproducible) - -Pinned workflow (`runs-on: ubuntu-24.04` — browser CI is `ubuntu-latest` -today, `integration-tests.yml:155` — inside a pinned container digest); -lockfile-resolved Playwright with recorded browser revision -(`browser/package.json:10` is a caret range); pinned compressors -(`gzip -9 -n`, `brotli -q 11`). Vectors: minimal `[core]`; reference -`[core, creative, gpt, prebid, datadome]`; maximal all 13. Budgets vs -checked-in baselines (+5% bytes; baseline records image/browser/tool -versions and is invalid if any differ). Browser timing: -`performance.mark("tsjs:bids-script")` → -`performance.mark("tsjs:first-display")`; reference fixture; warm cache; -local resources; 5 warm-ups, 50 samples, nearest-rank p90 ≤ baseline × -1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail. -**Retained-heap budget (named accurately — this measures retained, not -transient allocation peak):** via the Playwright CDP session — -`HeapProfiler.collectGarbage` then `Runtime.getHeapUsage`, sampled at -five fixed points (post-boot, post-adInit, post-first-render, -post-refresh, post-SPA-navigation) on the maximal vector; metric = max -retained sample; gate ≤ baseline × 1.10; same rerun rule. (Transient -allocation peak is out of scope; if a future leak needs it, add -`HeapProfiler` continuous sampling as a separate budget.) Server -benchmark: the G5 lookup path; 100 warm-ups, 1,000 iterations; median + -p90 one-sided ≤ baseline × 1.10; 3-run 5% agreement or inconclusive. - -### 7.11 Toolchain - -TypeScript floor to the resolved 5.9 line; release-gating flags -`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, -`verbatimModuleSyntax`, `noImplicitOverride`, -`useUnknownInCatchVariables`. The gate is a **checked-in package.json -script**, `"typecheck": "tsc -p tsconfig.json --noEmit"`, invoked so the -lockfile-resolved compiler is used: +All strings and numbers already satisfy their canonical auction/projection bounds; +`adId` is the exact admitted `r1_` reservation, and no renderer descriptor, ADM, cache +coordinate, or other capability crosses this boundary. The empty `ad` is deliberate: +the TS bridge resolves the already-stored tagged render source only after the PUC +claim. The adapter owns the bound Prebid object, registered `trustedServer` bidder +adapter, and the exact 10.26.0 response-admission callback; neither the external +artifact stamp nor publisher code exposes this method. + +All validation, reservation insertion, exact replacement of the TS bid's `adId`, and +frozen bid-object construction complete before the call; no targeting or other +publisher-visible mutation precedes it. The boundary returns exactly +`admitted | not_admitted`, and the 10.26.0 artifact fixture proves `not_admitted` +leaves no bid/event/targeting state. `not_admitted` tombstones the reservation and settles +`failed{reason:'prebid_admission_failed'}`. A throw settles the same failure; detected +partial publication tombstones the id, suppresses every later PUC request, settles +`failed{reason:'prebid_contract_violation'}`, and fails the artifact-conformance +gate. Runtime handling is fail-closed even though the same violation blocks future +release. A Prebid version change requires a reviewed contract-fixture update. + +`admitted` moves the reservation into an `awaiting_prebid_selection` state; it does +not create a render attempt or transfer permanent ownership. The Prebid adapter's +early synchronous `auctionEnd` listener uses the supported artifact's exact +auction-id/ad-unit winner query before publisher targeting callbacks. It promotes +only the exact selected TS `adId` to a render attempt and atomically tombstones every +other TS reservation admitted for that auction/ad unit as unselected. A ten-second +watchdog from admission performs the same tombstoning and records +`prebid_selection_timeout` if no matching `auctionEnd` arrives. Navigation or auction +abort tombstones the whole admitted set immediately. Subsequent GPT/render failure +follows the normal terminal lifecycle for the selected reservation. A fast Universal +Creative request can never race ahead of reservation lookup, and a losing bid cannot +hold capacity until the 15-minute tombstone expiry as a live entry. + +### 3.6 Static renderer and APS runner proxy endpoints + +`/integrations/aps/renderer/v1` and `/integrations/aps/runner.js` are always-reserved +Trusted Server routes. When APS is enabled, `GET` returns the local static renderer +or proxies the APS-hosted creative runner respectively. When APS is disabled, `GET` +returns a local `404 no-store`; neither route ever falls through to a publisher +origin. The family is dispatched before publisher auth, EC, and generic integration +filters. All adapters expose the same method, routing, security-header, and failure +semantics. Unsupported methods return local `405` with `Allow: GET`; unknown +renderer versions and the abandoned `/integrations/aps/runner/v1.js` shape return a +local `404 no-store`. + +The renderer v1 body and headers are immutable and served with a long-lived immutable +cache policy; a renderer-body or CSP semantic change requires a new renderer route +version. The document is static and contains no descriptor data. Its iframe +`sandbox` attribute and response CSP `sandbox` directive contain exactly this token +set, serialized in this order: + +```text +allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation +``` + +They omit `allow-same-origin`, `allow-top-navigation`, downloads, modals, +presentation, orientation lock, and storage-access escape. The exact renderer v1 CSP +header is: + +```text +default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' 'self' https:; connect-src https:; frame-src https: data: blob:; img-src https: data: blob:; media-src https: data: blob:; style-src 'unsafe-inline' https:; font-src https: data:; worker-src https: blob:; form-action https:; +``` + +The broad HTTPS resource directives are confined below the opaque outer sandbox and +are required for APS and bidder resources; `'self'` permits the local runner proxy in +HTTP-based hermetic adapters as well as production HTTPS. The response also has +exactly `Content-Type: text/html; charset=utf-8`, +`Cache-Control: public, max-age=31536000, immutable`, +`X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. It deliberately +omits both `X-Frame-Options` and a CSP `frame-ancestors` directive so publisher and GAM +creative ancestors can embed it; the iframe/CSP sandbox, nonce, source-bound port, +and descriptor validation are the embedding boundary. It does not forward publisher +credentials or authorization in TS-owned fetches. Runner-created APS creative +resources may use APS-origin cookies under ordinary browser policy. No cookie is +authority. Any header or CSP change requires renderer v2; policy reporting is a +separate observability design. + +The runner endpoint is a thin runtime transport proxy, not an artifact store. Its +only upstream is the fixed APS URL +`https://client.aps.amazon-adsystem.com/prebid-creative.js`; no request field, +configuration value, query parameter, or header can select another target. Trusted +Server issues a credential-free, referrer-free `GET`, does not forward browser +cookies, authorization, client IP, or publisher headers, requests +`Accept-Encoding: identity`, and disables redirect following in the platform client. + +The platform HTTP abstraction exposes an internal `ProxyResponseEvidenceV1` policy +used only by this route. It preserves upstream status and security-relevant evidence +for every occurrence of `Content-Type`, `Content-Encoding`, and `Content-Length` +before the generic proxy removes headers or consumes the body. An adapter that +exposes duplicate fields separately returns every raw value. An adapter runtime that +combines duplicates may return its exact combined value only when the combination is +visible and cannot be mistaken for a valid singleton; the closed grammars below +reject any comma/list form for all three headers. It is forbidden to split a combined +value or normalize it into an apparently valid singleton. All other upstream headers +are irrelevant because the successful response drops them. + +The policy requests identity encoding, prevents redirect following, and returns the +identity body as a bounded stream or bounded buffer without transforming bytes. +Cloudflare inspects the initial Workers `Headers` values before the generic adapter +strips encoding/length; concatenated duplicate values remain combined and therefore +fail the singleton grammars. If the runtime erases encoding evidence or decodes a +non-identity response without exposing that fact, the evidence is `unavailable` and +the proxy rejects it. Fastly and Axum apply the same evidence/no-decompression +contract. No adapter may reconstruct erased evidence. + +The entire upstream operation—from dispatch through the final response-body byte—has +a five-second monotonic deadline. Deadline expiry cancels the platform pending +request, discards every collected byte, and makes any unavoidable late platform +continuation self-discard without constructing a response. It returns the same local +failure as any other proxy error and cannot outlive the ten-second APS-completion +deadline. Spin does not use its current eager `spin_sdk::http::send` path for this +policy: it calls the WASI HTTP outgoing handler with supported request options and +polls the response/body stream against a monotonic-clock pollable for the total +deadline. Failure to set the transport timeouts or cancel/drop the pending resources +is `unavailable` evidence and prevents APS from being enabled on that build. + +The proxy accepts only status `200`. `Content-Encoding` must be absent or exactly one +case-insensitive `identity` token; lists and every other coding are rejected. Exactly +one parseable `Content-Type` header is required. Its case-insensitive essence must be +`application/javascript` or `text/javascript`, with either no parameters or only one +case-insensitive `charset=utf-8` parameter; duplicate or unknown parameters and every +other media type or charset are rejected. The exact body cap is +`APS_RUNNER_MAX_RESPONSE_BYTES = 8 MiB` on every adapter. If exactly one canonical +decimal `Content-Length` matching `^(0|[1-9][0-9]*)$` is present, it is rejected +before collection when above the cap and must equal the final identity-body byte +count. Missing length is allowed; duplicate, malformed, mismatched, or conflicting +values fail. Collection stops and cancels the request as soon as streamed or buffered +bytes exceed the cap. The complete body must decode as UTF-8 without replacement; +validation never transforms the bytes. + +A successful response relays the upstream body bytes unchanged while replacing +transport headers with +`Content-Type: application/javascript; charset=utf-8`, +`Access-Control-Allow-Origin: *`, +`Cross-Origin-Resource-Policy: cross-origin`, +`X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. Upstream +cookies and all other response headers are dropped. Upstream fetch, status, media +type, content encoding, redirect, length, size, or deadline failure returns a local +empty `502 no-store`; vendor bodies are never exposed in logs or error responses. +The adversarial parity corpus executes through each real adapter transport, including +Cloudflare and Spin wasm, rather than injecting already-normalized core responses. An +adapter that cannot preserve the raw evidence, common cap, or total deadline cannot +enable APS and blocks the release. + +No APS runner bytes, digest, vendor-version record, redistribution license, update +script, generated artifact, or offline fallback is stored in Trusted Server source or +release artifacts. This design does not define runner caching behavior. The runner +route is intentionally unversioned because it represents a live APS-owned dependency, +not immutable TS-owned bytes. + +Proxying does not make the runner trusted TS code or prove that it rendered. APS +remains the runtime owner of those executable bytes and its resolve/reject semantics +are a narrow external trust dependency. The outer opaque iframe, validated +descriptor, one-shot lifecycle port, and completion deadline contain execution and +reject missing, late, or misbound signals; they cannot determine whether APS told the +truth when it invoked `resolve`. The proxy never rewrites, inspects, or repairs the +JavaScript body. + +The renderer accepts one parent-provided, nonce-bound descriptor plus the publisher +origin captured by the kernel before iframe creation. Because its opaque origin has +`location.origin === "null"`, it validates the supplied origin's shape and uses it +only to repeat the descriptor's not-publisher-origin check. It clears the nonce from +its URL and implements the TS side of `ApsRunnerContractV1`. Before runner load, it +creates a one-shot Promise and queues exactly: + +```ts +new CustomEvent('prebid/creative/render', { + detail: { + aaxResponse: renderer.aaxResponse, + seatBidId: renderer.bidId, + source: 'internal', + resolve, + reject, + }, +}) +``` +`resolve` and `reject` are the Promise's one-shot functions and are the only +non-serializable fields. The renderer resolves `/integrations/aps/runner.js` against +its own absolute Trusted Server document URL and loads only that route. It creates the +script with `crossOrigin='anonymous'` and `referrerPolicy='no-referrer'`; it does not +set SRI because the proxy relays a live APS-owned artifact rather than immutable +TS-owned bytes. Under the APS conformance contract, the runner consumes the queued +event and promises to call `resolve` only after its asynchronous handler commits the +nested creative iframe, and to call `reject` for validation, load, or render failure. +Promise resolution sends one completed result; rejection, proxy/CORS error, runner +error, or script-load error sends one failed result over the transferred lifecycle +port. Runner `load` is intermediate progress only. The static renderer owns no APS +completion timer. Proxy/CORS/script-load failure maps to `runner_no_load`; callback +rejection maps to `runner_failed`. + +A locally authored fictional fixture implements the exact proxy and +queue/resolve/reject behavior; it is not a copy, transformation, or derivative of the +APS body. Real-GAM tests exercise the live proxied APS dependency in all three +browsers and are a release prerequisite. The APS runner is allowed to change +upstream. Load failure, explicit rejection, and silence fail closed through the +existing APS-completion deadline. A changed or compromised APS runner can invoke +`resolve` prematurely or incorrectly; this is an accepted external-dependency risk +that the outer renderer cannot detect. Real-browser DOM/network conformance reduces +but does not eliminate it. V1 never loads the APS URL directly from the browser, +executes a stored fallback, treats script `load` as completion, or uses a reusable +global `postMessage` acknowledgement. + +## 4. Render lifecycle protocol + +### 4.1 State machine + +```text +created + -> waiting_for_gam_and_claim | rendering_direct +waiting_for_gam_and_claim + -> waiting_for_owner | failed | cancelled +waiting_for_owner + -> waiting_for_insertion | failed | cancelled +waiting_for_insertion + -> waiting_for_document | waiting_for_adm | failed | cancelled +rendering_direct + -> waiting_for_document | waiting_for_adm | failed | cancelled +waiting_for_document + -> waiting_for_aps_completion | failed | cancelled +waiting_for_aps_completion + -> accepted | failed | cancelled +waiting_for_adm + -> accepted | failed | cancelled ``` -npm --prefix crates/trusted-server-js/lib run typecheck + +Transitions are methods on `RenderAttempt`, not ad-hoc flag mutation. Each method +checks the expected state and terminal latch. Timers are created at the transition +whose deadline they enforce and are cleared by the transition that settles them. + +An accepted transition first atomically promotes durable DOM/targeting ownership +from the attempt into one `CommittedRenderArtifact` owned by the exact slot and +navigation. The attempt disposer removes only uncommitted resources; promotion +detaches the committed iframe, targeting snapshot, and physical-slot metadata before +the terminal latch disposes the attempt. Direct TS iframes are removed by artifact +replacement or navigation disposal. PUC content remains owned by its physical GPT +slot: for a TS-owned slot the artifact may dispose it only through safe GPT +destroy/redefine, while publisher-owned slot DOM remains publisher-controlled and +the artifact releases only TS metadata and compare-restorable targeting. Before a +slot publishes another accepted artifact it disposes the prior artifact. Navigation +disposes artifacts according to those same ownership/quarantine rules. Claim, +registration, owner-control, and renderer-document ports/listeners that are no +longer needed close after terminal settlement and are never promoted. + +### 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 +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. + +The bridge is one capture-phase dispatcher installed as the first reversible core +effect in the synchronous activation barrier, before any integration-module +activation and before any TS-owned GPT or Prebid script injection. This guarantees +it precedes native non-capture listeners installed later by TS while leaving no +dispatcher active during asynchronous preparation; +publisher capture listeners that already ran are inside the publisher trust +boundary. The runtime's dispatcher owns both the initial-request branch below and +the owner-registration branch in §4.3; no second global listener exists. It performs +this order: + +1. Perform only minimal, side-effect-free recognition. For a JSON string or a + clone-safe plain object, inspect own data properties only and extract string + `message`, `adId`, and an optional string `lifecycleTicket`. Do not read accessors + or traverse prototypes. Malformed or unrecognizable data is ignored. +2. Route `message === 'TS Render Owner Register'` to §4.3. If + `message !== 'Prebid Request'`, ignore it. For `Prebid Request`, look up the + extracted `adId` in the global reservation/tombstone store before exact parsing, + port, or slot-local checks. +3. For a non-TS id, do not suppress native Prebid. For a live or tombstoned TS id, + immediately call `stopImmediatePropagation()` so an extended/object-form request, + wrong port count, stolen capability, or replay cannot fall through. +4. Only after suppression, require the supported exact JSON-string shape and exactly + one transferred port. A recognized TS id with invalid shape/port is generically + refused when a usable port exists; all available ports are closed. It never + reaches native Prebid. +5. The first exact live claim during the compatible active GPT cycle acquires the + authoritative PUC `WindowProxy` from `MessageEvent.source` and stores that source + with its response port. This is the only source acquisition step; SafeFrame + ancestry is neither inspected nor guessed. Later owner messages must come from + this exact source. The unguessable reservation id, exact active slot/generation, + and one-time consumption authorize the first claim. Same-realm publisher code is + already inside the documented trust boundary. +6. Join the buffered claim with an attributable nonempty `slotRenderEnded`. A claim + that arrives first is bounded by the owning GPT-cycle/attempt deadline, discloses + no render data, and holds only its source and port. A nonempty GAM result that + arrives first starts a three-second claim deadline. Only when both conditions are + true does the runtime atomically revalidate and consume the reservation. +7. Empty GAM, navigation disposal, supersession, an incompatible cycle, or the + attempt deadline closes a buffered port, tombstones the reservation, and settles + the attempt. A second claim is generically refused; it never replaces the first. + +The successful outer response is an exact JSON string: + +```ts +{ + message: 'Prebid Response' + adId: string + renderer: string + rendererVersion: '3' + tsOwner: { + version: 1 + status: 'ready' + kind: 'aps' | 'adm' + lifecycleTicket: string + } +} +``` + +`renderer` is the checked-in TS dynamic-owner program. `lifecycleTicket` is +`t1_` plus 22 unpadded base64url characters from 16 CSPRNG bytes, bound to the +attempt, generation, source, and reservation, with a fixed three-second TTL from +posting the outer response. No descriptor or ADM appears in the outer response. A +recognized TS id that cannot be served receives the same response shape with +`tsOwner:{version:1,status:'refused'}` and no other `tsOwner` keys; the dynamic owner +rejects immediately, causing PUC to emit its ordinary `adRenderFailed`. If no usable +response port exists, the listener can only suppress and close available ports. +The claim deadline expiry is `bridge_claim_timeout`. + +### 4.3 Owner-control registration + +PUC executes a dynamic renderer in a hidden `__pb_renderer__` iframe, so a global +message posted by that hidden frame cannot satisfy source binding. The TS owner must +instead call PUC's supplied `h.sendMessage(type,payload,onResponse)` helper. PUC +adds `adId`, serializes the request, sends it from the original PUC frame, and +creates the response channel. + +The owner calls: + +```ts +h.sendMessage( + 'TS Render Owner Register', + { version: 1, lifecycleTicket }, + onRegistrationResponse +) +``` + +The kernel therefore receives exact JSON +`{message:"TS Render Owner Register",adId,version:1,lifecycleTicket}` from the +captured PUC `WindowProxy` plus exactly one helper-created response port. It +atomically consumes a live ticket and checks the exact `adId`, source, attempt, and +generation. Success posts exact JSON +`{message:"TS Render Owner Registered",adId,version:1,lifecycleTicket}` on that +response port and transfers exactly one newly-created owner-control port. Refusal +posts exact JSON `{message:"TS Render Owner Refused",adId,version:1}` with no +transferred port. These are the only registration responses. + +This message is received by the same capture-phase dispatcher from §4.2. Its +registration branch first looks up the minimally extracted `lifecycleTicket` in the +runtime ticket/tombstone map. An unknown ticket is ignored for native/publisher +listeners. For a live or tombstoned TS ticket it immediately calls +`stopImmediatePropagation()` before exact JSON-shape or port validation, then checks +the captured source, exact `adId`, ticket, attempt, generation, and exactly one port. +A recognized invalid/replayed request is generically refused when a usable port +exists and all available ports are closed. Ticket settlement, consumption, or +attempt disposal replaces its live entry with a tombstone through the original fixed +ticket expiry; expiry prunes either live or tombstoned state. The dispatcher itself +is runtime-owned; attempt disposal performs that registry transition and removes +attempt handlers, not the global dispatcher. + +The owner starts a three-second watchdog before invoking `h.sendMessage`, calls the +helper's returned stop-listening disposer after the first response, and rejects on +timeout, refusal, malformed data, or a port count other than one. The kernel owns +the opposite control port and the owner owns the transferred port. Replay, wrong +source, wrong port count, stale generation, or expiry cannot bind a channel. Kernel +and owner each have a terminal latch; late registration, acknowledgement, +settlement, or watchdog callbacks close their ports and remain inert. + +### 4.4 APS document and runner acknowledgement + +After registration, the kernel posts this exact control message and transfers +exactly one renderer-document port: + +```ts +{ + message: 'TS APS Start' + version: 1 + lifecycleTicket: string + rendererUrl: string + envelope: { + version: 1 + nonce: string + publisherOrigin: string + renderer: ApsRendererV1 + } +} +``` + +The kernel owns the opposite document port. The owner creates exactly one iframe at +the versioned renderer URL with the nonce in its fragment, reports exact +`{message:"TS Owner Inserted",version:1,lifecycleTicket}` on the control port, and +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 static document sends only these exact document-port messages: + +- `{message:"TS APS Document Accepted",version:1,nonce}` after nonce and descriptor + validation; +- `{message:"TS APS Runner Loaded",version:1,nonce}` when the runner script loads, + as nonterminal progress; +- `{message:"TS APS Render Completed",version:1,nonce}` when the queued APS render + event invokes its one-shot success callback; +- `{message:"TS APS Render Failed",version:1,nonce,reason}` where `reason` is + `descriptor_invalid | runner_no_load | runner_failed`. + +Insertion has a one-second deadline, document acceptance has a three-second +deadline from iframe insertion, and the kernel is the sole owner of the ten-second +APS-completion deadline beginning at document acceptance. Callback silence at that +deadline maps to `runner_failed`; the static renderer never starts a competing +completion timer. Script load never accepts. Failure, timeout, port error, +supersession, or navigation disposal settles once. On a direct path the kernel +removes its exact pending iframe. On a PUC path the iframe is remote DOM owned only +by the dynamic owner; the kernel never claims it can remove that node and instead +posts exactly one owner-control settlement: + +```ts +type OwnerSettlementV1 = + | { + message: 'TS Owner Settled' + version: 1 + lifecycleTicket: string + outcome: 'accepted' + } + | { + message: 'TS Owner Settled' + version: 1 + lifecycleTicket: string + outcome: 'failed' + reason: RenderFailureReason + } + | { + message: 'TS Owner Settled' + version: 1 + lifecycleTicket: string + outcome: 'cancelled' + reason: 'caller_aborted' | 'superseded' | 'navigation_disposed' + } +``` + +The PUC owner owns the remote node, its DOM handlers, and its side of the control +port. An accepted settlement promotes that exact iframe as committed, removes its +temporary handlers, closes the control port, and resolves the renderer Promise once. +A failed or cancelled settlement removes the exact uncommitted iframe, removes its +handlers, closes the port, and rejects once so PUC emits its ordinary render-failure +event. The same cleanup applies to the PUC ADM/cache owner in §4.5. + +When registration accepts the transferred control port, before waiting for an APS or +ADM start message, the owner arms one fail-closed 20-second settlement/channel +watchdog. Start does not extend or rearm it. The deadline is longer than every +kernel-owned insertion/document/render deadline and also covers registration-to- +start loss. The owner cancels it on settlement. A malformed control message, +`messageerror`, local owner disposal, or watchdog expiry performs the failed/ +cancelled cleanup above and rejects once; a silently closed or lost control channel +is therefore bounded. This watchdog is remote resource cleanup only and cannot +report acceptance to the kernel or change its already-terminal outcome. A +settlement-post throw is isolated in the kernel because the remote watchdog owns +this failure path. + +A caller `AbortSignal` remains attempt-owned after owner registration. If it wins +the terminal latch, the kernel closes its renderer-document channel and sends the +exact cancelled/`caller_aborted` settlement; direct rendering also removes the +kernel-owned iframe. The PUC owner performs the remote cleanup just specified. A +later insert, load, document message, APS callback, settlement, or watchdog is inert. + +The winning descriptor dimensions are also the exact layout contract across all +three nested documents. Before inserting its renderer iframe, the PUC owner sets its +own document root and body to zero margin/padding with hidden overflow, then creates +one block iframe with matching positive width/height attributes and CSS pixels, zero +border, and no scrollbars. The static renderer document has the same zero +margin/padding and hidden-overflow root/body contract before loading the APS runner. +The runner-created descendant creative is expected to occupy the same viewport. A +300×250 winner therefore has 300×250 `clientWidth`, `clientHeight`, `scrollWidth`, +and `scrollHeight` in the PUC owner and renderer documents, and a 300×250 descendant +viewport, with no default eight-pixel body margin, clipping, or overflow. Equivalent +assertions run for every boundary fixture dimension. This is layout correctness, not +render completion; the callback contract above remains the acceptance authority. + +### 4.5 ADM/cache ownership + +Cache first resolves to bounded, validated ADM through the transport in §3.1. Direct +and PUC ADM/cache use one TS-authored iframe constructor and this exact ordered +sandbox value: + +```text +allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation +``` + +It omits `allow-same-origin`, downloads, modals, presentation, pointer lock, and +storage-access escape. The iframe has `referrerPolicy='no-referrer'`, exact positive +source dimensions in both attributes and CSS pixels, zero border/margin, hidden +overflow, `display:block`, `scrolling='no'`, title `Ad content`, and aria-label +`Advertisement`. + +The constructor creates the iframe detached, installs one-shot `load`/`error` +handlers and disposal state, builds the complete ADM document, assigns exactly one +`srcdoc`, and only then appends the iframe once. It never appends an empty iframe, +sets `src`, or performs a TS-authored replacement navigation. A `load` is accepted +only when the exact frame is still the pending frame for the current attempt and +navigation generation, the intended `srcdoc` was assigned before append, and the +terminal latch is open. Initial `about:blank`, pre-assignment, removed-frame, +replaced-frame, stale-generation, post-disposal, duplicate, and error events cannot +accept. Error, removal before acceptance, or the five-second deadline fails +`adm_document_no_load`. Real-browser tests force initial-blank, replacement, +removal, error, timeout, supersession, and stale-load orderings. + +For PUC ADM/cache, the trusted TS-authored owner—not bidder creative code—uses the +same registration protocol. The kernel sends exact +`{message:"TS ADM Start",version:1,lifecycleTicket,source:AdmRenderSourceV1}` on the +owner-control port with no transferred port. The owner reports +`TS Owner Inserted` only after the shared constructor appends exactly one owned +iframe, and reports exact +`{message:"TS ADM Loaded",version:1,lifecycleTicket}` or +`{message:"TS ADM Failed",version:1,lifecycleTicket}`. The kernel answers with one +`OwnerSettlementV1`; only that settlement resolves or rejects the PUC renderer +Promise. Insertion has a one-second deadline and load has a five-second deadline. +Direct ADM uses the same constructor and accepts through its own terminal latch on +the intended load. No secret or capability is injected into bidder-controlled +markup. + +### 4.6 Channel ownership and parsing + +| Channel | Creator | Retained endpoint | Transferred endpoint | Lifetime | +| --------------------- | ------------------------- | ------------------- | ----------------------------------------- | ------------------------------------------------- | +| outer PUC response | PUC `prebidMessenger` | original PUC frame | kernel global listener | one ready/refused response or claim disposal | +| registration response | PUC `h.sendMessage` | original PUC helper | kernel global listener | one registered/refused response or owner watchdog | +| owner control | kernel after registration | kernel attempt | hidden TS dynamic owner | insertion through final owner settlement | +| renderer document | kernel before APS start | kernel attempt | exact static APS renderer `contentWindow` | document acceptance through APS completion | + +Global window messages are JSON strings with the exact keys specified above. Port +payloads are structured-clone objects with the exact keys specified above. Every +parser rejects accessors, wrong prototypes, unknown keys, wrong literal/version, +wrong port counts, oversized strings, and already-consumed capabilities before +performing a state transition. The disposer clears handlers, closes both locally +owned ports where possible, and makes queued callbacks generation-inert. + +The shared protocol corpus fixes these bounds and encodings: + +- before `JSON.parse`, an inbound global-dispatcher string is at most 4,096 UTF-8 + bytes; a larger value is unrecognizable and causes no property access or state + lookup; +- a TS `adId` is exactly the 25-character `r1_` reservation form; a lifecycle ticket, + renderer nonce, and attempt id are exactly the respective 25-character `t1_`, + `n1_`, and `a1_` forms from §2.2; +- `adServerDomain` is nonempty and at most 2,048 UTF-8 bytes. It is retained only for + exact PUC-shape conformance and is never a fetch target or authority; +- `publisherOrigin` and `rendererUrl` are at most 2,048 UTF-8 bytes. The former must + serialize an exact HTTP(S) origin with no path/query/fragment; the latter must equal + the current generation's absolute `/integrations/aps/renderer/v1` URL with no query + or fragment, after which the owner appends the exact `n1_` nonce fragment; +- a navigation/refresh generation is a nonnegative safe integer; and +- the generated dynamic-owner `renderer` program is at most 64 KiB UTF-8 and the + complete successful outer-response JSON is at most 72 KiB. Build tests enforce + both; refusal responses contain no renderer. + +Structured-clone port payloads use their exact field-level limits: APS descriptor +256 KiB decoded AAX; ADM 512 KiB; `creativeUrl`, cache `baseUrl`, and cache +`fetchUrl` 4,096 UTF-8 bytes; `publisherOrigin`, `rendererUrl`, and +`adServerDomain` 2,048 UTF-8 bytes; server slot id 1–256 UTF-8 bytes with no NUL or +ASCII control; and the fixed +capability forms above. No generic unbounded string remains. Boundary-minus-one, +boundary, boundary-plus-one, multi-byte UTF-8, duplicate-key, and malformed-encoding +cases run through the producer plus both the global dispatcher and port parsers. + +### 4.7 Notifications + +APS has no `nurl` or `burl`; none is synthesized. Existing notifications on other +bid formats remain nonblocking and exactly-once per accepted lifecycle transition. +Notification transport failure cannot change a render outcome. Redesigning or +measuring notification delivery is outside this spec. + +## 5. TSJS target architecture + +### 5.1 Layers + +```text +kernel/ boot, registry, queue, event bus, sessions, disposal, logging +adapters/ googletag, prebid, messaging +services/ slots, auction batches, render lifecycle, consent +integrations/ gpt, prebid, aps, creative, and existing publisher integrations +composition/ the sole construction root for adapters, services, and integration modules ``` -(the script runs `tsc` from the package's own `node_modules/.bin`, so no -`npx` path ambiguity). Dev toolchain bumps as individual CI-gated PRs; -`prebid.js` excluded from casual bumps; monthly review. - -## 8. Rollout - -Single-release state machine per §0. **Statistical method:** sampled -traces only (diagnostic reported separately); **randomization unit = -`assignment_id`** (minted inside the affinity token, §0; persisted on -client-event rows), so attempts cluster by session; the estimator is a -**checked-in cluster bootstrap** (`scripts/gates/estimator.py`): -resample assignment ids, 2,000 resamples, fixed seed recorded in the -gate artifact, strata (publisher × slot) weighted by control-arm -traffic share; one-sided 95% confidence bounds on **relative -differences**; each gate is an independent go/no-go (no cross-gate -multiplicity correction — stated). **"Missing telemetry counts as -failure" is made enforceable for whole-missing traces:** the server -writes a **sampled exposure row** (`ts_expected_attempts`, one per -server-observed eligible APS win in a sampled trace) at auction time; -gates **left-join client attempts to expected rows**, so a trace whose -client events never arrive is a visible missing attempt (not an absent -row that silently shrinks the denominator). A per-window -client-transport completeness ratio below 90% makes the statistical gate -**inconclusive** rather than passing on a biased sample. Per-flow floors; -rare flows (direct, fallback) gate hermetically + real-GAM, never -statistically. `cycle_unattributable` divides by all -attribution-candidate TS cycles. - -**Billing is the one gate the cluster bootstrap cannot run**, because GAM -aggregate reports expose only per-`ts_arm` totals, not session clusters. -Two options, one chosen per DR: (a) source billing from **GAM Data -Transfer / impression-level logs**, which carry the `ts_arm` key-value -**and** a joinable impression/`attempt_id` correlator, and run the same -cluster bootstrap over impression rows; or (b) if Data Transfer is -unavailable, use a **separate pre-reviewed aggregate estimator** with the -**day** as the randomization unit (a two-sample non-inferiority test over -daily per-arm RPM across the billing window), declared in the gate -artifact. Whichever is chosen, `ts_arm` is a **reserved, network-audited -key proven untargeted by any production line item and A/A-validated -before canary** (§0, §11), so it cannot perturb the metric it measures. - -**Phase 0 decision records:** DR-1 mediator presence; DR-2 script -creatives (deployment decision); DR-3 #997 vs re-merge; DR-4 -candidate-id echo owner (`merge_highest_cpm` config-blocked until -delivered); DR-5 non-Fastly sinks (splits Phase-2 gates, scopes -persistence gates). - -Phases: **0** identity/schemas/toolchain/DRs + gate artifacts -(pipes/scripts/workflows) + observation-only control build definition; -**1** kernel/sessions/minimal messaging/cycle registry/transactional -bootstrap — Phase-1 gates are **hermetic** (in-page counters; beacon -transport does not exist until Phase 2); **2** trace + beacon + renewal - -- diagnostic upgrade + probe issuance + four-adapter ingest + per-sink - probes + the alert drill; **3** APS delivery (schema crate + corpus; - mediation; render token + reservation store; renderer route + - three-message ack + CSP route; §6.8; G4a–G4g incl. AuctionBatch; - `notification_sent`; fallback; DR-3 restoration; correlator + SRA); - **4** structure (layering, plugins, adapters, registry, messaging, - namespace; four-flow parity); **5** decomposition + bootstrap stub + - parity rerun + **full Phase-3 statistical and real-GAM gates repeated - on the exact immutable RC** (attested per A.3) before weight-up, then - cutover per §0. - -## 9. Test acceptance matrix - -Hermetic CI blocks PRs; the real-GAM suite is release-gating (A.3). - -| Area | Coverage | -| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Attempts | `attempt_id` uniqueness; parent/child fallback linkage; exactly one `render_terminal` per attempt; parent `failed{gam_empty}` precedes `fallback_start` | -| Terminal model | discriminated outcomes incl. `no_bid`/`cancelled`; per-reason `source` nullability; validity-matrix conformance | -| Request cycles | disabled-initial-load `display()` retired for any caller; TS-noop-refresh → TS-display and publisher variants (same-class supersession); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap | -| Ack per path | four G4b sequences; APS three-message; owner-observed ADM `load`/`error` with **nonce never in bidder realm**; bidder-synthesized message ignored; early-`burl` attempt fails; per-path deadlines; stale/replayed; after-disposal | -| Bridge signal | truncated/replaced `hb_adid` → `bridge_request{matched:false}` with no response and native Prebid untouched; stolen TS ids fully suppressed (neither TS nor native responds); listener order | -| AuctionBatch | multi-slot partial/full supersession; fetch aborts only when all children dead; stale-bid filtering through live child identity; timeout; navigation disposal; reversed responses; discriminated auction-client errors | -| Diagnostic | `dexp` ceiling — renewal-before-expiry capped, repeated renewal to ceiling then failure; credential byte-level vectors; fragment clearing; in-memory storage; pre-upgrade buffering then flush; forgery/wrong-origin/replay | -| Transport/limits | batch caps (64 events AND 12 KiB); ≤ 4 batches/flush; sustained single-tab, 5-tab, diagnostic flush, pagehide burst inside the 60/120 budget; overflow coalescing; Fastly synchronized-burst documented; unknown-address bucket | -| Affinity | token vectors (format, rotation, constant-time); state-dependent defaults (canary vs post-cutover reassignment; no stale-cookie stragglers); coherence for HTML/assets/APIs/beacons; **CSP affinity via `policy_id` path, cookie-less renderer reports** | -| Arms/measurement | observation-only control build emits comparable sampled telemetry (its zero-behavior-change gated by hermetic parity vs baseline); arm-specific datasources; union view stamps `deployment_pool`; `assignment_id` on rows; cluster-bootstrap fixture; GAM `ts_arm` key | -| Latency/fill | `t_rel_ms` monotonic bounds; `attempt_started`→`render_terminal` durations; per-gate numerator/denominator queries (A.1); named source tables/joins | -| Joins | `Nullable(UUID)` type equality; auction-level vs slot-level vs bid-level canonical joins; raw-join multiplication rejected | -| Probes | authenticated probe rows (`probe_run_id`/`expected_seq`/`adapter`/`target`) per datasource; secret-derived CSP probe `policy_id` unforgeable; probe issuance protocol; persistence gates scoped to sink-backed adapters | -| Alerting | injected-failure drill: alert fires ≤ 1 h | -| Kill switch | switch state via HTML and response extensions; attempts created after delivery honor it before each irreversible action (incl. before `nurl`); SSAT-on-stale-page exemption documented and tested | -| Drop enum | `empty_seatbid_bids` + `bid_id_too_large` mapped; shared typed enum across producers; compile-time exhaustiveness | -| RC attestation | real-GAM workflow consumes the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}` and emits it in the attested output; gates parameterized by (release, pool, epoch); RC re-canary inherits each row's window | -| Config/affinity | `config_hash` SHA-256 over exact bytes verified at startup (mismatch = fail); affinity HMAC vectors + rotation + constant-time | -| Heap | CDP procedure at the five fixed points; GC before sample; rerun rule | -| Lint | custom scope-aware rule catches `window`/`globalThis`/`self` member access and same-file aliases outside adapters (claim scoped to these shapes) | -| Notifications | dispatch mechanics (no-cors GET, no-referrer, keepalive; `Image()` fallback; server-side macro expansion only); `notif_id` emission; duplicate alarm + reconciliation; hermetic exactly-once | -| Mediation | required-unique bounded upstream ids (`missing`/`duplicate`/`bid_id_too_large`, no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules; provenance fail-closed scope; strategy timeouts; both lifecycles; APS + non-USD startup error | -| Render token | format/CSPRNG/retry/TTL/one-time; scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed | -| Trace auth | auth ≤ 256 B; encoding vectors; expiry/skew/max-future; renewal preserves mode; renewal-after-expiry fails; previous-key retention; deterministic sampling (exact u64 threshold; same trace → same mode concurrently) | -| Internal routes | wrong-method 405 + `Allow` + `no-store`; unknown version 404; no fall-through; dispatch before filters; no forwarding; per-family origin policies | -| CSP | both media types; opaque/null origin; policy-id path identity (forged body ignored); bucket caps + overflow counter; three-browser capture; per-version frozen header manifest | -| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX; generated validity matrix | -| ABI/plugins/boot | one kernel; exact-release verdicts; object-form release check; partial-install unwind; abort-pending; disposer-after-disposal; hung-resume self-discard; fallback-then-late-bundle deferral | -| Lifecycle | `timed_out → present`; disposal inventories; unissued intents cancelled by navigation; boot consume/freeze/delete; final-namespace smoke | -| Delivery | unknown hash 410 `no-store`; exact-match immutable; release-time vector materialization (unlisted = build error); cutover rehearsal | -| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin | -| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness; kill-switch snapshot semantics | -| Perf | marks present; three vectors; heap CDP; inconclusive-rerun; pinned-environment baseline validity | - -## 10. Alternatives considered (complete) - -1. **Patch APS point-failures without telemetry** — rejected: four correct - fixes produced no reliable ads; the next would be another guess. -2. **Always direct-render APS** (skip GAM/PUC) — rejected: unilaterally - changes GAM reporting/pacing; kept only as the attributed-`gam_empty` - fallback. -3. **Single module graph / shared chunks now** — rejected for this release: - changes the delivery pipeline while everything else changes; successor - option behind the same registry surface. -4. **Full rewrite in one branch without phases** — rejected: the - browser-spec safety net is thinnest exactly where behavior changes. -5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle - guarantee; the generated fallback keeps it. -6. **Timeout-triggered fallback rendering** — rejected: uncancelable GPT - requests race late fills → double-render/double-bill. -7. **Timeout-based quarantine re-arm** — rejected: recreates the - stale-event bug. -8. **N/N−1 compatibility machinery** — removed by the §0 policy decision. -9. **Client-computed notification hashes** — rejected: no key without - breaking the pseudonymization boundary; server-minted `notif_id`. -10. **Fingerprint identities for id-less bids** — rejected: can merge - distinct demand; rejection with closed reasons instead. -11. **Plain readable cohort cookie** — rejected: dark-pool opt-in + - cache-cardinality abuse; opaque authenticated token. -12. **`billing_outcome` event** — removed: no honest post-accept producer. -13. **Injected in-realm ADM reporter** — rejected: hands bidder code the - acceptance credential; owner-observed `load`/`error` instead. -14. **Cookie-routed CSP affinity** — rejected: opaque-origin reports carry - no cookie; server-selected `policy_id` path identity. -15. **Token-identity alone for arm attribution** — rejected: authorization - is not a queryable row dimension; the router injects a trusted internal - cohort header ingest stamps (§0). -16. **Indefinite diagnostic renewal** — rejected: signed `dexp` ceiling. -17. **Shared attempt identity for parent/child** — rejected: distinct - `attempt_id`. -18. **24 h affinity for a multi-day experiment** — rejected: the affinity - lifetime now covers the maximum experiment window (§0). -19. **`Image()` notification fallback** — rejected: it constructs a - separate credentialed/referrer-bearing request outside the primary - transport's privacy contract; a failed dispatch reports `failed` - instead (§G4d). - -## 11. Risks (complete register) - -- **Hard-cutover blast radius** — accepted by policy; bounded by the §0 - runbook (probes, low-weight canary, monitored window, weight-back). -- **Sticky-cohort routing is new infrastructure** the cutover depends on — - Phase 0 work; its coherence and affinity-lifetime tests are - release-gating. -- **Mediator wire-contract change** (`candidate_id` echo) — DR-4 gates - `merge_highest_cpm`; config validation enforces the block. -- **Published notification triggers** become a contract for PBS-path - demand — changing them later breaks SSP reporting. -- **Required `[auction].currency`/`winner_selection`** in mediated - deployments are a deliberate startup-error class under §0. -- **Beacon abuse** — pre-parse caps, per-family origin policies, - fail-closed numeric limits, signed modes, credentialed diagnostics. -- **Registry/limiter memory** — explicit capacities, TTL reclamation, - reject-at-capacity; Fastly overshoot documented, not claimed. -- **CSP data is advisory** — never a sole automatic rollback signal. -- **Sink blindness** — per-datasource authenticated probes. -- **ABI freeze** — `tsjs._internal.registry` is load-bearing; exact-release - verdicts are the contract. -- **Schema generation** — checked-in artifacts + staleness CI. -- **Observation-only control build** is new scoped work whose "zero - behavior change" property is itself gated by hermetic parity vs baseline - **and an A/A pre-canary** (the `ts_arm` GAM key must move no metric). -- **`assignment_id` persistence** is pseudonymous but new — bounded by the - affinity token lifetime and excluded from any identity join by schema - review; it reaches telemetry only via the router-injected trusted header - (§0), never a client field. -- **`ts_arm` GAM targeting key** could itself perturb line-item matching — - reserved and audited network-wide, proven untargeted by production line - items, and A/A-tested before canary (§8). - -## 12. Success criteria - -1. APS creatives render in each configured flow, hermetically and in the - attested real-GAM suite. -2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the - failing class from one page load; §5.7 SLIs hold, including the alert - drill. -3. Both lint families (incl. the custom scope-aware rule) pass; stateful - sharing only via the registry; exact-release mismatches quarantine - loudly. -4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or - generated. -5. Exactly one `render_terminal` per `attempt_id`; parent/child fallback - attempts are distinct rows; attempt aggregation keys on `attempt_id` - with the tuple as grouping only. -6. The only TSJS-owned global is `window.tsjs` (§7.4); no expandos. -7. §7.10 budgets hold, including the CDP heap procedure. -8. No existing warning lost; issue-surfacing conditions log `warn`+ with - the beacon reason. -9. TypeScript floor and flags via the checked-in `typecheck` script; - `prebid.js` pin documented. -10. `nurl`/`burl` only on carrying paths at their binds with the - normative dispatch mechanics; APS fires neither; hermetic - exactly-once + production alarm + reconciliation. -11. Trace-bearing responses `private, no-store`; authorizations signed, - mode-carrying, renewal-preserving, diagnostic bounded by `dexp`; - unsampled transmits nothing. -12. Cutover rehearsed; config-hash verification enforced; post-cutover - routing defaults flip (no stale-cookie stragglers). -13. Phase-3 statistical and real-GAM gates pass on the attested - immutable RC before weight-up. -14. Appendix A shipped with this design; changes carry decision records. -15. Baseline APS fix behaviors re-implemented; baseline browser tests - pass unmodified. - -## 13. Open questions - -One: does Amazon expose any creative-completion acknowledgement that -could add a post-`accepted` state under a new name (future -enhancement)? - ---- - -## Appendix A — Normative rollout gates (initial values) - -Roles: **RO** release owner, **QA** QA owner, **OPS** on-call. -Randomization unit: `assignment_id` (§0). Statistical gates: sampled -traces, cluster bootstrap (§8), canonical views only, checked-in -artifacts (`tinybird/pipes/gate_*.pipe`, `scripts/gates/*.sh`, -`.github/workflows/*`). Every statistical row specifies -numerator/denominator/source; missing rows count as failure. "Hold" = -weight frozen; "Rollback" = weight back + re-purge. Changes require -decision records. Low volume: inconclusive → extend once → Hold. - -### A.1 Phase gates - -| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action | -| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------- | ----- | -------- | -| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold | -| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold | -| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold | -| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold | -| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold | -| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold | -| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold | -| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold | -| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold | -| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback | -| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold | -| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback | -| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback | -| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback | -| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback | -| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback | -| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold | -| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold | -| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold | -| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback | -| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; per-flow `render_terminal{failed}` rate vs pre-cutover canary | 10,000 attempts/flow | lag ≤ 5 min; loss < 0.1%; per-flow failed ≤ canary + 0.5 pt (flow-weighted; a below-floor flow holds, not passes) | 24 h | OPS | Rollback | - -### A.2 Expected stages per flow - -| Flow | Expected sequence | Stage thresholds (named denominators) | -| --------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_terminal{accepted}` | `targeting_set`/eligible wins ≥ 98%; each later stage ≥ 95% of prior; document/`bridge_response_sent` ≥ 99%; runner fail+timeout ≤ 1% of document | -| prebid | same (keyed by Prebid `adId`) | same | -| page_bids | same (post-SPA-navigation) | same | -| direct | `attempt_started → renderer_document_loaded → render_terminal{accepted}` | document ≥ 99% of attempts; accepted ≥ 95%; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) | -| fallback | parent `render_terminal{failed, gam_empty}` → child `fallback_start → renderer_document_loaded → render_terminal{accepted}` | `fallback_start`/eligible parent `gam_empty` ≥ 99%; accepted ≥ 95% of starts; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) | - -### A.3 Real-GAM suite (attested) - -| Field | Value | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) | -| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** | -| **Independent verification** | **the workflow does not trust its input**: it fetches the deployed pool's runtime `release_id`/`config_hash` from a trusted control-plane endpoint, hashes the actually-served bundle bytes, verifies the deploy-provider binary provenance and the pool/epoch, and **compares all of them to the manifest — any mismatch fails the run** (so a green run cannot claim manifest X while exercising deployment Y) | -| **Output** | **OIDC-backed signed provenance** embedding the verified (release, pool, deployment epoch), not a caller-supplied echo | -| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession | -| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) | -| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo | -| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) | -| Command | `npx playwright test --config real-gam.config.ts` | -| Artifact | Playwright HTML report + trace zips, retained 90 days | -| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures | -| Approval evidence | green attested run URL in the release checklist, signed off by RO | +- Kernel imports no adapter, service, or integration. +- Adapters import kernel contracts only and are the sole readers/writers of GPT, + Prebid, and cross-window messaging globals. +- Services import kernel and adapter interfaces. +- Integrations compose services and never import another integration. +- Composition imports all layers, constructs one `RuntimeSession`, and injects + adapters/services through interfaces. Kernel sessions never import or construct a + concrete adapter or service. +- Layering is enforced by ESLint restricted paths. +- A custom scope-aware lint rule rejects GPT/Prebid global access outside adapters, + including same-file aliases of `window`, `globalThis`, `self`, `googletag`, or + `pbjs`. + +### 5.2 One runtime across IIFE bundles + +Each shipped integration remains a separately built IIFE with imports inlined, so +module singletons cannot be the shared-runtime mechanism. `tsjs-core` installs the +only runtime and keeps its service registry in the composition closure. During boot, +the temporary `_registerIntegration` handshake collects each accepted module; the +composition root alone invokes its exact frozen preparation/activation contexts. +After commit the handshake is permanently refusing and `tsjs._internal` exposes only +the frozen status described in §5.4. + +Every other bundle registers through: + +```ts +tsjs._registerIntegration({ id, release, prepare }) +``` + +This is a release-internal bundle handshake, not a publisher extension API. An +**integration** remains the product capability; an **integration module** is only +that integration's transactional TSJS implementation unit. The design introduces +no separately installed or third-party plugin system. + +The build first emits every production bundle with the same fixed release sentinel, +then computes one `releaseId`: 64 lowercase hexadecimal SHA-256 characters over a +canonical ordered manifest containing every bundle id and its sentinel-normalized +bytes. It replaces exactly one sentinel in each bundle and verifies none remains. +This avoids a self-referential hash while changing the id for any logical bundle or +ordering change. The same value is embedded in core and every integration bundle. +Before core is injected, the server emits this exact manifest: + +```ts +interface BootManifestV1 { + readonly version: 1 + readonly releaseId: string + readonly integrations: readonly { + readonly id: string + readonly required: true + }[] +} +``` + +Integration ids match `^[a-z0-9][a-z0-9_-]{0,63}$`, are unique, and appear in the actual +server injection order. The list contains exactly the enabled integration bundles; +all listed integrations are required for that page and there are at most 16. Core is injected first, then +integration modules in manifest order. Registration requires exact id membership and `release` +equality. Duplicate id, unknown id, wrong release, missing module, or registration +after the boot deadline fails with `abi_mismatch` or `bundle_partial`. +Integrations obtain stateful services from the registry; they never construct a +second runtime, slot registry, GPT adapter, or bridge listener. + +Integration-module startup is a two-phase transaction. Registration stores code but +does not execute it. In manifest order, core calls a synchronous or asynchronous +`prepare(ctx)` whose only legal effects are validating frozen configuration, +obtaining injected service interfaces, allocating private inert data/closures, and +registering private-memory disposers. Preparation cannot read or write ad-tech +globals, attach a listener/observer/wrapper, touch live DOM, inject a script, start a +timer/fetch, schedule detached work, invoke publisher code, or call a stateful +adapter/service method. The one Promise returned to and awaited by core, including +its ordinary `await`/settlement continuations, is permitted; no continuation may be +detached from that Promise or survive its settlement/abort. Preparation returns +exactly one prepared module with a synchronous `activate(ctx)` function. + +After every required module prepares, core enters one synchronous activation barrier +in manifest order. `activate(ctx)` may install only synchronously compare-restorable +wrappers, listeners, observers, guards, and service subscriptions. It registers the +disposer before each mutation and may stage bounded post-commit work through +`ctx.afterCommit(fn)`, but cannot inject/load a script, start network/timers, schedule +work, drain a publisher queue, or invoke publisher callbacks directly. The core +dispatcher and correctness-critical GPT listeners are the first reversible core +activations in this same barrier, not effects left live during asynchronous +preparation. If any activation throws, core synchronously runs every activated and +prepared disposer once in reverse order before committing fallback. Since the +barrier never yields and activation cannot call publisher code, no publisher task +can observe a partial generation. + +The activation barrier checks the same monotonic boot deadline immediately before +and after every `activate` call and once more before kernel handoff. Elapsed time +greater than or equal to 10,000 ms synchronously unwinds and commits fallback even +when the timer task has not run. JavaScript cannot preempt an activation function +that never returns; a malicious/nonreturning same-realm module can freeze the page +and is an accepted platform limitation, not a second-runtime recovery case. + +After all activations succeed, core commits the complete kernel API, runs staged +`afterCommit` callbacks in manifest order, and only then drains the preload queue. +Those callbacks may synchronously start scripts, timers, readiness work, and baseline +DOM scans; publisher code they intentionally invoke therefore sees the complete +kernel. A callback throw is isolated to its module, runs that module's remaining +disposers, records a bounded local runtime failure, and makes affected operations +fail through their typed readiness/render result; it cannot roll back an already +published kernel or create a fallback generation. + +`ctx.signal` aborts pending preparation. `ctx.onDispose(fn)` is the only disposal +registration mechanism; a disposer registered after disposal runs immediately, and +one failing disposer does not prevent the rest. Each module may call +`ctx.afterCommit` at most once, so the at-most-16 pending modules stage at most 16 +callbacks. A second call by one module throws during activation, unwinds the barrier, +and commits `bundle_partial`. The bootstrap deadline below is the only preparation +deadline; modules share its remaining budget and do not start independent ten-second +preparation clocks. + +### 5.3 Bootstrap ownership + +Bootstrap uses a generation-scoped state machine: + +```text +unclaimed -> installing -> kernel + \-> failed -> fallback +``` + +Initial namespace capture is field-wise and does not replace a publisher-created +`window.tsjs` object: `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}`. The +kernel remains externally inert and commits ownership only after all manifest +integration modules prepare and synchronously activate in order. Before module +work, bootstrap normalizes `que` to one actual Array and defines the `tsjs.que` data +property as writable false/configurable true for the installing generation. It keeps +the ingress Array's native `push` +throughout `installing`. Thus ordinary assignment cannot redirect the queue, and +callbacks pushed at any point during the shared deadline append to the same ingress +Array instead of a one-time snapshot. A preexisting non-Array `que` contributes no +callbacks and is replaced; an Array's existing own data entries are retained in +index order. + +Kernel and fallback use the same synchronous, non-interleavable commit handoff in one +JavaScript task. Its order is exact: + +1. create an empty actual-Array final executor, install its own immediate-execution + `push`, and freeze the Array; +2. snapshot the ingress Array's callable own data entries in ascending index order; +3. clear the ingress Array and replace its `push` with a forwarder to the final + executor for publishers retaining the old reference; +4. redefine `tsjs.que` as the final executor with a writable-false, + configurable-false descriptor while installing all other complete committed + `tsjs` fields; +5. for a kernel commit, run every staged `afterCommit` callback in manifest order; + fallback has none; and +6. drain the snapshot FIFO. + +No browser task or microtask can interleave steps 1–6. Code intentionally invoked by +an `afterCommit` callback sees the complete API and committed queue. A callback is therefore either +in the snapshot or reaches the final executor through one of the two queue +references, never lost or invoked twice. A callback that pushes while the snapshot +drains executes immediately through the committed queue before draining continues; +one throw is isolated. Both ingress and final values satisfy +`Array.isArray(...) === true`. The old ingress identity remains a live forwarding +queue; the public `tsjs.que` identity changes exactly once at commit. + +One ten-second watchdog begins immediately before core injection and covers core, +registration, preparation, and the synchronous activation barrier for every required +integration module. A preparation throw/rejection, activation throw, ABI mismatch, +or deadline aborts the installing generation, synchronously unwinds registered +disposers in reverse order, and then commits the generated no-bundle fallback. +Bundles that arrive after +fallback are rejected and quarantined; they cannot register into or replace the +fallback generation. Late continuations verify their owner generation and +self-discard. + +`gpt_bootstrap.js` becomes a queue-and-boot-data stub. The old bootstrap's +initial-load hooks, handoff wrappers, hydration scheduler, slot definition, +targeting, display, and refresh are deleted. Those behaviors run only after the +complete runtime commits. This intentionally changes the missing/partial-bundle +case: it no longer attempts a best-effort GPT render through a duplicated degraded +runtime, and instead settles every known slot through the terminal fallback below. +The fallback is generated from one TypeScript source and pinned by a staleness test; +behavior is not hand-maintained in both ES5 and TypeScript. + +The fallback is a terminal, non-rendering shell, not a reduced second runtime. Its +commit atomically records one immutable boot failure reason: + +- `abi_mismatch` for invalid manifest shape, duplicate/unknown integration id, wrong + release, duplicate registration, or incompatible ABI; or +- `bundle_partial` for a missing required integration module, preparation + throw/rejection, activation throw, or the shared boot deadline. + +Before draining user work it installs `version:'1.0.0'`, the embedded `releaseId`, a +safe frozen `TsjsBootV1`, the final `tsjs.requestAds` input validator, the +validating-then-refusing `tsjs.addAdUnits`, the local `tsjs.log`, the +immediate-executor `tsjs.que`, a permanently refusing internal +`_registerIntegration`, and a frozen +`tsjs._internal` value containing only `{state:'fallback',releaseId,reason}`. It +constructs no runtime session, slot registry, GPT/Prebid adapter, bridge dispatcher, +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, +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 +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 +that validated immutable `tsjs.boot.auctionProjection` snapshot. Explicit valid ids present in that snapshot, +and every omitted-slot snapshot entry in projection order, resolve once as +`failed{path:'primary',reason:}`. An explicit id absent from the +projection resolves `slot_unresolved`; an already-aborted signal resolves each known +member as `cancelled{reason:'caller_aborted'}`. An empty projection plus omitted slots +resolves `{slots:[]}`. Input-shape errors still reject with `RequestAdsInputError`. + +After installing those surfaces, fallback drains the preexisting callback queue FIFO +exactly once with `this === tsjs`; one callback throw does not prevent later callbacks. +Subsequent `que.push(fn)` executes a callable immediately once and ignores non-callable +values. Every late module registration is refused without invoking integration code, and +every late bundle continuation self-discards. Browser tests cover each failure +checkpoint, queued and later `requestAds`, callback throws, already-aborted signals, +and late bundles; no valid call remains pending. + +### 5.4 Public surface after cutover + +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` | + +`window.tsjs.que` remains the pre-load command queue because it is the bootstrap +transport, not a legacy behavior alias. + +The complete committed public API is the following union; the pre-load +`{que,boot}` transport is not a committed API generation: + +```ts +interface CreativeBootV1 { + readonly version: 1 + readonly enabled: boolean + readonly clickGuard: boolean + readonly renderGuard: boolean +} + +interface TsjsBootV1 { + readonly abi: 1 + readonly releaseId: string + readonly manifest: Readonly + readonly auctionProjection: Readonly + readonly cachePolicy?: Readonly + readonly creative: Readonly + readonly diagnostics: Readonly +} + +interface TsjsCommandQueue { + readonly length: 0 + push(callback: unknown): 0 +} + +interface TsjsApiBase { + readonly version: '1.0.0' + readonly releaseId: string + readonly boot: Readonly + readonly que: TsjsCommandQueue + readonly log: TsjsLog + /** Release-internal late-bundle sink; always returns false after commit. */ + readonly _registerIntegration: (registration: unknown) => false + addAdUnits( + units: ProgrammaticAdUnit | readonly ProgrammaticAdUnit[] + ): AddAdUnitsResult + requestAds(options?: RequestAdsOptions): Promise +} + +interface TsjsKernelApi extends TsjsApiBase { + readonly diagnostics: Readonly + readonly _internal: Readonly<{ state: 'kernel'; releaseId: string }> +} + +interface TsjsFallbackApi extends TsjsApiBase { + readonly diagnostics?: never + readonly _internal: Readonly<{ + state: 'fallback' + releaseId: string + reason: 'abi_mismatch' | 'bundle_partial' + }> +} + +type TsjsApi = TsjsKernelApi | TsjsFallbackApi +``` + +`version` is the semantic public-API generation and changes only with a reviewed API +contract; `releaseId` identifies the exact bundle set and equals +`boot.releaseId`/`boot.manifest.releaseId`. Core recursively freezes the boot value +before installing integrations. Integration-specific configuration is not a public +mutable bag: the composition root validates each server-projected, deny-unknown +config against that integration's typed schema and passes the frozen value only in +its preparation/activation contexts. `_internal` is a frozen, non-enumerable status +value; the service registry remains in the composition closure and is available to +integration modules only through those contexts during startup. + +The final queue is the frozen actual empty Array from the commit handoff and contains +no retained callbacks. Its own `push` invokes one callable +immediately and exactly once with `this === tsjs`, returns `0`, ignores a +non-callable, and isolates/logs a throw. Pre-load callbacks are snapshotted and +drained FIFO only after the committed API is installed, so publisher callbacks never +run against a half-installed generation. Native mutators, borrowed Array mutators, +index assignment, `length` assignment, deletion, and property definition cannot +change the frozen executor or retain a callback; failure follows ordinary strict- or +sloppy-mode JavaScript semantics and `length` remains `0`. + +#### 5.4.1 Programmatic ad-unit registration + +The clean public core surface retains programmatic direct-auction registration: + +```ts +interface ProgrammaticAdUnit { + code: string + mediaTypes: { + banner: { sizes: readonly (readonly [number, number])[] } + } + bids?: readonly { + bidder: string + params?: Readonly> + }[] +} + +interface AddAdUnitsResult { + readonly registered: readonly string[] +} + +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' + +class AdUnitRegistrationError extends Error { + readonly code: AdUnitRegistrationErrorCode + readonly unitIndex?: number +} + +class TsjsUnavailableError extends Error { + readonly code: 'runtime_unavailable' + readonly releaseId: string + readonly reason: 'abi_mismatch' | 'bundle_partial' +} +``` + +Registration is synchronous, all-or-nothing, and navigation-scoped. `code` becomes +the exact slot id and must satisfy the server slot-id UTF-8 bound from §4.6. The +argument is one unit or a nonempty array of at most 256 plain data objects. Codes are +nonempty and unique in the call; banner sizes are nonempty integral number pairs in +the shared 1–4096 renderer range; bidder names are nonempty and at most 64 UTF-8 +bytes; params are plain JSON-compatible data with the same request-body cap as +`/auction`. Accessors, +unknown media types, duplicate codes, or a collision with any server-projected or +already registered slot reject the whole call with a typed +`AdUnitRegistrationError` before state changes. There is no merge-by-code behavior. +The outer shape/count maps to `invalid_units`; a non-plain unit or unknown/accessor +unit field to `invalid_unit`; code shape, in-call duplicate, and registry collision to +`invalid_code`, `duplicate_code`, and `slot_collision`; media/banner shape to +`invalid_media_types`; a nonnumeric/nonfinite/fractional/nonpositive dimension to +`invalid_dimensions`; an integral dimension outside 1–4096 to +`dimensions_out_of_range`; bid-array and bidder-name shape to +`invalid_bids`/`invalid_bidder`; non-JSON, cyclic, accessor-bearing, or otherwise +unserializable params to `invalid_params`; and encoded body overflow to +`request_body_too_large`. `unitIndex` is the lowest failing input index when the +failure belongs to a unit and is absent for outer/capacity errors. Validation order +is the order just listed, then capacity reservation; repeated runs return the same +code/index without reading publisher accessors. + +Successful units receive registration ordinals after the immutable server projection +and participate in later `requestAds` snapshots. They use the direct auction/render +path unless an explicit future design gives them a GPT mapping; registration alone +never defines, displays, refreshes, or targets GPT. The old placeholder-writing +methods are deleted rather than aliased. `tsjs.log` retains the existing bounded +level/method surface, while runtime configuration is immutable boot data or typed +integration-owned configuration. Fallback validates `addAdUnits` input and then throws +`TsjsUnavailableError` with the committed boot failure; it constructs no registry. + +The retained logger has this exact hard-cutover surface: + +```ts +type TsjsLogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug' + +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 +} +``` + +The initial level is `warn`. `setLevel` accepts only the five exact strings above; +an invalid runtime value throws `TypeError` without changing the current level. +Missing/throwing console methods are swallowed at the logger boundary, log failures +never change ad behavior, and the logger does not retain argument arrays. The +fallback exposes the same logger and level behavior. + +### 5.5 Direct auction API + +```ts +interface RequestAdsOptions { + slots?: readonly string[] + timeoutMs?: number + signal?: AbortSignal +} + +type RequestAdsInputErrorCode = + | 'invalid_options' + | 'invalid_slots' + | 'empty_slots' + | 'duplicate_slot' + | 'invalid_timeout' + | 'invalid_signal' + +class RequestAdsInputError extends Error { + readonly code: RequestAdsInputErrorCode +} + +type RequestAdsSlotResult = + | { slot: string; path: 'primary' | 'fallback'; outcome: 'accepted' } + | { slot: string; path: 'primary' | 'fallback'; outcome: 'no_bid' } + | { + slot: string + path: 'primary' | 'fallback' + outcome: 'failed' + reason: RenderFailureReason + } + | { + slot: string + path: 'primary' | 'fallback' + outcome: 'cancelled' + reason: 'caller_aborted' | 'superseded' | 'navigation_disposed' + } + +interface RequestAdsResult { + slots: RequestAdsSlotResult[] +} +``` + +Every `RequestAdsOptions.slots` entry is an exact, case-sensitive registered slot id: +either a server slot id from §2.2 or a programmatic `code` admitted by §5.4.1. The +public API never interprets an entry as a GPT ad-unit path, DOM id, or DOM alias. An +explicit id absent from the invocation snapshot resolves individually as +`failed{reason:'slot_unresolved'}` while valid siblings proceed. Internal GPT-path or +DOM-alias lookup that has zero or multiple matches also fails the affected slot as +`slot_unresolved`; it never selects the first registration. + +When `slots` is omitted, `requestAds` synchronously snapshots every server-projected +and programmatic slot registered in the current `NavigationSession`, ordered by its +navigation-local registration ordinal. That immutable snapshot is the batch +membership and result order; a slot registered after invocation is excluded. When +`slots` is present, its validated input order is the result order. The `slot` field in +every result is always the exact registered slot id. + +When `timeoutMs` is omitted, the shared auction-response deadline is exactly 10,000 +milliseconds. An explicit value replaces that default and must satisfy the bounds +below; renderer/GPT path deadlines remain independently fixed by their lifecycle +transitions. + +Omitted/`undefined` options are valid. Otherwise the argument must be a non-null +plain object whose prototype is this realm's `Object.prototype` or `null`, containing +only `slots`, `timeoutMs`, and `signal` as own enumerable data properties. Accessors +or unknown keys are `invalid_options`; non-array slots, non-string/empty/>256-byte +slot values, and more than 256 values are `invalid_slots`; an explicitly empty array +is `empty_slots`; an exact duplicate is `duplicate_slot`; a noninteger timeout +outside `100..30_000` is `invalid_timeout`; and a value that fails the platform +`AbortSignal.prototype.aborted` brand getter is `invalid_signal`. These reject +before attempts with `RequestAdsInputError`. Once an attempt is created, the +returned promise resolves with per-slot terminal results and does not reject for +auction or render failures. Unknown requested slots fail individually while valid +siblings proceed. Omitted slots with an empty registry resolve an empty `slots` +array. A registration collision cannot alter the snapshot or result ordering. + +The response deadline governs only the shared auction fetch. Once a response parses, +path-specific renderer deadlines take over. The caller signal remains active until +all child attempts settle. + +### 5.6 Adapters and external readiness + +GPT and Prebid adapters expose `present | pending | timed_out | incompatible`. +`timed_out` and `incompatible` are not permanent global failures: a later valid +external replacement can satisfy later operations. +Each queued operation owns its own deadline and disposal; an expired operation is +removed instead of running unexpectedly when the external library appears later. +Each adapter queue holds at most 64 live operations, drains FIFO, and fails only the +overflowing operation with `external_queue_full`. The operation deadline is exactly +ten seconds from enqueue and is independent of the auction-fetch response deadline; +expiry is `external_ready_timeout`. Readiness at the boundary races through the +operation's terminal latch, so exactly one of dispatch or timeout wins. + +The GPT adapter owns early event subscription, command-queue interaction, +`display`, `refresh`, targeting, and service-state inspection. The Prebid adapter +owns commands, event subscription, bid-response registration, and Universal +Creative integration. Tests use adapter fakes rather than mutable global objects. + +The decoupled Prebid artifact remains pure Prebid.js and retains its independent +five-second queue-drain watchdog. The generated wrapper arms that watchdog as its +first statement, before stamp inspection or module initialization. At 5,000 ms it +looks up the then-current real Prebid object and calls its idempotent `processQueue()` +at most once for that wrapper, whether or not the TS integration installed, so +publisher callbacks are not held hostage by stamp conflict, a missing TS bundle, or +a partial/duplicate artifact. Duplicate wrappers may reach the idempotent API, but +black-box tests require each queued publisher callback to execute once. A later TS integration module may call the idempotent API again. +The module first verifies the real Prebid API, then installs transactionally. It does +not install the synthetic-refresh policy, clear targeting, or mutate publisher bids +when only the injected `{que,cmd}` stub exists. The artifact manifest carries both +module stems and runtime bidder codes, including aliases. + +The artifact exposes this frozen plain-data runtime stamp; the separately emitted +build manifest contains the same fields plus filename/integrity metadata: + +```ts +interface ExternalPrebidArtifactV1 { + readonly abi: 1 + readonly artifactReleaseId: string + readonly prebidVersion: '10.26.0' + readonly moduleStems: readonly string[] + readonly bidderCodes: readonly string[] + readonly bidderAliases: readonly { + readonly code: string + readonly moduleStem: string + }[] + readonly userIdModules: readonly { + readonly moduleName: string + readonly configNames: readonly string[] + readonly eidSources: readonly string[] + }[] +} +``` + +After arming the watchdog and before executing embedded Prebid module factories, the +wrapper inspects the current `window.pbjs` and its own descriptor for +`__trustedServerArtifactV1`. If that object already has the required real API plus a +valid recursively frozen stamp, an exact same-release/content duplicate reuses it and +skips module initialization/redefinition; a different valid release refuses only the +new wrapper and likewise leaves the already-working object untouched. A different +artifact can become active only by replacing the whole `window.pbjs` object. + +Otherwise the wrapper initializes its embedded pure Prebid modules. It then attempts +to define one own non-enumerable, non-writable, non-configurable data property whose +value is the recursively frozen `ExternalPrebidArtifactV1` only when the descriptor +is absent. Define failure is caught locally. An accessor, inherited-only value, +invalid stamp, or hostile/different non-configurable value is left untouched and +records at most one bounded console warning when possible. None of these paths +throws, cancels the already-armed watchdog, or prevents publisher Prebid from +initializing; they make only TS readiness incompatible. + +This inert build description is the artifact's only Trusted Server handshake. Apart +from the independent Prebid queue self-start watchdog specified above, the generated +wrapper performs no auction, admission, render, targeting, or refresh behavior. The +Prebid adapter reads the property only through `Object.getOwnPropertyDescriptor`, +rejects an accessor or inherited value, and captures both the `pbjs` object and stamp +identities in one `PrebidArtifactBinding`. Every operation rechecks both identities; +replacement of `window.pbjs` invalidates only that binding and later readiness may +bind the new object if it carries a valid stamp. No `window.__tsjs_*` stamp or +fallback lookup exists. + +The artifact build contains exactly one 64-zero-character release sentinel in that +runtime stamp. It hashes the emitted JavaScript after normalizing that one field back +to the sentinel, writes the resulting 64 lowercase hexadecimal SHA-256 characters +into the field, and verifies that no sentinel remains. The separately emitted build +manifest records that same `artifactReleaseId` plus the ordinary SHA-256/SRI of the +final bytes. Thus the embedded id has a non-self-referential preimage; it is +diagnostic artifact identity, not a requirement to match the TSJS `releaseId`. + +`prebidVersion` must be exactly `10.26.0`; changing it requires the reviewed +artifact-contract fixture update in §3.5. Module/code/config names are nonempty, +unique in their array, and at most 128 +UTF-8 bytes; EID sources are lowercase, nonempty, unique per module, and at most 256 +UTF-8 bytes. The manifest admits at most 256 module stems, 512 bidder codes, 512 +alias rows, and 128 user-ID modules with at most 64 config names and 64 EID sources +each. Every alias code appears in `bidderCodes`, every alias module appears in +`moduleStems`, and each configured `client_side_bidder` must appear in +`bidderCodes`. Arrays are lexically sorted so build/runtime fixtures compare exact +content. + +A missing stamp, wrong `abi`, invalid release/version, malformed/oversized member, +missing configured bidder, missing required user-ID module/EID mapping, or a real API +missing a required method makes the current readiness operation +`external_artifact_incompatible`. It records one bounded local diagnostic and does +not install TS refresh interception or mutate publisher state. An older unstamped +artifact remains ordinary publisher Prebid: its own 5,000 ms watchdog drains its +queue exactly once, and the later TS module does not replay publisher callbacks. +Replacement by a valid artifact can satisfy later operations. Compatibility requires +both `abi:1` and exact Prebid 10.26.0; external artifacts are not pinned to a TSJS +release id. + +### 5.7 GPT correctness retained during decomposition + +- Subscribe to `slotRequested` and `slotRenderEnded` before any TS request. +- Pass `changeCorrelator: false` for TS refreshes unless the explicit configuration + says otherwise. +- Call `enableSingleRequest()` only before services are enabled; never reconfigure a + publisher-owned GPT service after `enableServices()`. +- Restore the intended initial-load behavior represented by issue #922/PR #997 and + pin it with tests. +- Responsive-size ambiguity fails `slot_unresolved` and never silently skips or + chooses an arbitrary container. +- A TS fallback slot is defined on the resolved inner div, never its outer + `-container`. An exact later publisher `defineSlot` receives that same live slot + even when its path/formats differ, with a local mismatch warning, because defining + a second physical slot would violate the one-placement invariant. A + hydration-renamed alias is accepted only when the original element is gone and + exactly one live, unclaimed TS fallback shares the configured prefix, exact GAM + path, and normalized formats. Ambiguity remains native and cannot transfer TS + ownership. +- Successful handoff synchronously transfers ownership, removes the slot from the + TS destroy set, suppresses exactly the publisher's duplicate initial `display`, + and under disabled initial load suppresses exactly its duplicate first refresh. + A global refresh expands the live GPT slot list, filters only one-shot suppressed + slots, and forwards unrelated slots with the original options. +- Publisher calls are not held until TS targeting is ready. A publisher-owned + display/refresh remains publisher work, and its failures cannot start TS fallback. +- Every TS-owned GPT destroy/redefine uses one adapter transaction. It first marks + the exact old object and cycle retired, then calls `destroySlots([old])` and + requires a successful return before attempting `defineSlot` for a replacement. + A throw/false destroy leaves the old identity retired and its path/aliases + quarantined, defines no second physical slot, and makes current or next TS work + fail `gpt_request_failed` until publisher destruction or reload. If destroy + succeeds but replacement definition fails, the slot stays unbound and the same + failure is returned; bindings and ownership commit only after one replacement is + successfully defined. A stale generation after either call disposes any newly + created TS-owned replacement and cannot bind it. Request-timeout, completion- + timeout recovery, navigation replacement, and DOM reconciliation all call this + transaction rather than open-coding destroy/redefine. +- Runtime-owned DOM reconciliation detects when a framework replaces the element of + a TS-owned live slot. It debounces changes, retires/destroys only the orphaned + TS-owned GPT object, resolves the unique current element, and rebinds within the + current navigation. One `MutationObserver` per `NavigationSession` watches + `childList` changes under `document.documentElement`; it is disconnected on + navigation disposal. Once an exact owned element becomes disconnected, that slot + opens a 5,000 ms reconciliation window on the monotonic clock. The first resolution + pass runs after 250 ms without another relevant mutation; if it is unresolved or + ambiguous, exactly one final pass runs at the 5,000 ms boundary. A pass that finds + one unique current element may win the slot's terminal reconciliation latch only + after the destroy/redefine transaction commits its replacement. Destroy or define + failure settles current work as `gpt_request_failed`; it is not counted as a + successful rebind. The window expiry racing a successful final pass goes through + the same latch: success wins only if the unique replacement was committed first; + otherwise the slot records `slot_unresolved` and runs the failed-reconciliation + disposer. That disposer + cancels the slot's active TS request cycle, tombstones its live render reservation, + compare-restores only targeting still equal to TS-installed values, clears every + exact/alias binding to the orphan, and asks the GPT adapter to destroy that exact + still-TS-owned object. Before the destroy call it marks the object/cycle retired in + weak identity state, so a throw or later GPT callback is quarantined and cannot + re-enter selection, fallback, trace attribution, or targeting. It then releases + the timer, candidate set, and all strong references. Successful destruction + settles a nonterminal current attempt as `failed{reason:'slot_unresolved'}`; + throw/false destruction settles it as `failed{reason:'gpt_request_failed'}` and + adds one bounded local warning. Neither outcome restores ownership. + + A second disconnect after one successful rebind may open one final window; two + successful rebinds is the per-slot, per-navigation maximum. A further disconnect + immediately runs that same disposer with + `failed{reason:'reconciliation_capacity'}` and cannot rebind again before the next + `NavigationSession`. Navigation disposal uses the same physical-object/targeting/ + cycle cleanup without emitting a new failure. Reconciliation never destroys a + transferred or otherwise publisher-owned slot, and ownership transfer racing any + pass wins the latch, cancels TS reconciliation state, removes TS destroy ownership, + and leaves physical/targeting cleanup to the publisher. + +- The Prebid refresh policy preserves the exact `excluded_gam_ad_unit_path_suffixes` + behavior: path matching is literal/case-sensitive suffix matching; missing, + non-string, or throwing `getAdUnitPath()` fails open; stale TS/Prebid keys are + cleared from every target; only eligible slots enter the synthetic auction; and + the complete target slot list plus original options still reaches GPT. +- Use one adapter-level refresh interception and one slot-service request path; + remove the three independent integration wrappers without removing handoff or + exclusion semantics. +- Preserve the baseline collapsed-shell resize as a guarded exception tied to the + current attempt. It runs only after a TS PUC response is posted and only when the + source is the exact connected iframe, width/height attributes and computed size + are still at most one pixel, dimensions are finite/positive, the frame/wrapper are + ordinary non-fixed/non-sticky display shells, and no anchor container is present. + Only that iframe and its still-collapsed immediate wrapper may be resized. + +### 5.8 Local diagnostics + +The kernel owns a bounded, failure-isolated diagnostics bus. Render attempts and the +GPT adapter publish immutable observations after their correctness transition; a +diagnostics subscriber can never delay, reject, retry, or mutate the source +operation. Subscriber throws are logged locally and isolated from later subscribers. +The internal bus admits at most 16 integration-module subscriptions, one for each +manifest member; publisher code cannot register on that internal bus. + +`tsjs.diagnostics.renderTrace` replaces the mutable `tsjs.renders`, `renderLog`, and +`renderSeq` globals and the `tsjs:adRendered` CustomEvent with read-only snapshot and +subscription methods. The final schema is: + +```ts +type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh' +type RenderTraceServedFromV1 = + | 'inline' + | 'gam' + | 'debug-adm' + | 'pbs-cache' + | 'prebid' + +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 +} + +interface RenderTraceDiagnostics { + current(): Readonly>> + history(): readonly Readonly[] + subscribe(listener: (record: Readonly) => void): () => void +} + +interface TsjsDiagnostics { + readonly renderTrace: RenderTraceDiagnostics + readonly gpt?: GptDiagnosticsApi +} + +class DiagnosticsSubscriberLimitError extends Error { + readonly code: 'subscriber_capacity' + readonly surface: 'renderTrace' | 'gpt' +} +``` + +Snapshots are frozen copies, not references to the runtime store. Subscription is +FIFO; unsubscribe is idempotent; a listener registered during dispatch begins with +the next observation. `count` is the positive per-slot impression ordinal, `seq` is +the positive runtime-global observation ordinal, and `at` is the initial record's +`Date.now()` epoch milliseconds; enrichment retains all three. The initial record and +every later enrichment commit to `current`/`history` first and return to the +correctness/publisher stack without invoking public code. After commit, the +diagnostics service snapshots the current subscriber ids and enqueues one frozen +full-record copy in a 200-entry FIFO keyed by `seq`; another enrichment pending for +that `seq` replaces both its queued snapshot and captured subscriber-id set without +changing order. A listener added after the initial commit may therefore receive a +later enrichment commit, but never the earlier snapshot. Overflow drops the oldest +pending notification and increments a diagnostics-only counter. One owned +zero-delay task drains the queue in observation order. A listener registered after a +commit cannot receive that committed observation; unsubscribe before delivery +suppresses its captured id; registration during delivery starts with the next +observation. A slow/non-returning listener can block only that later diagnostics task, +never the render/GPT transition that scheduled it, and a throw is isolated from later +listeners. This asynchronous frozen delivery is the timing/detail replacement +contract for the removed `tsjs:adRendered` event; no CustomEvent or compatibility +alias is emitted. + +Each public diagnostics surface admits at most 32 live subscribers. A 33rd +subscription throws `DiagnosticsSubscriberLimitError{code:'subscriber_capacity'}` +without adding the listener; unsubscribe immediately returns capacity. The +argument must be callable or `subscribe` throws `TypeError` before the capacity +check. The +composition root freezes `tsjs.diagnostics` only after all manifest diagnostics +integration modules have registered their surfaces. Fallback exposes no diagnostics +namespace because it constructs no runtime. + +- sequence numbers are runtime-global across separately built IIFEs; +- current state is keyed by exact registered slot id and therefore capped by the + 256-record navigation registry. Slot/navigation disposal synchronously prunes its + current entry; history is document-runtime scoped, capped at 200, and evicts the + oldest row before append; +- one physical impression is one history row; a later bridge/GPT/visibility signal + enriches that row in place and cannot weaken prior `rendered` or `injected` truth; +- a publisher/GAM refresh with no current TS auction has no TS attribution; +- `gam-only` means GAM reported fill without proof TS placed the creative, while + `ok` requires TS placement plus visibility; +- DOM `data-ts-*` stamps remove absent/stale fields on every update; an old badge is + removed before the new status is considered; and +- the boot-armed local overlay remains bounded, newest-first, click-to-export, + and noninteractive with the creative. Overlay/export failure cannot affect ads. + +Diagnostics enablement is resolved before core preparation and transported only +through frozen boot data: + +```ts +interface DiagnosticsBootV1 { + readonly version: 1 + readonly renderTraceOverlay: boolean + readonly gpt: { readonly active: boolean } +} +``` + +The server always emits this complete value, defaulting to +`{version:1,renderTraceOverlay:false,gpt:{active:false}}`. Both objects must be +non-null plain objects with exactly the shown own enumerable data properties; +accessors, unknown/missing keys, or wrong prototypes/literals/types are +`abi_mismatch`, not silent diagnostics disablement. The kernel copies the validated +data and recursively freezes that copy before module preparation; copy/freeze +failure is also `abi_mismatch`. `gpt.active:true` requires exactly one required `gpt_diagnostics` +integration id in `BootManifestV1`, and `false` requires that module to be absent; +the inverse mismatch is also `abi_mismatch` before any GPT diagnostics listener or +buffer exists. + +The existing render-trace server toggle resolves +`tsjs.boot.diagnostics.renderTraceOverlay`; TSJS does not read or mutate its cookie. +GPT diagnostics remains deployment-disabled by default. When configured, one exact +`ts_console=1|true` directive on an eligible GET document navigation enables the +host session and `ts_console=0|false` disables it; values are case-sensitive and +duplicate/unrecognized directives fail closed for that response. The server owns the +host-only HttpOnly session cookie, removes the reserved directive before publisher or +origin handling, preserves unrelated path/query/fragment data, and emits only the +resolved `gpt.active` boolean. The old +`window.__tsjs_gpt_diagnostics_active` flag and browser storage bootstrap are deleted. + +The GPT diagnostics integration module preserves the behavioral contract in +`docs/superpowers/specs/2026-07-28-gpt-runtime-diagnostics-overlay-design.md` unless +this design explicitly changes ownership or activation transport. It consumes raw +facts from the sole GPT adapter rather than registering another control wrapper. + +When `gpt.active` is true, core installs the six documented GPT observations +(`slotRequested`, `slotResponseReceived`, `slotRenderEnded`, `slotOnload`, +`impressionViewable`, and `slotVisibilityChanged`) before any TS-owned GPT request. +It starts a 512-entry FIFO pre-module fact buffer and replays it in order when the +diagnostics module activates. Overflow evicts the oldest fact and increments one +diagnostics-only counter; after replay, live facts fan out directly and the buffer is +released. When inactive, no diagnostics buffer or four diagnostics-only listeners +exist. The GPT adapter may still own `slotRequested` and `slotRenderEnded` listeners +required for ordinary ad correctness under §5.7; inactive zero-side-effect tests +measure that baseline and require zero diagnostics-added listeners, DOM, timers, +observers, API, or network work. + +The GPT diagnostics store retains at most 64 observed GPT slot objects, ten request +cycles per slot, and 128 callback-issue records. It evicts the +least-recently-active slot or oldest cycle/issue before insert and increments the +corresponding export counter. An evicted GPT slot can re-enter only on a future +`slotRequested`; its monotonic request number is retained in a `WeakMap`, and earlier +non-request callbacks remain unmatched. Its public API shares the 32-subscriber cap +above. Exact slot identity/binding and element replacement, physical request cycles, +callback truth, timing fields, frozen bounded export, Shadow DOM overlay, badge +layers, SPA behavior, privacy, and non-interference remain. Diagnostic records are +memory-only; neither diagnostics surface writes localStorage, sessionStorage, +IndexedDB, or uploads data. The hard-cutover API is `tsjs.diagnostics.gpt`; the old +flag/runtime expandos and `tsjs.gptDiagnostics` alias are deleted. + +GPT public subscriptions use a separate one-entry latest-snapshot notifier and never +run from a GPT callback, adapter fan-out, store mutation, or binding observer. After +each committed store/binding change, the controller builds one frozen +`GptDiagnosticsExportV1`, snapshots current subscriber ids, and schedules one owned +zero-delay task. A later change before delivery replaces that pending snapshot and +subscriber-id set; this API signals current state rather than promising one callback +per raw GPT fact. Registration after a commit cannot receive that commit unless a +later change replaces the pending snapshot; unsubscribe before delivery suppresses +the captured id. Listener throws are isolated, and a slow/non-returning listener can +block only the diagnostics task. Module disposal cancels the task, clears the one +pending snapshot and subscriber set, and delivers nothing later. `snapshot()` remains +a synchronous frozen read with no subscriber invocation. Tests apply the same +subscribe/unsubscribe/slow/throw rules as render trace plus 0/1/2-update coalescing. + +### 5.9 Creative and remaining integration preservation + +`CreativeBootV1` in §5.4 is exact plain boot data. The server always emits it. A +disabled integration is exactly +`{version:1,enabled:false,clickGuard:false,renderGuard:false}` and has no `creative` +manifest member. When enabled but its config is absent, the server emits +`{version:1,enabled:true,clickGuard:true,renderGuard:false}`; explicit configuration +replaces the two guard booleans. `enabled:true` requires exactly one required +`creative` manifest id and `false` requires its absence. An accessor, non-plain +prototype, missing/unknown key, wrong literal/version/type, disabled non-false guard, +or manifest mismatch is an `abi_mismatch` before any creative guard installs. The recursively +frozen `tsjs.boot.creative` is the only final inspection/configuration surface. +`globalThis.tscreative`, `globalThis.tsCreativeConfig`, `installGuards`, `setConfig`, +and `getConfig` are deleted, not aliased; changing guard policy requires a new boot/ +document generation. + +The creative integration module prepares inertly and activates transactionally +exactly once in the kernel barrier. Activation installs the click guard when +`clickGuard` is true and the image/iframe dynamic-source guards when `renderGuard` is +true, but performs no baseline DOM rewrite. Only when +`clickGuard || renderGuard` is true, activation gives a still-loading document one +owned `DOMContentLoaded` callback to perform the baseline idempotent rescan after the +initial DOM completes; an already interactive/complete document gets no listener and +performs that scan from one staged `afterCommit` callback. Its +disposer removes that listener, observers, and owned DOM state and compare-restores a +patched constructor/property/function only if the current value is still the exact +wrapper installed by this generation. Disabled guards install no wrapper, observer, +listener, scan, or DOM state, whether the integration itself is disabled or it is +enabled with both guard booleans false. SPA navigation retains the document-scoped +guards and their dynamic-node behavior; failed preparation/activation or full runtime +disposal removes them once. + +Creative processing keeps its current independent policy controls. Auction +sanitization remains explicit opt-in/default-off; rewriting retains its existing +setting/default and still runs on every delivery path where that setting applies. +The runtime click guard resolves and stores one validated absolute HTTP(S) URL before +navigation, rejects `javascript:`, `data:`, `blob:`, malformed, and credentialed +targets, and uses the established `/first-party/proxy-rebuild` GET redirect path to +recover clicks from the opaque sandbox. Dynamic resource/click rewriting, iframe +sandbox attributes, font/CORS handling, body/base handling, and the direct/SSAT/cache +delivery boundaries remain covered by unit plus real-browser tests. This APS/TSJS +work neither enables sanitization nor broadens creative privileges. + +Every other enabled TSJS integration becomes a thin transactional integration module without an +internal feature rewrite. Its complete current unit suite runs unchanged against a +pre-cutover fixture and a module-composed fixture. At minimum the parity corpus +proves: + +| Integration | Required preserved behavior | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DataDome | dynamic script/preload matching and fixed first-party route rewriting preserve the upstream path | +| Didomi | configured proxy path becomes the absolute `didomiConfig.sdkPath` without clobbering unrelated publisher config | +| Google Tag Manager | script/preload rewriting plus Google Analytics `sendBeacon`/`fetch` rewriting preserve method, body, and unrelated traffic | +| Lockr | script guard plus bounded SDK readiness polling rewrites only the initialized Lockr API host | +| Osano | USP/GPP/TCF cookie mirroring retains marker ownership, timeout, readiness, retry, event, focus/visibility, clear, and non-clobber semantics | +| Permutive | script guard, bounded SDK readiness, API-host rewriting, and at-most-100 normalized local segment values continue to feed auction context | +| Sourcepoint | optional SDK guard and Sourcepoint-owned GPP cookie mirroring retain localStorage shapes, marker ownership, initial retry, visibility/focus updates, and safe clearing | +| Testlight | preexisting and later callbacks bridge once into the final TSJS queue; invalid entries and one throwing callback do not block later work | + +The shared script/beacon/DOM-insertion guards keep integration-owned matchers and +routes. A shared helper may centralize interception, but it cannot broaden one +integration's matcher, reorder another integration's startup, stack interception, +or leave a timer/listener after module disposal. Maximal-bundle tests load every +server-declared integration module in real manifest order and assert both its behavior and +exactly-once disposal, not merely successful registration. + +### 5.10 Error handling and bounded state + +No empty `catch` remains in the migrated kernel, adapters, or APS/GPT/Prebid paths. +Boundary failures become typed results and a concise local warning; disposer and +late-callback failures cannot escape into publisher code. Logs redact descriptors, +AAX payloads, account ids, creative URLs, auction bodies, and capability values. + +Every collection has a named owner, capacity, and pruning rule. Tests exercise +capacity, TTL, duplicate registration, replay, timeout, navigation replacement, and +late continuation behavior with fake timers. + +### 5.11 Decomposition targets + +The implementation extracts cohesive behavior rather than mechanically splitting +by line count: + +| Current area | Target responsibility | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `gpt/index.ts` | thin integration composition over GPT adapter, slot service, initial-load policy, handoff, SPA navigation, and render bridge | +| `prebid/index.ts` | thin integration composition over Prebid adapter, shim, refresh handler, eids, and APS registration | +| `core/request.ts` | validation plus `AuctionBatch`; rendering delegated to render service | +| `core/render.ts` | path-independent DOM helpers only; lifecycle lives in render service | +| `core/trace.ts` | diagnostics subscriber over lifecycle/GPT observations; no mutable integration-owned trace registry | +| APS maps in globals | runtime-owned bounded reservation service | +| duplicated `script_guard.ts` | shared factory plus integration configuration | + +Other integrations receive an integration-module wrapper and service lookup where required, but +their internal behavior is not otherwise rewritten. + +### 5.12 TypeScript and performance gates + +The lockfile compiler is the authority. CI runs a checked-in `typecheck` script with +`strict`, `noUncheckedIndexedAccess`, `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, +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 +runs independently in Chromium, Firefox, and WebKit. Correctness failures are never +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`. +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 + begins only after exact reservation/slot lookup, attributable nonempty GAM, + current generation, source binding, and atomic consumption. +3. Lifecycle tickets and nonces are CSPRNG, one-use, TTL-bounded, never logged, and + invalidated on supersession/navigation. +4. Exact-key message parsing prevents confused-deputy extensions. Unknown versions + are ignored or failed closed according to whether the message claims a TS + capability. +5. Native Prebid messages with non-TS ids continue to native listeners. Any message + carrying a live or tombstoned TS id is suppressed before later validation. +6. The upstream APS runner URL, every creative URL, and production renderer/proxy + routes must be HTTPS. HTTP is permitted only for loopback hermetic adapters; their + fixed local proxy route remains covered by CSP `'self'`. The runner executes only + through the fixed-target, anonymous-CORS Trusted Server proxy. The proxy relays the + APS body unchanged but never stores it in source, forwards publisher credentials, + accepts a caller-selected target, or executes a fallback. Runner-created APS-origin + resources may use their own origin cookies under browser policy. The renderer + document accepts no cookie as authority. +7. Script creatives remain opt-in because they materially broaden executable + behavior. Enabling them requires a documented security review of the fixed + renderer CSP. +8. The design adds no persistent identifier and no external event pipeline. + +## 7. Verification and acceptance + +### 7.1 Required test layers + +| Layer | Required proof | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Rust unit | APS parsing/admission, dimensions, scripts, AAX projection, mediation provenance/order/timeouts, targeting identity, descriptor serialization, endpoint headers/body | +| `rc/july` parity | executable `905984e62` TSJS source/test/browser inventory; every `RCJ-*` row maps to pre-cutover evidence, a final owner, focused tests, and either retained or deliberately replaced observable behavior | +| Cross-language corpus | every positive/adversarial descriptor has the same Rust, TS, and embedded ES5 result; stale generation fails | +| TS unit | kernel ownership, integration-module transaction/unwind, terminal fallback, sessions, registries, selection/cycle/batch/latch APIs, adapter readiness, GPT handoff/reconciliation, Prebid artifact/refresh, creative security, diagnostics, and every remaining integration parity corpus | +| Hermetic browser | all render paths, PUC bridge, three-level APS sizing, direct iframe races, owner/port/runner behavior, fallback, SafeFrame-shaped nesting, GPT handoff/hydration, creative clicks, diagnostics, and duplicate/replay/wrong-source/stale cases | +| Real-GAM test network | SSAT APS-PUC, Prebid-adapter APS-PUC, page-bids APS-PUC, direct APS, direct ADM/cache, fallback after attributable empty GAM, SRA, refresh, SPA, handoff, hydrated DOM replacement, and collapsed shell | +| Adapter parity | exact renderer sandbox/CSP/header bytes plus runner-proxy routing, five-second deadline, closed response parsing, bounded relay, header filtering, and failures match on all adapters | +| Regression | non-APS Cache/ADM and notifications, pure external Prebid/native bids/EIDs/user IDs/refresh exclusions, publisher GPT/handoff/SRA/SPA, creative processing/click recovery, render trace/GPT diagnostics, and every remaining integration remain correct | +| Quality | full-package TypeScript/lint including tests/scripts/build code, ESLint boundaries, format, clippy, Rust adapter suites, Vitest, artifact integration, Playwright, deterministic bundle/performance budgets | + +### 7.2 Mandatory race matrix + +Tests must cover at least: + +- duplicate simultaneous `Prebid Request` for the same id; +- claim before/after attributable nonempty GAM, claim followed by empty GAM, and + navigation/supersession at each side of that two-condition join; +- replay after consumption and after tombstone expiry boundary; +- attempt-id navigation prefix failure, ordinal uniqueness and exhaustion without an + issued-id set; forced lifecycle-ticket/renderer-nonce collisions through the + eighth draw; 255/256/257 live nonces and 319/320/321 ticket/tombstone entries; + capacity versus expiry pruning; and proof that neither overflow path posts a usable + capability; +- valid id from wrong slot/source, altered id from the expected source, and a native + Prebid id; +- PUC registration before/after timeout, wrong source, zero/two ports, replay, + caller abort before/after registration/insertion/document acceptance, and owner + watchdog racing a late kernel response; every winner produces exactly one + encodable `OwnerSettlementV1` and one PUC Promise settlement; channel loss before + start and after insertion, settlement-post throw, and the 20-second remote cleanup + boundary prove the owner removes only its uncommitted iframe while accepted DOM + remains; +- renderer document success followed by runner failure/timeout; +- runner proxy stall and slow-drip across the five-second total deadline; redirect; + absent/duplicate/malformed/mismatched/over-limit `Content-Length`; + absent/accepted/parameterized/rejected `Content-Type`; absent/identity/listed/other + `Content-Encoding`; declared and streamed over-limit bodies; byte-preserving + success; stripped upstream headers; and empty, non-leaking `502 no-store` failure; +- GPT/Prebid readiness at either side of its deadline, `slotRequested` at either + side of the request-start deadline, and `slotRenderEnded` at either side of the + completion deadline, including late real completion of an attributable + completion-timeout cycle and proof that future events never release an + unattributable request-timeout quarantine; +- iframe `load`, `error`, removal, supersession, and navigation disposal ordering; +- accepted-artifact promotion racing terminal disposal, replacement of an accepted + direct iframe, TS-owned GPT destroy/redefine, and publisher-owned GPT navigation; +- two consecutive attempts installing identical targeting strings with newer + success, failure, and supersession; older-artifact disposal after newer promotion; + publisher different-value and same-value `setTargeting`, per-key clear, and + clear-all before each cleanup point; +- old-navigation completion after a new slot with the same DOM id exists, both + before and after the replacement's completion, for TS- and publisher-owned slots; +- two concurrent `/auction` calls with partial slot overlap, reversed responses, + one caller abort, full abort, and response timeout; +- initial boot projection versus SPA navigation-owned projection, proving boot + remains recursively frozen; stale/duplicate/malformed page-bids responses; + page-bids racing programmatic registration at the 255/256/257 combined-slot + boundary; auction/provider/upstream/currency/CPM/targeting grammar and + byte/count boundaries; canonical projection just below/at/above 8 MiB with the + exact all-winners-to-`winner_not_renderable` reduction; and all-or-nothing + projection/slot/targeting commit; +- `display()` under disabled initial load from TS and publisher callers; +- exact and hydration-renamed late `defineSlot` handoff, mismatch/ambiguity, + duplicate publisher display, explicit/global initial-load-disabled refresh, + ownership transfer, SPA disposal, and unrelated slots/options; +- DOM replacement before and after GPT request start, debounced TS-owned orphan + reconciliation at 249/250 ms and 4,999/5,000 ms, first/final pass success, + two-success capacity, expiry/rebind latch ordering, ambiguous replacement, + transfer racing replacement, exact orphan destruction/quarantine, targeting + compare-restore, cycle/reservation cleanup, throwing/false GPT destroy and failed + replacement definition in reconciliation/request-timeout/completion-timeout/ + navigation paths, proof no second physical slot is defined, navigation disposal, + and proof that publisher-owned slots are never destroyed; +- SRA completion ordering, duplicate `responseIdentifier`, missing completion, and + publisher/TS overlap; +- missing/stub/late/duplicate/older/partial external Prebid artifacts, artifact + queue-watchdog versus late module activation, ABI and release-identity boundaries, + every manifest collection/string capacity, malformed/unsorted/duplicate members, + sentinel-normalized versus final-byte integrity, exact 10.26.0, own data-property + binding, same-release duplicate reuse without module re-execution, different-valid- + release refusal without disturbing the active object, absent/accessor/inherited/ + invalid/hostile non-configurable stamp handling after the watchdog arms, publisher + callback delivery exactly once on every conflict path, bound `pbjs`/stamp replacement, + `PreparedTrustedBid` admission/non-publication, bidder aliases, client-side + adapter gaps, user-ID/EID manifest gaps, replacement by a later valid artifact, + absence of TS auction/admission/render/refresh behavior from the external artifact, + and native publisher queue/bid + survival; +- explicit/global Prebid refresh with normal, all-excluded, and mixed slots; + literal path case/trailing-slash mismatch; missing/non-string/throwing + `getAdUnitPath`; cleanup of excluded slots; original option identity; and the + initial-TS-refresh bypass; +- reservation capacity with an unexpired oldest entry and late request for that + entry; navigation slot capacity across repeated programmatic calls at totals + 255/256/257, mixed server/programmatic records, all-or-nothing overflow, and full + disposal/reuse on the next navigation; immutable winner-CPM retention across a + replaced projection before a delayed cache claim, Prebid lease promotion, ignored + cache-response price/current targeting, and separate direct-cache expansion; +- integration-module prepare throw/reject/abort and activation throw at each + checkpoint, late preparation continuation after fallback, and `afterCommit` throw; + 9,999/10,000/10,001 ms synchronous activation returns plus the pre/post-call and + pre-handoff monotonic checks; nonreturning activation documented as unpreemptable; + duplicate `afterCommit` registration and 15/16-module callback capacity; + publisher GPT activity and script/creative DOM activity before/during/after a later + module failure prove preparation is inert, activation cannot yield, rollback is + same-task, and post-commit work sees only the full kernel; queued and later + `requestAds`, callback throws, already-aborted signals, refusing late integration + registration, and proof that no second runtime, listener, port, timer, request, + script, wrapper, guard, or iframe survives a fallback commit; +- exact kernel/fallback `TsjsApi` own surfaces; semantic version and release-id + equality; boot deep-copy/freeze and malformed-field safe fallback; actual-Array + queue identity; pushes before/during/at activation and commit completion; retained ingress + references after swap; snapshot-versus-forward exactly-once behavior; nested push + ordering; frozen final-queue `length:0` under native/borrowed mutators, index and + length assignment, deletion, and property definition in strict and sloppy callers; + immediate post-load return values, `this`, non-callables, and callback throws; +- main bundle absence after server GPT projection, proving the old degraded bootstrap + renderer is deliberately gone: no GPT definition/targeting/display/refresh occurs, + every known slot settles with the committed fallback reason, the queue drains once, + and a late bundle cannot revive rendering; +- explicit `requestAds` selection with exact server-projected and programmatic slot + ids, an unknown id beside a valid sibling, GPT-path and DOM-alias collisions, and + omitted-slot snapshot membership/order while another slot registers after + invocation; +- programmatic `addAdUnits` single/array registration, boundary sizes/counts, + accessors/unknown keys/media, malformed params, duplicate/colliding ids, + all-or-nothing rollback, navigation disposal, registration after a `requestAds` + snapshot, dimension type and 0/1/4096/4097 boundaries, 63/64/65-byte bidder names, + direct rendering, fallback refusal, absence + of placeholder methods, logger default/all levels, invalid-level non-mutation, and + missing/throwing console methods; +- direct and PUC ADM initial `about:blank`, pre-assignment, intended `srcdoc`, error, + removal, replacement, duplicate load, supersession, disposal, stale-generation, + and deadline orderings, proving only the current intended navigation accepts; and +- render-trace record/update reordering, one-impression enrichment, weaker-signal + non-regression, 200-entry pruning, stale attribution/DOM-field/badge removal, + navigation pruning of `current`, 32/33 subscriber boundaries and capacity reuse, + 199/200/201 pending notification bounds, same-sequence coalescing, post-commit + asynchronous frozen subscription detail/timing, subscribe/unsubscribe races, + slow/throwing listeners, absence of `tsjs:adRendered`, + hidden/gam-only/ok truth, overlay/export failure, and cross-IIFE sequence order; +- GPT diagnostics activation before/after early buffered callbacks, exact raw-event + replay, exact `tsjs.boot.diagnostics` schema, query/session enable-disable and + fail-closed inputs, accessor/prototype/unknown/missing/version rejection and + manifest-activation mismatch, active six-listener versus inactive correctness-listener counts, + 511/512/513 fact-buffer bounds, 63/64/65 slots, 9/10/11 cycles, 127/128/129 issues, + 32/33 public subscribers, 0/1/2-update latest-snapshot coalescing, + subscribe/unsubscribe/disposal races and slow/throwing listeners, slot element + replacement, timing/frozen-export bounds, overlay disposal, inactive + zero-diagnostics-side-effect behavior, and diagnostics failure during live ads; +- creative processing across sanitize/rewrite policy combinations and every delivery + path; exact default/explicit `CreativeBootV1` validation; automatic immediate and + `DOMContentLoaded` install; disabled and enabled-with-both-guards-false + zero-side-effect behavior; idempotent rescan; + exact-wrapper disposal; absence of mutable/install globals; opaque sandbox click + recovery; absolute HTTP(S), credentials, malformed and non-network schemes; + dynamic URLs; replaced elements; and redirect/browser navigation failure; and +- every remaining integration module alone and in the maximal manifest, including missing globals, + readiness/timeouts, malformed consent/storage, matcher false positives, callback + throws, startup failure, reverse-order disposal, and cross-integration isolation; and +- every protocol string/body limit at boundary-minus-one, boundary, and + boundary-plus-one UTF-8 bytes, including multibyte, duplicate-key, malformed + encoding, exact 1/4096 renderer dimension bounds across Rust/TS/ES5/cache/PUC DOM, + and exact capability-form cases through both dispatcher and port parsers. + +### 7.3 Real-GAM pass criteria + +The checked-in test-network fixture and hermetic fakes use fictional ids and no +production demand. Real network ids, GAM creative configuration, and secrets are +injected by the protected CI/manual environment and never checked into this +repository. +Each required flow must demonstrate the expected DOM and lifecycle result, not an +analytics row: + +- APS paths: one creative request, one bridge claim where applicable, one renderer + iframe, one APS runner load, one APS render-completion callback, one accepted + result, no duplicate render. The PUC owner, static renderer, and descendant creative + each have the exact winning viewport with zero default margin, no clipping, and no + overflow. +- Empty GAM fallback: parent settles empty/failure before exactly one child render. +- Direct ADM/cache: exact owned iframe reaches one accepted result. +- Failure fixtures: wrong id, invalid descriptor, missing claim, missing owner, + missing document acknowledgement, and runner failure each reach the specified + terminal reason within the specified timeout. +- After SPA replacement, no old attempt mutates the current slot or targeting. + +The suite records browser console, network metadata, DOM, and GPT-event evidence as +CI artifacts. Network capture excludes APS runner and creative response bodies so a +test artifact cannot become an accidental vendor-code archive. It requires no +external analytics, billing, or experiment result. + +## 8. Delivery and rollout + +This work is assembled through test-only constructors while being built, then cuts +over once through the existing APS/TSJS release mechanism. No runtime flag, +old/new selector, compatibility branch, or dual protocol is introduced in any +deployable artifact. + +1. **Contract first:** land descriptor corpus, lifecycle types, adapter interfaces, + and failing tests without changing production behavior. +2. **Kernel and services:** introduce runtime/integration-module ownership, sessions, slot + registry, auction batch, and render lifecycle behind test-only construction. +3. **Server APS path:** make admission, mediation, descriptor projection, targeting, + and renderer route conform to the contract. +4. **Browser integrations:** migrate APS, GPT, Prebid, direct auction, fallback, + local diagnostics, creative processing, every remaining TSJS integration, and + bootstrap to the shared services/integration modules while their `RCJ-*` parity corpus stays + green. +5. **Delete legacy paths:** remove expandos, duplicate bridge branches, old + `requestAds`, legacy globals, duplicated bootstrap behavior, and unused flags + from the release candidate. +6. **Pre-production:** pass all hermetic suites and the protected real-GAM network + in Chromium, Firefox, and WebKit; archive its console, network, DOM, and GPT-event + evidence with the release artifact. +7. **Binary production cutover:** deploy the verified artifact through the + repository's normal release mechanism. This design adds no percentage router or + canary-selection infrastructure. Hold an exclusive production deployment window, + attest the active immutable artifact, and re-check it immediately before cutover; + any mismatch blocks and regenerates evidence. Retain that immediately prior + artifact and roll back the whole cutover on renderer errors, elevated request + failures, CSP/security errors, or non-APS regressions. +8. **Post-cutover:** monitor existing operational signals for 24 hours. The deployed + artifact already contains no temporary development selector. + +Binary rollback restores Trusted Server code but cannot restore older live APS runner +bytes. If the proxied runner becomes unavailable, incompatible, or produces suspect +completion results, the emergency containment action is to disable +`[integrations.aps]`; this stops new APS admission and makes both reserved APS routes +return their local `404 no-store` response. APS remains disabled until controlled +real-browser conformance passes again. + +This document does not prescribe new rollout telemetry. If existing operational +signals are insufficient for a deployment decision, that blocks rollout and is +resolved operationally or in a separate observability spec; it does not justify +adding a hidden analytics subsystem here. + +## 9. Decisions and rejected alternatives + +1. **Patch only the current GPT bridge:** rejected; it leaves duplicated runtime + state, SPA races, direct-auction concurrency, and bootstrap ownership unresolved. +2. **Full TSJS rewrite:** rejected; extract the kernel and migrate behavior in tested + slices so current contracts remain the oracle until cutover. +3. **Use `slotRenderEnded` as render success:** rejected; it observes GAM creative + injection, not APS runner completion. +4. **Use a bid id as the only bridge credential:** rejected; ids can collide, be + truncated, replayed, or arrive from a wrong frame. +5. **Put the lifecycle nonce in `Prebid Request`:** impossible; Universal Creative + owns that message. The nonce is minted only after a validated claim. +6. **Trust creative-document callbacks for ADM/cache:** rejected; bidder-controlled + markup must not hold the acceptance capability. The TS-authored owner observes + its iframe. +7. **Evict live reservations at capacity:** rejected; a late request could fall + through to native Prebid or claim the wrong work. Refuse new registration. +8. **Abort a shared auction fetch when one slot is superseded:** rejected; sibling + attempts may still need the response. +9. **Keep legacy globals/API aliases:** rejected; backward compatibility is not a + requirement and dual paths would preserve the architecture defect. +10. **Add analytics or experiment infrastructure to prove rollout:** rejected as a + separate project. Rendering is proven by contract, browser, and real-GAM + conformance; release uses a pre-production gate and binary artifact rollback. +11. **Check in or release a pinned APS runner:** rejected. Trusted Server owns only + the fixed-target transport proxy and renderer contract; APS owns the live runner + bytes. + +## 10. Risks and mitigations + +| 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 | +| 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 | +| CSP blocks a legitimate APS creative | three-browser real-GAM suite; script creatives remain opt-in; CSP changes are explicit security work | +| Hard cutover breaks stale pages | accepted compatibility stance; verify pre-production and retain the prior immutable artifact for binary rollback | +| Kernel extraction changes unrelated integrations | per-integration pre/post behavior corpus, adapter fakes, current full suites, behavioral maximal-bundle test, and exact disposal assertions | +| `rc/july` moves after the design is approved | pin `905984e62`; stop before code changes, diff all inventoried TSJS/bootstrap/browser paths, and update the ledger/tests explicitly | +| Diagnostics change ad behavior or overclaim a render | one-way observation bus, bounded early-event replay, isolated subscribers, honest `gam-only`/`ok` rules, inactive zero-side-effect tests, and no correctness dependency | +| Bounded registries refuse traffic under extreme churn | explicit reservation `registry_full` and slot `registry_capacity`, lifecycle pruning, capacity stress tests; never trade correctness for eviction | +| GPT event attribution remains ambiguous | fail the TS attempt deterministically and never trigger fallback from ambiguous/publisher-owned activity | +| Late async work mutates new SPA state | generation checks, owned disposers, terminal latch, and adversarial reversed-order tests | +| Browser tests report iframe load but not APS success | require the bound APS render-completion callback and inspect network/DOM evidence | +| APS runner becomes unavailable or stops the callback | load/rejection/silence fail the attempt; real-browser conformance blocks release and APS disablement is the emergency containment path | +| APS runner reports completion incorrectly | accepted external trust risk; protected conformance checks DOM/network behavior, but cannot prove future mutable bytes; suspect behavior disables APS | +| Existing operational signals are weak | do not invent telemetry in this spec; hold deployment or write a separate observability design | + +## 11. Success criteria + +The design is complete when all of the following are true: + +1. The five supported render flows have explicit owners, identity rules, deadlines, + and terminal behavior. +2. APS descriptor production and all three validators agree on the full corpus. +3. Mediation cannot detach price from provenance or attach the wrong renderer. +4. GAM receives a valid, non-truncated identity for every accepted TS renderer bid. +5. Duplicate, replayed, wrong-source, stale-navigation, and late lifecycle messages + cannot render or settle twice. +6. Direct multi-slot auctions settle every requested slot and obey child-versus-batch + cancellation. +7. The runtime has one slot registry, one bridge listener, one adapter instance per + external library, and one explicit owner for every timer/listener/port/iframe. +8. Legacy expandos, duplicate bridge branches, old globals, old `requestAds`, and + duplicated bootstrap logic are absent from the final bundle. The `pub_id` config + alias, `/__ts/page-bids`, its JS retry, and the unversioned APS renderer path are + absent from server routes, tests, and documentation. Every bootstrap failure + checkpoint commits the terminal non-rendering shell, drains work exactly once, + and cannot construct or admit a second runtime. +9. APS renderer and runner-proxy routing, security headers, bounded relay, and failure + behavior are proven through each real adapter transport, are equivalent across all + four adapters, and never fall through to publishers. APS runner bytes are neither + stored in the repository nor required to be identical across different upstream + fetches. +10. Rust, TypeScript, ESLint, Vitest, hermetic Playwright, adapter parity, and + real-GAM conformance suites pass. +11. Non-APS Cache/ADM rendering, native Prebid handling, publisher-owned GPT, refresh, + SRA, and SPA regression suites pass. +12. No analytics, persistence, billing, experimentation, or deployment-routing + artifact is added by the implementation plan. +13. `requestAds` accepts only exact server-projected or transactionally registered + programmatic slot ids, omitted selection is an immutable invocation-time + registration-order snapshot, and ambiguous internal aliases fail closed without + affecting valid siblings. +14. Direct and PUC ADM acceptance is possible only for the exact current frame's one + intended `srcdoc` navigation; initial blank, replacement, removal, stale, late, + and duplicate events cannot accept. +15. Every `RCJ-*` ledger entry maps to an executable pre-cutover fixture, one final + owner, focused final tests, and a preserved/rebuilt/superseded disposition; the + final manifest has no unmapped TSJS source or browser contract. +16. Late GPT handoff, hydrated/ responsive DOM replacement, Prebid partial-artifact + recovery and refresh exclusions, creative security, render trace, GPT + diagnostics, and every remaining TSJS integration pass their complete parity + suites after the hard cutover. +17. PUC owner, renderer document, and descendant creative dimensions are exact and + unclipped for the winning size, while collapsed-shell correction cannot resize an + unrelated, anchor, fixed, sticky, disconnected, or already-expanded frame. +18. Programmatic ad units register transactionally into the navigation slot service, + participate in deterministic direct-auction snapshots, and render through the + same lifecycle; placeholder rendering and mutable generic runtime configuration + are absent rather than silently retained as a second path. +19. The committed `TsjsApi` kernel/fallback surfaces, semantic version, exact + release identity, queue, logger, immutable boot data, and diagnostics presence are + executable contracts; creative guards auto-install from `CreativeBootV1` with no + mutable/install global API. +20. Attempt ids require no issued-id history, and reservation/ticket/nonce registries + refuse capacity or collision exhaustion without exposing a reusable capability. +21. The external Prebid artifact remains free of TS auction/render behavior, exposes + only its exact own frozen 10.26.0 build stamp, and the TS-owned adapter admits a + fully prepared bid without partial publication. + +## 12. Open implementation decisions + +These choices may be resolved in the implementation plan without changing the +architecture: + +- exact source-file boundaries inside `kernel/`, `adapters/`, and `services/`; +- whether the canonical descriptor schema is generated from Rust metadata or a + small neutral schema file, provided all validators share the corpus and staleness + check; +- the repository's existing feature/config switch used only while the new path is + under construction; +- exact operational thresholds for the existing binary deployment mechanism. + +The `RCJ-*` ledger membership, behavioral dispositions, public diagnostics +namespace, Prebid artifact independence, and integration parity requirement are not +open implementation decisions. + +They may not be resolved by adding compatibility shims, a second runtime, external +telemetry requirements, durable persistence, experiment routing, or a weaker +lifecycle success definition. From 0b2dbfbf5d8eb4316b47ac0b081ab8ff7bfbcea2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:57:01 -0700 Subject: [PATCH 553/844] Establish APS and TSJS implementation baseline --- .github/workflows/integration-tests.yml | 35 +- .github/workflows/test.yml | 9 + .../tests/shared/tsjs-performance.spec.ts | 228 ++++++++ crates/trusted-server-js/lib/build-all.mjs | 69 ++- crates/trusted-server-js/lib/package.json | 1 + .../lib/scripts/check-bundle-budgets.mjs | 137 +++++ .../lib/scripts/check-rc-july-adoption.mjs | 166 ++++++ .../trusted-server-js/lib/src/core/auction.ts | 10 +- .../trusted-server-js/lib/src/core/config.ts | 3 +- .../lib/src/core/registry.ts | 2 +- .../trusted-server-js/lib/src/core/request.ts | 11 +- .../trusted-server-js/lib/src/core/types.ts | 255 +++++---- .../lib/src/integrations/creative/click.ts | 2 +- .../lib/src/integrations/gpt/index.ts | 24 +- .../src/integrations/gpt_diagnostics/api.ts | 8 +- .../integrations/gpt_diagnostics/badges.ts | 12 +- .../integrations/gpt_diagnostics/binding.ts | 12 +- .../integrations/gpt_diagnostics/observer.ts | 23 +- .../integrations/gpt_diagnostics/overlay.ts | 22 +- .../src/integrations/gpt_diagnostics/store.ts | 44 +- .../lib/src/integrations/osano/index.ts | 2 +- .../lib/src/integrations/prebid/index.ts | 16 +- .../lib/src/integrations/sourcepoint/index.ts | 26 +- .../lib/src/integrations/testlight/index.ts | 3 +- .../src/shared/dom_insertion_dispatcher.ts | 8 +- .../test/contract/rc-july-adoption.test.mjs | 29 + .../lib/test/core/auction.test.ts | 49 +- .../lib/test/core/registry.test.ts | 2 +- .../lib/test/core/request.test.ts | 5 +- .../lib/test/core/trace.test.ts | 515 ++++++++++++++++++ .../performance/aps-tsjs-prechange.json | 86 +++ .../lib/test/integrations/aps/render.test.ts | 44 +- .../test/integrations/creative/click.test.ts | 4 +- .../google_tag_manager/script_guard.test.ts | 12 +- .../lib/test/integrations/gpt/ad_init.test.ts | 173 +++--- .../lib/test/integrations/gpt/index.test.ts | 16 +- .../gpt_diagnostics/binding.test.ts | 2 +- .../gpt_diagnostics/index.test.ts | 6 +- .../gpt_diagnostics/observer.test.ts | 18 +- .../gpt_diagnostics/store.test.ts | 66 +-- .../lib/test/integrations/osano/index.test.ts | 2 +- .../test/integrations/prebid/index.test.ts | 352 ++++++------ .../integrations/sourcepoint/index.test.ts | 2 +- .../lib/test/shared/beacon_guard.test.ts | 9 +- crates/trusted-server-js/lib/tsconfig.json | 9 +- scripts/dispatch-workflow-run.mjs | 166 ++++++ scripts/integration-tests-browser.sh | 65 ++- 47 files changed, 2126 insertions(+), 634 deletions(-) create mode 100644 crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts create mode 100644 crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs create mode 100644 crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs create mode 100644 crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs create mode 100644 crates/trusted-server-js/lib/test/core/trace.test.ts create mode 100644 crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json create mode 100644 scripts/dispatch-workflow-run.mjs diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 4973afe44..55db2a176 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,4 +1,6 @@ name: "Integration Tests" +run-name: >- + Integration Tests / ${{ inputs.evidence_id || format('PR {0}', github.event.pull_request.number) }} permissions: contents: read @@ -9,6 +11,11 @@ on: pull_request: types: [opened, synchronize, reopened] workflow_dispatch: + inputs: + evidence_id: + description: Unique identifier used to bind this run to an evidence artifact + required: true + type: string env: ORIGIN_PORT: 8888 @@ -155,7 +162,7 @@ jobs: browser-tests: name: browser integration tests needs: prepare-artifacts - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -244,3 +251,29 @@ jobs: name: playwright-traces path: crates/trusted-server-integration-tests/browser/test-results/ retention-days: 7 + + - name: Record TSJS pre-change performance evidence + if: github.event_name == 'workflow_dispatch' + 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.toml + TEST_FRAMEWORK: nextjs + TSJS_PERF_MODE: baseline + TSJS_PERF_OUTPUT: crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json + TSJS_PERF_MACHINE_CLASS: github-hosted:ubuntu-24.04 + TSJS_EVIDENCE_ID: ${{ inputs.evidence_id }} + run: >- + npm exec -- playwright test + tests/shared/tsjs-performance.spec.ts + --project=chromium + + - name: Upload TSJS pre-change performance evidence + if: github.event_name == 'workflow_dispatch' && always() + uses: actions/upload-artifact@v4 + with: + name: tsjs-performance-${{ inputs.evidence_id }} + path: crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f1bbe27a..9c9c511af 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -268,5 +268,14 @@ jobs: - name: Build bundle run: npm run build + - name: Typecheck full TSJS package + run: npm run typecheck + + - name: Lint full TSJS package + run: npm run lint + + - name: Verify rc/july adoption manifest + run: node --test test/contract/rc-july-adoption.test.mjs + - name: Run unit tests run: npm test -- --run 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 new file mode 100644 index 000000000..f88f57f55 --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts @@ -0,0 +1,228 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { test, expect, type Browser, type Page } from "@playwright/test"; + +const REPO_ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", +}).trim(); +const TSJS_CRATE = resolve(REPO_ROOT, "crates/trusted-server-js"); +const CORE_BUNDLE = resolve(TSJS_CRATE, "dist/tsjs-core.js"); +const BUILD_METRICS = resolve(TSJS_CRATE, "dist/tsjs-build-metrics-v1.json"); +const WARMUPS = 5; +const SAMPLES = 50; +const PERCENTILE = 90; + +type HeapCheckpoint = + | "afterBoot" + | "afterFirstRender" + | "afterRefresh" + | "afterSpaNavigation"; + +interface PerfApi { + addAdUnits(unit: { + code: string; + mediaTypes: { banner: { sizes: Array<[number, number]> } }; + }): void; + renderAdUnit(code: string): void; +} + +function fixtureDocument(): string { + return ` + +TSJS deterministic performance fixture v1 +
+`; +} + +async function openFixture( + browser: Browser, +): Promise<{ page: Page; close(): Promise }> { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.setContent(fixtureDocument(), { waitUntil: "load" }); + await page.addScriptTag({ path: CORE_BUNDLE }); + return { page, close: () => context.close() }; +} + +async function render(page: Page): Promise { + return page.evaluate(() => { + const perfWindow = window as unknown as { + tsjs: PerfApi; + __tsjsPerf: { bootStartedAt: number; firstDisplayAt: number | null }; + }; + perfWindow.tsjs.addAdUnits({ + code: "perf-slot", + mediaTypes: { banner: { sizes: [[300, 250]] } }, + }); + perfWindow.tsjs.renderAdUnit("perf-slot"); + const firstDisplayAt = + perfWindow.__tsjsPerf.firstDisplayAt ?? performance.now(); + return firstDisplayAt - perfWindow.__tsjsPerf.bootStartedAt; + }); +} + +async function collectHeap(page: Page): Promise { + const session = await page.context().newCDPSession(page); + try { + await session.send("HeapProfiler.collectGarbage"); + const usage = await session.send("Runtime.getHeapUsage"); + return usage.usedSize as number; + } finally { + await session.detach(); + } +} + +function p90(values: number[]): number { + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.ceil((PERCENTILE / 100) * ordered.length) - 1]!; +} + +function packageVersion(packagePath: string): string { + const packageJson = JSON.parse(readFileSync(packagePath, "utf8")) as { + version: string; + }; + return packageJson.version; +} + +test.describe("TSJS deterministic performance evidence", () => { + test("records bundle, p90 display, and forced-GC heap baselines", async ({ + browser, + browserName, + }) => { + test.setTimeout(180_000); + const mode = process.env.TSJS_PERF_MODE; + test.skip( + mode !== "baseline" && mode !== "gate", + "performance evidence run only", + ); + expect(browserName).toBe("chromium"); + + for (let index = 0; index < WARMUPS; index += 1) { + const fixture = await openFixture(browser); + try { + await render(fixture.page); + } finally { + await fixture.close(); + } + } + + const displaySamplesMs: number[] = []; + for (let index = 0; index < SAMPLES; index += 1) { + const fixture = await openFixture(browser); + try { + displaySamplesMs.push(await render(fixture.page)); + } finally { + await fixture.close(); + } + } + + const heapFixture = await openFixture(browser); + const retainedHeapBytes = {} as Record; + try { + retainedHeapBytes.afterBoot = await collectHeap(heapFixture.page); + await render(heapFixture.page); + retainedHeapBytes.afterFirstRender = await collectHeap(heapFixture.page); + await heapFixture.page.evaluate(() => { + const perfWindow = window as unknown as { tsjs: PerfApi }; + perfWindow.tsjs.renderAdUnit("perf-slot"); + }); + retainedHeapBytes.afterRefresh = await collectHeap(heapFixture.page); + await heapFixture.page.evaluate(() => { + location.hash = "performance-fixture-navigation"; + const oldSlot = document.getElementById("perf-slot"); + const replacement = document.createElement("div"); + replacement.id = "perf-slot"; + oldSlot?.replaceWith(replacement); + const perfWindow = window as unknown as { tsjs: PerfApi }; + perfWindow.tsjs.renderAdUnit("perf-slot"); + }); + retainedHeapBytes.afterSpaNavigation = await collectHeap( + heapFixture.page, + ); + } finally { + await heapFixture.close(); + } + + const outputArgument = process.env.TSJS_PERF_OUTPUT; + expect(outputArgument, "TSJS_PERF_OUTPUT is required").toBeTruthy(); + const outputPath = isAbsolute(outputArgument!) + ? outputArgument! + : resolve(REPO_ROOT, outputArgument!); + const buildMetrics = JSON.parse(readFileSync(BUILD_METRICS, "utf8")) as { + schemaVersion: number; + sets: Record; + }; + expect(buildMetrics.schemaVersion).toBe(1); + + const npmVersion = execFileSync("npm", ["--version"], { + encoding: "utf8", + }).trim(); + const artifact = { + schemaVersion: 1, + mode, + source: { + ref: execFileSync("git", ["branch", "--show-current"], { + cwd: REPO_ROOT, + encoding: "utf8", + }).trim(), + sha: execFileSync("git", ["rev-parse", "HEAD"], { + cwd: REPO_ROOT, + encoding: "utf8", + }).trim(), + }, + environment: { + node: process.version, + npm: npmVersion, + typescript: packageVersion( + resolve( + REPO_ROOT, + "crates/trusted-server-js/lib/node_modules/typescript/package.json", + ), + ), + chromium: browser.version(), + ciMachineClass: + process.env.TSJS_PERF_MACHINE_CLASS ?? + (process.env.CI + ? "github-hosted:ubuntu-latest" + : `local:${process.platform}-${process.arch}`), + fixture: "tsjs-core-placeholder-v1", + }, + sampling: { + warmups: WARMUPS, + samples: SAMPLES, + percentile: PERCENTILE, + }, + bundles: buildMetrics.sets, + performance: { + bootToFirstDisplayMs: { + samples: displaySamplesMs, + p90: p90(displaySamplesMs), + }, + retainedHeapBytes, + }, + evidence: { + evidenceId: process.env.TSJS_EVIDENCE_ID ?? null, + workflowRunId: process.env.GITHUB_RUN_ID + ? Number(process.env.GITHUB_RUN_ID) + : null, + }, + }; + + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(artifact, null, 2)}\n`); + expect(displaySamplesMs).toHaveLength(SAMPLES); + expect(Object.values(retainedHeapBytes)).toHaveLength(4); + }); +}); diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 2bfee01b1..17b12c11d 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -15,7 +15,9 @@ */ import fs from 'node:fs'; +import { createHash } from 'node:crypto'; import path from 'node:path'; +import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:zlib'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; @@ -23,6 +25,37 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const srcDir = path.resolve(__dirname, 'src'); const distDir = path.resolve(__dirname, '..', 'dist'); const integrationsDir = path.join(srcDir, 'integrations'); +const metricsFile = 'tsjs-build-metrics-v1.json'; + +const REFERENCE_INTEGRATIONS = ['creative', 'gpt', 'prebid']; + +function compress(bytes) { + return { + gzipBytes: gzipSync(bytes, { level: 9, mtime: 0 }).byteLength, + brotliBytes: brotliCompressSync(bytes, { + params: { + [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT, + [zlibConstants.BROTLI_PARAM_QUALITY]: 11, + [zlibConstants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, + }, + }).byteLength, + }; +} + +function measureBundleSet(files) { + const separator = Buffer.from('\n;\n', 'utf8'); + const parts = files.flatMap((file, index) => { + const bytes = fs.readFileSync(path.join(distDir, file)); + return index === files.length - 1 ? [bytes] : [bytes, separator]; + }); + const bytes = Buffer.concat(parts); + return { + files, + rawBytes: bytes.byteLength, + ...compress(bytes), + sha256: createHash('sha256').update(bytes).digest('hex'), + }; +} // Clean dist directory fs.rmSync(distDir, { recursive: true, force: true }); @@ -39,7 +72,7 @@ const integrationModules = fs.existsSync(integrationsDir) ); }) .sort() - : []; + : []; console.log('[build-all] Discovered integrations:', integrationModules); @@ -89,5 +122,39 @@ const builtFiles = fs .filter((f) => f.startsWith('tsjs-') && f.endsWith('.js')) .sort(); +const referenceFiles = ['tsjs-core.js', ...REFERENCE_INTEGRATIONS.map((name) => `tsjs-${name}.js`)]; +for (const file of referenceFiles) { + if (!builtFiles.includes(file)) { + throw new Error(`[build-all] Reference bundle file was not built: ${file}`); + } +} + +const metrics = { + schemaVersion: 1, + compression: { + concatenationSeparator: '\\n;\\n', + gzipLevel: 9, + gzipMtime: 0, + brotliMode: 'text', + brotliQuality: 11, + }, + modules: builtFiles.map((file) => { + const bytes = fs.readFileSync(path.join(distDir, file)); + return { + file, + rawBytes: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; + }), + sets: { + minimal: measureBundleSet(['tsjs-core.js']), + reference: measureBundleSet(referenceFiles), + maximal: measureBundleSet(builtFiles), + }, +}; + +fs.writeFileSync(path.join(distDir, metricsFile), `${JSON.stringify(metrics, null, 2)}\n`); + console.log('[build-all] Built files:', builtFiles); console.log(`[build-all] Total: ${builtFiles.length} modules`); +console.log(`[build-all] Wrote deterministic metrics: ${metricsFile}`); diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index 427fef1e1..e8e732736 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -10,6 +10,7 @@ "dev": "vite build --watch", "test": "vitest run", "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint --fix . --max-warnings=0", "format": "prettier --check \"**/*.{ts,tsx,js,json,css,md}\"", diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs new file mode 100644 index 000000000..fe79ea538 --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const libDir = path.resolve(scriptDir, '..'); +const defaultBaselinePath = path.join( + libDir, + 'test', + 'fixtures', + 'performance', + 'aps-tsjs-prechange.json' +); +const metricsPath = path.resolve(libDir, '..', 'dist', 'tsjs-build-metrics-v1.json'); +const SET_NAMES = ['minimal', 'reference', 'maximal']; +const SIZE_NAMES = ['rawBytes', 'gzipBytes', 'brotliBytes']; +const MAX_GROWTH = 1.05; + +function fail(message) { + throw new Error(`[bundle-budgets] ${message}`); +} + +function readJson(file, label) { + if (!fs.existsSync(file)) fail(`${label} does not exist: ${file}`); + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + fail(`${label} is not valid JSON (${file}): ${error instanceof Error ? error.message : error}`); + } +} + +function assertPositiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) fail(`${label} must be a positive integer`); +} + +function validateSets(sets, label) { + if (!sets || typeof sets !== 'object' || Array.isArray(sets)) { + fail(`${label} must be an object`); + } + for (const setName of SET_NAMES) { + const set = sets[setName]; + if (!set || typeof set !== 'object' || Array.isArray(set)) { + fail(`${label}.${setName} must be an object`); + } + if (!Array.isArray(set.files) || set.files.length === 0) { + fail(`${label}.${setName}.files must be a non-empty array`); + } + for (const [index, file] of set.files.entries()) { + if (typeof file !== 'string' || !/^tsjs-[a-z0-9_]+\.js$/.test(file)) { + fail(`${label}.${setName}.files[${index}] is not a canonical TSJS bundle filename`); + } + } + if (new Set(set.files).size !== set.files.length) { + fail(`${label}.${setName}.files contains a duplicate`); + } + for (const sizeName of SIZE_NAMES) { + assertPositiveInteger(set[sizeName], `${label}.${setName}.${sizeName}`); + } + if (typeof set.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(set.sha256)) { + fail(`${label}.${setName}.sha256 must be 64 lowercase hexadecimal characters`); + } + } +} + +function parseArgs(argv) { + const options = { baselineOnly: false, baselinePath: defaultBaselinePath }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--baseline-only') { + options.baselineOnly = true; + } else if (argument === '--baseline') { + const value = argv[index + 1]; + if (!value) fail('--baseline requires a path'); + options.baselinePath = path.resolve(value); + index += 1; + } else { + fail(`unknown argument: ${argument}`); + } + } + return options; +} + +export function checkBundleBudgets({ + baselineOnly = false, + baselinePath = defaultBaselinePath, +} = {}) { + const baseline = readJson(baselinePath, 'baseline'); + const metrics = readJson(metricsPath, 'build metrics'); + if (baseline.schemaVersion !== 1) fail('baseline.schemaVersion must equal 1'); + if (metrics.schemaVersion !== 1) fail('build metrics schemaVersion must equal 1'); + validateSets(baseline.bundles, 'baseline.bundles'); + validateSets(metrics.sets, 'buildMetrics.sets'); + + const failures = []; + for (const setName of SET_NAMES) { + const expected = baseline.bundles[setName]; + const actual = metrics.sets[setName]; + if (JSON.stringify(actual.files) !== JSON.stringify(expected.files)) { + failures.push( + `${setName}.files changed: expected ${JSON.stringify(expected.files)}, got ${JSON.stringify(actual.files)}` + ); + } + for (const sizeName of SIZE_NAMES) { + const limit = baselineOnly ? expected[sizeName] : Math.floor(expected[sizeName] * MAX_GROWTH); + if (actual[sizeName] > limit) { + failures.push( + `${setName}.${sizeName} is ${actual[sizeName]} bytes; limit is ${limit} from baseline ${expected[sizeName]}` + ); + } + } + if (baselineOnly && actual.sha256 !== expected.sha256) { + failures.push(`${setName}.sha256 differs from the pre-change build`); + } + } + + if (failures.length > 0) fail(`budget check failed:\n- ${failures.join('\n- ')}`); + + return { + baselineOnly, + baselinePath, + maxGrowthPercent: baselineOnly ? 0 : 5, + sets: metrics.sets, + }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const result = checkBundleBudgets(parseArgs(process.argv.slice(2))); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : error}\n`); + process.exitCode = 1; + } +} diff --git a/crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs b/crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs new file mode 100644 index 000000000..f80eb1b1d --- /dev/null +++ b/crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs @@ -0,0 +1,166 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const MANIFEST_FENCE = /```json rcjuly-tsjs-manifest-v1\n([\s\S]*?)\n```/g; +const LEDGER_ID = /\| `(RCJ-[A-Z]+-[0-9]+)`/g; +const QUALITY_ID = 'RCJ-QUAL-01'; +const SOURCE_ROOT = 'crates/trusted-server-js/lib/src/'; + +function sorted(values) { + return [...values].sort((left, right) => left.localeCompare(right)); +} + +function gitLines(repositoryRoot, args) { + const output = execFileSync('git', args, { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + return output.split('\n').filter(Boolean); +} + +function gitObjectExists(repositoryRoot, objectName) { + try { + execFileSync('git', ['cat-file', '-e', objectName], { + cwd: repositoryRoot, + stdio: 'ignore', + }); + return true; + } catch { + return false; + } +} + +function extractManifest(specSource) { + const matches = [...specSource.matchAll(MANIFEST_FENCE)]; + if (matches.length !== 1 || typeof matches[0]?.[1] !== 'string') { + throw new Error(`expected exactly one rcjuly-tsjs-manifest-v1 block, found ${matches.length}`); + } + + const manifest = JSON.parse(matches[0][1]); + if ( + manifest === null || + typeof manifest !== 'object' || + manifest.version !== 1 || + typeof manifest.baseline !== 'string' || + !Array.isArray(manifest.includeRoots) || + !Array.isArray(manifest.mappings) + ) { + throw new Error('rc/july adoption manifest has an invalid outer shape'); + } + + return manifest; +} + +function mappingMatches(file, mapping) { + return ( + (Array.isArray(mapping.exact) && mapping.exact.includes(file)) || + (typeof mapping.prefix === 'string' && file.startsWith(mapping.prefix)) || + (Array.isArray(mapping.prefixes) && mapping.prefixes.some((prefix) => file.startsWith(prefix))) + ); +} + +function mappingIdsForFile(file, mappings) { + const ids = new Set(); + for (const mapping of mappings) { + if (!mappingMatches(file, mapping)) continue; + for (const id of mapping.ids ?? []) ids.add(id); + } + return ids; +} + +export function auditRcJulyAdoption({ repositoryRoot, specPath }) { + const specSource = fs.readFileSync(specPath, 'utf8'); + const manifest = extractManifest(specSource); + const files = new Set(); + + for (const includeRoot of manifest.includeRoots) { + for (const file of gitLines(repositoryRoot, [ + 'ls-tree', + '-r', + '--name-only', + manifest.baseline, + '--', + includeRoot, + ])) { + files.add(file); + } + } + + for (const mapping of manifest.mappings) { + for (const file of mapping.exact ?? []) { + if (gitObjectExists(repositoryRoot, `${manifest.baseline}:${file}`)) files.add(file); + } + } + + const orderedFiles = sorted(files); + const unmappedFiles = orderedFiles.filter( + (file) => !manifest.mappings.some((mapping) => mappingMatches(file, mapping)) + ); + const qualityOnlySourceFiles = orderedFiles.filter((file) => { + if (!file.startsWith(SOURCE_ROOT)) return false; + const ids = mappingIdsForFile(file, manifest.mappings); + return ![...ids].some((id) => id !== QUALITY_ID); + }); + const deadMappings = manifest.mappings + .map((mapping, index) => ({ index, mapping })) + .filter(({ mapping }) => !orderedFiles.some((file) => mappingMatches(file, mapping))) + .map(({ index }) => index); + + const manifestIds = new Set(manifest.mappings.flatMap((mapping) => mapping.ids ?? [])); + const ledgerIds = new Set([...specSource.matchAll(LEDGER_ID)].map((match) => match[1])); + const manifestOnlyIds = sorted([...manifestIds].filter((id) => !ledgerIds.has(id))); + const ledgerOnlyIds = sorted([...ledgerIds].filter((id) => !manifestIds.has(id))); + + return { + baseline: manifest.baseline, + fileCount: orderedFiles.length, + mappingCount: manifest.mappings.length, + manifestIdCount: manifestIds.size, + ledgerIdCount: ledgerIds.size, + unmappedFiles, + qualityOnlySourceFiles, + deadMappings, + manifestOnlyIds, + ledgerOnlyIds, + }; +} + +export function assertRcJulyAdoption(result) { + const failures = []; + if (result.fileCount !== 144) failures.push(`expected 144 files, found ${result.fileCount}`); + if (result.mappingCount !== 38) { + failures.push(`expected 38 mappings, found ${result.mappingCount}`); + } + if (result.manifestIdCount !== 23 || result.ledgerIdCount !== 23) { + failures.push( + `expected 23 manifest/ledger ids, found ${result.manifestIdCount}/${result.ledgerIdCount}` + ); + } + for (const key of [ + 'unmappedFiles', + 'qualityOnlySourceFiles', + 'deadMappings', + 'manifestOnlyIds', + 'ledgerOnlyIds', + ]) { + if (result[key].length > 0) failures.push(`${key}: ${JSON.stringify(result[key])}`); + } + if (failures.length > 0) throw new Error(failures.join('\n')); +} + +const scriptPath = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + const repositoryRoot = path.resolve(path.dirname(scriptPath), '../../../..'); + const specPath = path.join( + repositoryRoot, + 'docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md' + ); + const result = auditRcJulyAdoption({ repositoryRoot, specPath }); + assertRcJulyAdoption(result); + process.stdout.write(`${JSON.stringify(result)}\n`); +} diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts index a02684362..7db67fbc8 100644 --- a/crates/trusted-server-js/lib/src/core/auction.ts +++ b/crates/trusted-server-js/lib/src/core/auction.ts @@ -45,9 +45,9 @@ export interface AuctionBid { /** Matches the `impid` in the response — corresponds to adUnit `code`. */ impid: string; /** Creative HTML (already rewritten with proxy URLs by the server). */ - adm: string; + adm?: string | undefined; /** Typed APS renderer descriptor, when the bid does not carry `adm`. */ - renderer?: ApsRendererV1; + renderer?: ApsRendererV1 | undefined; /** CPM price. */ price: number; /** Creative width. */ @@ -60,6 +60,12 @@ export interface AuctionBid { creativeId: string; /** Advertiser domains. */ adomain: string[]; + /** Server-side auction ID used for render tracing. */ + auctionId?: string | undefined; + /** Upstream OpenRTB bid ID used for render tracing. */ + bidId?: string | undefined; + /** Trace hash of the delivered creative markup. */ + admHash?: string | undefined; } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-js/lib/src/core/config.ts b/crates/trusted-server-js/lib/src/core/config.ts index c0bbe7428..7026c4420 100644 --- a/crates/trusted-server-js/lib/src/core/config.ts +++ b/crates/trusted-server-js/lib/src/core/config.ts @@ -1,5 +1,6 @@ // Global configuration storage for the tsjs runtime (logging, debug, etc.). -import { log, LogLevel } from './log'; +import { log } from './log'; +import type { LogLevel } from './log'; export interface Config { debug?: boolean; diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 9af4a0a34..06401a5e6 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -17,7 +17,7 @@ export function addAdUnits(units: AdUnit | AdUnit[]): void { // 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 sizes && sizes.length ? sizes[0]! : null; } // Return a snapshot array of all registered ad units. diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts index c05070a9d..894ea068d 100644 --- a/crates/trusted-server-js/lib/src/core/request.ts +++ b/crates/trusted-server-js/lib/src/core/request.ts @@ -9,18 +9,21 @@ import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } export type RequestAdsCallback = () => void; export interface RequestAdsOptions { - bidsBackHandler?: RequestAdsCallback; - timeout?: number; + bidsBackHandler?: RequestAdsCallback | undefined; + timeout?: number | undefined; } type RenderCreativeInlineOptions = { slotId: string; // Accept unknown input here because bidder JSON is untrusted at runtime. creativeHtml: unknown; - creativeWidth?: number; - creativeHeight?: number; + creativeWidth?: number | undefined; + creativeHeight?: number | undefined; seat: string; creativeId: string; + auctionId?: string | undefined; + bidId?: string | undefined; + admHash?: string | undefined; }; // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 9caaf5b35..1a14775fd 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -6,18 +6,18 @@ export interface Banner { } export interface MediaTypes { - banner?: Banner; + banner?: Banner | undefined; } export interface Bid { bidder: string; - params?: Record; + params?: Record | undefined; } export interface AdUnit { code: string; - mediaTypes?: MediaTypes; - bids?: Bid[]; + mediaTypes?: MediaTypes | undefined; + bids?: Bid[] | undefined; } /** Minimal shape of a server-side auction slot injected into `window.tsjs.adSlots`. */ @@ -26,28 +26,28 @@ export interface AuctionSlot { gam_unit_path: string; div_id: string; formats: Array<[number, number]>; - targeting?: Record; + targeting?: Record | undefined; } /** Debug-only copy of server-side bid fields exposed for pipeline inspection. */ export interface AuctionDebugBidData { - slot_id?: string; - price?: number | null; - currency?: string; - creative?: string | null; - adomain?: string[] | null; - bidder?: string; - width?: number; - height?: number; - nurl?: string | null; - burl?: string | null; - bid_id?: string | null; - ad_id?: string | null; - creative_id?: string | null; - cache_id?: string | null; - cache_host?: string | null; - cache_path?: string | null; - metadata?: Record; + slot_id?: string | undefined; + price?: number | null | undefined; + currency?: string | undefined; + creative?: string | null | undefined; + adomain?: string[] | null | undefined; + bidder?: string | undefined; + width?: number | undefined; + height?: number | undefined; + nurl?: string | null | undefined; + burl?: string | null | undefined; + bid_id?: string | null | undefined; + ad_id?: string | null | undefined; + creative_id?: string | null | undefined; + cache_id?: string | null | undefined; + cache_host?: string | null | undefined; + cache_path?: string | null | undefined; + metadata?: Record | undefined; } export type ApsTagType = 'iframe' | 'script'; @@ -58,7 +58,7 @@ export interface ApsRendererV1 { version: 1; accountId: string; bidId: string; - creativeId?: string; + creativeId?: string | undefined; tagType: ApsTagType; creativeUrl: string; aaxResponse: string; @@ -80,32 +80,76 @@ export interface ApsPrebidRendererEntry { /** Bid targeting data from the server-side auction, injected into `window.tsjs.bids`. */ export interface AuctionBidData { - hb_pb?: string; - hb_bidder?: string; - hb_adid?: string; - hb_cache_host?: string; - hb_cache_path?: string; - /** Opaque server-auction correlation ID used only by GPT diagnostics. */ - hb_auction_id?: 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. */ - h?: number; - nurl?: string; - burl?: string; + hb_pb?: string | undefined; + hb_bidder?: string | undefined; + hb_adid?: string | undefined; + hb_cache_host?: string | undefined; + hb_cache_path?: string | undefined; + /** Trace-only OpenRTB bid identifier. */ + hb_bid_id?: string | undefined; + /** Trace-only server-side auction identifier. */ + hb_auction_id?: string | undefined; + /** Trace-only OpenRTB creative identifier. */ + hb_crid?: string | undefined; + /** Trace hash of delivered creative markup. */ + hb_adm_hash?: string | undefined; + nurl?: string | undefined; + burl?: string | undefined; /** Typed winning-bid renderer capability. */ - renderer?: AuctionBidRenderer; + renderer?: AuctionBidRenderer | undefined; /** Winning creative width used by the inline render bridge. */ - w?: number; + w?: number | undefined; /** Winning creative height used by the inline render bridge. */ - h?: number; + h?: number | undefined; /** * Sanitized winning creative markup for the inline render bridge. Present * when the server retained a non-empty creative; not gated by debug mode. */ - adm?: string; + adm?: string | undefined; /** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */ - debug_bid?: AuctionDebugBidData; + debug_bid?: AuctionDebugBidData | undefined; +} + +/** How a creative reached the page for a [`RenderRecord`]. */ +export type RenderServedFrom = 'inline' | 'gam' | 'debug-adm' | 'pbs-cache' | 'prebid'; + +/** Client-side record joining a rendered creative to its auction winner. */ +export interface RenderRecord { + slotId: string; + path: 'auction' | 'ssat' | 'gam-refresh'; + rendered: boolean; + elementId?: string | undefined; + auctionId?: string | undefined; + bidder?: string | undefined; + adId?: string | undefined; + bidId?: string | undefined; + creativeId?: string | undefined; + admHash?: string | undefined; + servedFrom?: RenderServedFrom | undefined; + gamEmpty?: boolean | undefined; + injected?: boolean | undefined; + visible?: boolean | undefined; + count: number; + seq: number; + at: number; +} + +/** + * 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]>; + /** Stable configured prefix used to safely bridge framework-generated IDs. */ + divIdPrefix: string; + /** Element ID GPT received when TS created the fallback slot. */ + slotElementId: string; + publisherClaimed: boolean; + suppressPublisherDisplay: boolean; + suppressPublisherRefresh: boolean; } export type GptDiagnosticsCallbackKind = @@ -127,15 +171,15 @@ export type GptDiagnosticsBindingReason = export interface GptDiagnosticsBinding { status: 'bound' | 'unbound' | 'ambiguous'; - reason?: GptDiagnosticsBindingReason; + reason?: GptDiagnosticsBindingReason | undefined; } export interface GptDiagnosticsDurations { - requestToResponseMs?: number; - responseToRenderMs?: number; - requestToRenderMs?: number; - renderToLoadMs?: number; - renderToViewableMs?: number; + requestToResponseMs?: number | undefined; + responseToRenderMs?: number | undefined; + requestToRenderMs?: number | undefined; + renderToLoadMs?: number | undefined; + renderToViewableMs?: number | undefined; } /** @@ -200,24 +244,16 @@ export type GptDiagnosticsDelivery = export interface GptDiagnosticsRequestCycle { requestNumber: number; - requestedAtMs?: number; - responseAtMs?: number; - renderAtMs?: number; - loadAtMs?: number; - viewableAtMs?: number; + requestedAtMs?: number | undefined; + responseAtMs?: number | undefined; + renderAtMs?: number | undefined; + loadAtMs?: number | undefined; + viewableAtMs?: number | undefined; durations: GptDiagnosticsDurations; - isEmpty?: boolean; - /** Configured sizes Trusted Server supplied to GPT for this request. */ - requestedSlotSizes?: ReadonlyArray; - /** Exact fill size fact GPT reported in its `slotRenderEnded` callback. */ - size?: Size; - /** - * Outer CSS box observed on the uniquely bound, connected slot element after - * a filled GPT render. This is not an assertion about internal creative pixels. - */ - observedSlotSize?: Size; - isBackfill?: boolean; - slotContentChanged?: boolean; + isEmpty?: boolean | undefined; + size?: Size | undefined; + isBackfill?: boolean | undefined; + slotContentChanged?: boolean | undefined; incompleteSequence: boolean; adManager?: GptDiagnosticsAdManagerIdentity; responseClass?: GptDiagnosticsResponseClass; @@ -240,18 +276,18 @@ export interface GptDiagnosticsRequestCycle { export interface GptDiagnosticsSlotExport { runtimeSlotNumber: number; - slotElementId?: string; - adUnitPath?: string; + slotElementId?: string | undefined; + adUnitPath?: string | undefined; binding: GptDiagnosticsBinding; - currentVisibilityPercentage?: number; - maximumVisibilityPercentage?: number; + currentVisibilityPercentage?: number | undefined; + maximumVisibilityPercentage?: number | undefined; requests: GptDiagnosticsRequestCycle[]; } export interface GptDiagnosticsCallbackIssue { kind: GptDiagnosticsCallbackKind; runtimeSlotNumber: number; - slotElementId?: string; + slotElementId?: string | undefined; timestampMs: number; disposition: GptDiagnosticsCallbackDisposition; reason: string; @@ -405,13 +441,20 @@ export interface TsjsApi { addAdUnits(units: AdUnit | AdUnit[]): void; renderAdUnit(codeOrUnit: string | AdUnit): void; renderAllAdUnits(): void; - setConfig?(cfg: Record): void; - getConfig?(): Record; - requestAds?(opts?: { bidsBackHandler?: () => void; timeout?: number }): void; - requestAds?( - callback: () => void, - opts?: { bidsBackHandler?: () => void; timeout?: number } - ): void; + setConfig?: ((cfg: Record) => void) | undefined; + getConfig?: (() => Record) | undefined; + requestAds?: + | { + (opts?: { bidsBackHandler?: (() => void) | undefined; timeout?: number | undefined }): void; + ( + callback: () => void, + opts?: { + bidsBackHandler?: (() => void) | undefined; + timeout?: number | undefined; + } + ): void; + } + | undefined; log?: { setLevel(l: 'silent' | 'error' | 'warn' | 'info' | 'debug'): void; getLevel(): 'silent' | 'error' | 'warn' | 'info' | 'debug'; @@ -423,39 +466,45 @@ export interface TsjsApi { // ── Server-side auction runtime (populated by TS edge injection) ────────── /** Ad slot definitions injected at open. */ - adSlots?: AuctionSlot[]; + adSlots?: AuctionSlot[] | undefined; /** Winning bid targeting data injected before . */ - bids?: Record; + bids?: Record | undefined; /** * Bounded client-side Prebid APS renderer capabilities keyed by Prebid's generated * `hb_adid`. The Universal Creative bridge consumes each entry at most once. */ - apsPrebidRenderers?: Record; + apsPrebidRenderers?: Record | undefined; /** Initialises GPT slots with server-side bid targeting and calls refresh(). */ - adInit?: () => void; + adInit?: (() => void) | undefined; + /** Render-trace registry: latest render per slot. */ + renders?: Record | undefined; + /** Append-only history of every render. */ + renderLog?: RenderRecord[] | undefined; + /** Monotonic render generation for cancelling stale async work. */ + renderGeneration?: number | undefined; + /** Page-global render sequence counter. */ + renderSeq?: number | undefined; /** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */ - prevGptSlots?: unknown[]; + prevGptSlots?: unknown[] | undefined; /** Guards one-time-per-page enableSingleRequest/enableServices calls. */ - servicesEnabled?: boolean; + servicesEnabled?: boolean | undefined; /** Maps actualDivId → slotId for slotRenderEnded billing lookup. */ - divToSlotId?: Record; + divToSlotId?: Record | undefined; /** * 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; + firedBeacons?: Record | undefined; /** Slot-level GPT targeting keys TS applied on the previous route. */ - prevSlotTargetingKeys?: Record; + prevSlotTargetingKeys?: Record | undefined; /** * One-shot bypass for the slim-Prebid refresh wrapper: true only while * adInit() runs its internal refresh of server-side-targeted slots, so the * wrapper passes that refresh straight to GPT instead of starting a * client-side auction that would clear the just-applied TS targeting. */ - adInitRefreshInProgress?: boolean; - /** Scoped context marking an active Prebid-controlled GPT refresh delegation. */ - prebidRefreshDispatchInProgress?: boolean; + adInitRefreshInProgress?: boolean | undefined; /** * Whether the publisher disabled GPT initial load through * `googletag.setConfig()` or `googletag.pubads().disableInitialLoad()`. @@ -465,19 +514,13 @@ export interface TsjsApi { * from a `refresh()`; adInit() uses this to refresh its own freshly defined * slots so they are not left blank. */ - gptInitialLoadDisabled?: boolean; + gptInitialLoadDisabled?: boolean | undefined; /** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */ - gptSlotHandoffs?: Record; + gptSlotHandoffs?: Record | undefined; /** True only while TS calls a GPT function that the handoff wrappers observe. */ - gptSlotHandoffInternal?: boolean; - /** Per-navigation first-impression ownership shared by GPT and Prebid. */ - firstImpression?: FirstImpressionState; - /** Guards the shared production GPT lifecycle listener installation. */ - firstImpressionListenersInstalled?: boolean; + gptSlotHandoffInternal?: boolean | undefined; /** Guards SPA pushState hook installation. */ - spaHookInstalled?: boolean; - /** Internal one-shot state shared by bootstrap and bundle scheduler installs. */ - initialAdInitScheduled?: boolean; + spaHookInstalled?: boolean | undefined; /** * Monotonic count of committed SPA navigations, incremented synchronously by * the SPA auction hook the moment it accepts a route change. The deferred @@ -489,7 +532,7 @@ export interface TsjsApi { * ignores) leaves the counter unchanged, and an `/a → /b → /a` round trip * (where the URL compares equal again) advances it. */ - navGeneration?: number; + navGeneration?: number | undefined; /** * Defers the initial `adInit()` until after React hydration: window `load`, * then a double `requestAnimationFrame`. Called by the server-injected @@ -509,15 +552,9 @@ export interface TsjsApi { * of the guard would clobber a committed SPA navigation's slots with the SSR * document's, and then be read by that route's `adInit()`. */ - scheduleInitialAdInit?: ( - initialBids?: Record, - initialSlots?: AuctionSlot[] - ) => void; + scheduleInitialAdInit?: + | ((initialBids?: Record | undefined) => void) + | undefined; /** Read-only GPT lifecycle diagnostics API, present only in an activated tab. */ - gptDiagnostics?: GptDiagnosticsApi; - /** - * Internal evidence channel for Trusted Server integration modules. Not part - * of the operator API; present only in an activated tab. - */ - gptDiagnosticsRecorder?: GptDiagnosticsRecorder; + gptDiagnostics?: GptDiagnosticsApi | undefined; } 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 38d6b982e..65bac8674 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -96,7 +96,7 @@ function equalCanon(a: Canon, b: Canon): boolean { const bk = Object.keys(b.params).sort(); if (ak.length !== bk.length) return false; for (let i = 0; i < ak.length; i++) { - const k = ak[i]; + const k = ak[i]!; if (k !== bk[i] || a.params[k] !== b.params[k]) return false; } return true; 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 701f928b2..88ae6cbdd 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -489,7 +489,7 @@ function patchCommandQueue(tag: Partial): void { // Only applicable when cmd is an array (pre-GPT-load case). if (Array.isArray(queue)) { for (let i = 0; i < queue.length; i++) { - queue[i] = wrapCommand(queue[i]); + queue[i] = wrapCommand(queue[i]!); } log.debug('GPT shim: command queue patched', { pendingCommands: queue.length }); } else { @@ -1445,6 +1445,22 @@ export function installTsAdInit(): void { : slotsToRefresh; if (slotsNeedingRefresh.length > 0) { + slotsNeedingRefresh.forEach((slotToRefresh) => { + const slotId = (ts.divToSlotId ?? {})[slotToRefresh.getSlotElementId()]; + const pending = slotId ? (ts.bids ?? {})[slotId] : undefined; + if (slotId) { + // Capture the bid/generation before the asynchronous GPT event. + // The event handler still records the live slot state below. + ( + slotToRefresh as GoogleTagSlot & { __tsRenderGeneration?: number } + ).__tsRenderGeneration = renderGeneration; + ( + slotToRefresh as GoogleTagSlot & { + __tsRenderBid?: AuctionBidData | undefined; + } + ).__tsRenderBid = pending; + } + }); // One-shot bypass: this internal refresh delivers the just-applied // server-side targeting to GAM. If slim-Prebid has wrapped refresh(), it // must pass this call straight through — not clear the targeting and run @@ -1780,9 +1796,9 @@ function expandAuctionPriceMacro(markup: string, cpm: number): string { /** A decoded PBS Cache bid: the renderable creative plus its render metadata. */ export interface CachedBid { adm: string; - width?: number; - height?: number; - price?: number; + width?: number | undefined; + height?: number | undefined; + price?: number | undefined; } /** 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 475bc7f93..29edc4e1f 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 @@ -45,10 +45,10 @@ type ApiWindow = Window & { }; interface ApiOptions { - window?: ApiWindow; - document?: Document; - now?: () => Date; - schedule?: (callback: () => void) => void; + window?: ApiWindow | undefined; + document?: Document | undefined; + now?: (() => Date) | undefined; + schedule?: ((callback: () => void) => void) | undefined; } type ApiListener = (snapshot: GptDiagnosticsExportV1) => void; 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 643dc355a..a85507b5f 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 @@ -21,8 +21,8 @@ interface BadgeBindings { } type BadgeWindow = Window & { - MutationObserver?: typeof MutationObserver; - ResizeObserver?: typeof ResizeObserver; + MutationObserver?: typeof MutationObserver | undefined; + ResizeObserver?: typeof ResizeObserver | undefined; }; const BADGE_MAX_WIDTH_PX = 260; @@ -30,9 +30,9 @@ const BADGE_EDGE_GUTTER_PX = 4; const MAX_BADGE_REQUESTED_SLOT_SIZES = 3; interface BadgeOptions { - window?: BadgeWindow; - document?: Document; - scheduleFrame?: (callback: () => void) => void; + window?: BadgeWindow | undefined; + document?: Document | undefined; + scheduleFrame?: ((callback: () => void) => void) | undefined; } function intersectsViewport(rectangle: DOMRect, window: Window): boolean { @@ -152,7 +152,7 @@ export class GptDiagnosticsBadgeManager { private readonly unsubscribeBindings: () => void; private readonly slotElementIds = new Set(); private slots: GptDiagnosticsStoreSlotSnapshot[] = []; - private layer?: HTMLElement; + private layer: HTMLElement | undefined; private mutationObserver?: MutationObserver; private resizeObserver?: ResizeObserver; private scheduled = false; 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 c6c46a727..f2dfc0f3e 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 @@ -9,20 +9,20 @@ interface BindingStore { } type BindingWindow = Window & { - CSS?: typeof CSS; + CSS?: typeof CSS | undefined; HTMLElement: typeof HTMLElement; - MutationObserver?: typeof MutationObserver; + MutationObserver?: typeof MutationObserver | undefined; }; interface BindingOptions { - document?: Document; - window?: BindingWindow; - scheduleFrame?: (callback: () => void) => void; + document?: Document | undefined; + window?: BindingWindow | undefined; + scheduleFrame?: ((callback: () => void) => void) | undefined; } export interface GptDiagnosticsBindingView { binding: GptDiagnosticsBinding; - element?: HTMLElement; + element?: HTMLElement | undefined; visible: boolean; } 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 7a19ba043..2378cf807 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 @@ -19,18 +19,10 @@ interface GptEvent { } interface GptRenderEvent extends GptEvent { - isEmpty?: boolean; + isEmpty?: boolean | undefined; size?: unknown; - isBackfill?: boolean; - slotContentChanged?: boolean; - lineItemId?: unknown; - creativeId?: unknown; - campaignId?: unknown; - advertiserId?: unknown; - sourceAgnosticLineItemId?: unknown; - sourceAgnosticCreativeId?: unknown; - yieldGroupIds?: unknown; - companyIds?: unknown; + isBackfill?: boolean | undefined; + slotContentChanged?: boolean | undefined; } interface GptVisibilityEvent extends GptEvent { @@ -59,12 +51,11 @@ interface GptCommandQueue { interface GoogletagLike { cmd: GptCommandQueue; - pubads?: () => GptPubAdsService; + pubads?: (() => GptPubAdsService) | undefined; } export interface GptObserverWindow { - googletag?: GoogletagLike; - tsjs?: Pick; + googletag?: GoogletagLike | undefined; } interface ObserverLogger { @@ -72,8 +63,8 @@ interface ObserverLogger { } interface ObserverOptions { - window?: GptObserverWindow; - logger?: ObserverLogger; + window?: GptObserverWindow | undefined; + logger?: ObserverLogger | undefined; } function normalizeSize(value: unknown): Size | undefined { 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 63c0b6fb3..81a83ec25 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 @@ -21,16 +21,16 @@ interface OverlayBindings { } type OverlayWindow = Window & { - MutationObserver?: typeof MutationObserver; + MutationObserver?: typeof MutationObserver | undefined; }; interface OverlayOptions { - window?: OverlayWindow; - document?: Document; - scheduleFrame?: (callback: () => void) => void; - onExport?: () => void; - onShadowRoot?: (root: ShadowRoot) => void; - onBadgeLayerChange?: (layer: HTMLElement | undefined) => void; + window?: OverlayWindow | undefined; + document?: Document | undefined; + scheduleFrame?: ((callback: () => void) => void) | undefined; + onExport?: (() => void) | undefined; + onShadowRoot?: ((root: ShadowRoot) => void) | undefined; + onBadgeLayerChange?: ((layer: HTMLElement | undefined) => void) | undefined; } const PANEL_STYLES = ` @@ -335,12 +335,12 @@ export class GptDiagnosticsOverlay { private readonly document: Document; private readonly scheduleFrame: (callback: () => void) => void; private readonly onExport: () => void; - private readonly onShadowRoot?: (root: ShadowRoot) => void; - private readonly onBadgeLayerChange?: (layer: HTMLElement | undefined) => void; + private readonly onShadowRoot: ((root: ShadowRoot) => void) | undefined; + private readonly onBadgeLayerChange: ((layer: HTMLElement | undefined) => void) | undefined; private readonly unsubscribeStore: () => void; private readonly unsubscribeBindings: () => void; - private host?: HTMLElement; - private panel?: HTMLElement; + private host: HTMLElement | undefined; + private panel: HTMLElement | undefined; private lifecycleObserver?: MutationObserver; private visualReady = false; private mountWaitStarted = false; 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 ca3348ae9..499a790cd 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 @@ -41,32 +41,30 @@ const CALLBACK_KINDS: GptDiagnosticsCallbackKind[] = [ 'slotVisibilityChanged', ]; -/** - * The GPT slot shape diagnostics reads. Aliased to the exported handle type so - * the writer signatures and the store implementation cannot drift apart. - */ -export type GptDiagnosticsSlotLike = GptDiagnosticsSlotHandle; +export interface GptDiagnosticsSlotLike { + getSlotElementId?: (() => string) | undefined; + getAdUnitPath?: (() => string) | undefined; +} export interface GptRenderFacts { - isEmpty?: boolean; - size?: Size; - isBackfill?: boolean; - slotContentChanged?: boolean; - adManager?: GptDiagnosticsAdManagerIdentity; + isEmpty?: boolean | undefined; + size?: Size | undefined; + isBackfill?: boolean | undefined; + slotContentChanged?: boolean | undefined; } export interface GptDiagnosticsStoreSlotSnapshot { runtimeSlotNumber: number; - slotElementId?: string; - adUnitPath?: string; - currentVisibilityPercentage?: number; - maximumVisibilityPercentage?: number; + slotElementId?: string | undefined; + adUnitPath?: string | undefined; + currentVisibilityPercentage?: number | undefined; + maximumVisibilityPercentage?: number | undefined; requests: GptDiagnosticsRequestCycle[]; } export interface GptDiagnosticsBindingInput { runtimeSlotNumber: number; - slotElementId?: string; + slotElementId?: string | undefined; } export interface GptDiagnosticsStoreSnapshot { @@ -87,18 +85,16 @@ type MutableRequestCycle = GptDiagnosticsRequestCycle; interface MutableSlotRecord { runtimeSlotNumber: number; - slotElementId?: string; - adUnitPath?: string; - currentVisibilityPercentage?: number; - maximumVisibilityPercentage?: number; + slotElementId?: string | undefined; + adUnitPath?: string | undefined; + currentVisibilityPercentage?: number | undefined; + maximumVisibilityPercentage?: number | undefined; requests: MutableRequestCycle[]; } interface StoreOptions { - now?: () => number; - schedule?: (callback: () => void) => void; - /** Deferred marker cleanup and diagnostic-window re-notification. */ - defer?: (callback: () => void, delayMs: number) => void; + now?: (() => number) | undefined; + schedule?: ((callback: () => void) => void) | undefined; } type RequestIntentSource = 'trusted_server_direct' | 'prebid_refresh' | 'publisher_refresh'; @@ -1146,7 +1142,7 @@ export class GptDiagnosticsStore { return; } - attach(record, candidates[0]); + attach(record, candidates[0]!); this.incrementDisposition(kind, 'matched'); this.notify(); } 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 8bd3daae1..ab12df202 100644 --- a/crates/trusted-server-js/lib/src/integrations/osano/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/osano/index.ts @@ -273,7 +273,7 @@ function readGppSignal(win: OsanoWindow): Promise { return; } - const applicableSections = data.applicableSections; + 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[] = []; 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 dd8981922..ce9190fcb 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -58,9 +58,9 @@ const pbjs: PbjsGlobal = ( * user ID modules were compiled into it. */ interface ExternalPrebidBundleManifest { - adapters?: string[]; - bidderCodes?: string[]; - userIdModules?: string[]; + adapters?: string[] | undefined; + bidderCodes?: string[] | undefined; + userIdModules?: string[] | undefined; } function sanitizeManifestList(value: unknown): string[] | undefined { @@ -142,11 +142,11 @@ const PENDING_PUBLISHER_DELIVERY_TTL_MS = 5000; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { /** Auction endpoint path. Defaults to '/auction'. */ - endpoint?: string; + endpoint?: string | undefined; /** Server-side bid timeout in milliseconds. Defaults to 1000. */ - timeout?: number; + timeout?: number | undefined; /** Enable Prebid.js debug logging. Defaults to false. */ - debug?: boolean; + debug?: boolean | undefined; } /** @@ -1251,7 +1251,9 @@ export function installPrebidNpm(config?: Partial): typeof pbjs if (hasUserIdApi && !auctionEids) { clearPrebidEidsCookie(); } - const payload = buildAdRequest(validBidRequests, { eids: auctionEids }); + const payload = buildAdRequest(validBidRequests, { + eids: auctionEids, + } as Parameters[1]); return { method: 'POST', url: auctionEndpoint, 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 f323468bb..9e181c94d 100644 --- a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts @@ -3,9 +3,11 @@ import { log } from '../../core/log'; import { installSourcepointGuard } from './script_guard'; type SourcepointWindow = Window & { - __tsjs_sourcepoint?: { - rewriteSdk?: boolean; - }; + __tsjs_sourcepoint?: + | { + rewriteSdk?: boolean | undefined; + } + | undefined; }; function shouldInstallSourcepointGuard(): boolean { @@ -30,33 +32,33 @@ const GPP_SOURCE_SOURCEPOINT = 'sp'; const INITIAL_RETRY_DELAY_MS = 500; interface SourcepointGppData { - gppString?: string; - applicableSections?: number[]; + gppString?: string | undefined; + applicableSections?: number[] | undefined; } interface SourcepointConsentStringEntry { - sectionId?: number; + sectionId?: number | undefined; } interface SourcepointSectionPayload { - consentString?: string; - applicableSections?: number[]; - consentStrings?: SourcepointConsentStringEntry[]; + consentString?: string | undefined; + applicableSections?: number[] | undefined; + consentStrings?: SourcepointConsentStringEntry[] | undefined; } interface SourcepointConsentPayload { - gppData?: SourcepointGppData; + gppData?: SourcepointGppData | undefined; [key: string]: unknown; } interface MirroredSourcepointConsent { gppString: string; - applicableSections?: number[]; + applicableSections?: number[] | undefined; } let initialized = false; let initialRetryDone = false; -let retryTimer: ReturnType | undefined; +let retryTimer: number | undefined; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; 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 3db00ce05..7f2598b17 100644 --- a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts @@ -1,7 +1,8 @@ import type { TsjsApi } from '../../core/types'; import { installQueue } from '../../core/queue'; import { log } from '../../core/log'; -import { resolvePrebidWindow, PrebidWindow } from '../../shared/globals'; +import { resolvePrebidWindow } from '../../shared/globals'; +import type { PrebidWindow } from '../../shared/globals'; type TestlightCallback = () => void; diff --git a/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts b/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts index 5fb9283be..1046d951d 100644 --- a/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts +++ b/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts @@ -42,11 +42,11 @@ interface RegisteredDomInsertionHandler extends DomInsertionHandler { } interface DomInsertionDispatcherState { - appendChildWrapper?: AppendChildMethod; - baselineAppendChild?: AppendChildMethod; - baselineInsertBefore?: InsertBeforeMethod; + appendChildWrapper?: AppendChildMethod | undefined; + baselineAppendChild?: AppendChildMethod | undefined; + baselineInsertBefore?: InsertBeforeMethod | undefined; handlers: Map; - insertBeforeWrapper?: InsertBeforeMethod; + insertBeforeWrapper?: InsertBeforeMethod | undefined; nextSequence: number; orderedHandlers: RegisteredDomInsertionHandler[]; version: number; diff --git a/crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs b/crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs new file mode 100644 index 000000000..a3334145e --- /dev/null +++ b/crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { auditRcJulyAdoption } from '../../scripts/check-rc-july-adoption.mjs'; + +const { test } = process.env.VITEST ? await import('vitest') : await import('node:test'); + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(testDirectory, '../../../../..'); +const specPath = path.join( + repositoryRoot, + 'docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md' +); + +test('the pinned rc/july TSJS baseline is completely mapped by the spec ledger', () => { + const result = auditRcJulyAdoption({ repositoryRoot, specPath }); + + assert.equal(result.baseline, '905984e62a0858c53d9f0ff6dd3a1bf190cf311d'); + assert.equal(result.fileCount, 144); + assert.equal(result.mappingCount, 38); + assert.equal(result.manifestIdCount, 23); + assert.equal(result.ledgerIdCount, 23); + assert.deepEqual(result.unmappedFiles, []); + assert.deepEqual(result.qualityOnlySourceFiles, []); + assert.deepEqual(result.deadMappings, []); + assert.deepEqual(result.manifestOnlyIds, []); + assert.deepEqual(result.ledgerOnlyIds, []); +}); 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 55dba7bb6..a47080105 100644 --- a/crates/trusted-server-js/lib/test/core/auction.test.ts +++ b/crates/trusted-server-js/lib/test/core/auction.test.ts @@ -4,7 +4,7 @@ import { buildAdRequest, parseAuctionResponse, sendAuction } from '../../src/cor import envelope from '../fixtures/aps-renderer-v1.json'; function apsRenderer(creativeId?: string) { - const bid = envelope.seatbid[0].bid[0]; + const bid = envelope.seatbid[0]!.bid[0]!; return { type: 'aps' as const, version: 1 as const, @@ -42,14 +42,17 @@ describe('auction/buildAdRequest', () => { const result = buildAdRequest(units); expect(result.adUnits).toHaveLength(1); - expect(result.adUnits[0].code).toBe('div-1'); - expect(result.adUnits[0].mediaTypes.banner?.sizes).toEqual([ + expect(result.adUnits[0]!.code).toBe('div-1'); + expect(result.adUnits[0]!.mediaTypes.banner?.sizes).toEqual([ [300, 250], [728, 90], ]); - expect(result.adUnits[0].bids).toHaveLength(2); - expect(result.adUnits[0].bids[0]).toEqual({ bidder: 'appnexus', params: { placementId: 123 } }); - expect(result.adUnits[0].bids[1]).toEqual({ bidder: 'rubicon', params: {} }); + expect(result.adUnits[0]!.bids).toHaveLength(2); + expect(result.adUnits[0]!.bids[0]).toEqual({ + bidder: 'appnexus', + params: { placementId: 123 }, + }); + expect(result.adUnits[0]!.bids[1]).toEqual({ bidder: 'rubicon', params: {} }); }); it('builds from Prebid BidRequest objects (adUnitCode + bidder)', () => { @@ -81,13 +84,13 @@ describe('auction/buildAdRequest', () => { const unit1 = result.adUnits.find((u) => u.code === 'div-gpt-1'); expect(unit1).toBeDefined(); expect(unit1!.bids).toHaveLength(2); - expect(unit1!.bids[0].bidder).toBe('appnexus'); - expect(unit1!.bids[1].bidder).toBe('rubicon'); + expect(unit1!.bids[0]!.bidder).toBe('appnexus'); + expect(unit1!.bids[1]!.bidder).toBe('rubicon'); const unit2 = result.adUnits.find((u) => u.code === 'div-gpt-2'); expect(unit2).toBeDefined(); expect(unit2!.bids).toHaveLength(1); - expect(unit2!.bids[0].bidder).toBe('openx'); + expect(unit2!.bids[0]!.bidder).toBe('openx'); }); it('handles empty units array', () => { @@ -139,7 +142,7 @@ describe('auction/buildAdRequest', () => { const result = buildAdRequest(units); expect(result.adUnits).toHaveLength(1); - expect(result.adUnits[0].mediaTypes).toEqual({}); + expect(result.adUnits[0]!.mediaTypes).toEqual({}); }); it('deduplicates by code/adUnitCode', () => { @@ -150,9 +153,9 @@ describe('auction/buildAdRequest', () => { const result = buildAdRequest(units); expect(result.adUnits).toHaveLength(1); - expect(result.adUnits[0].bids).toHaveLength(2); - expect(result.adUnits[0].bids[0].bidder).toBe('a'); - expect(result.adUnits[0].bids[1].bidder).toBe('b'); + expect(result.adUnits[0]!.bids).toHaveLength(2); + expect(result.adUnits[0]!.bids[0]!.bidder).toBe('a'); + expect(result.adUnits[0]!.bids[1]!.bidder).toBe('b'); }); }); @@ -245,8 +248,8 @@ describe('auction/parseAuctionResponse', () => { ], }); - expect(bids[0].renderer).toEqual(renderer); - expect(bids[0].creativeId).toBe('aps-fictional-slot'); + expect(bids[0]!.renderer).toEqual(renderer); + expect(bids[0]!.creativeId).toBe('aps-fictional-slot'); }); it('ignores unrelated or malformed renderer extensions while retaining ordinary adm', () => { @@ -265,8 +268,8 @@ describe('auction/parseAuctionResponse', () => { ], }); - expect(bids[0].renderer).toBeUndefined(); - expect(bids[0].adm).toBe('
ordinary
'); + expect(bids[0]!.renderer).toBeUndefined(); + expect(bids[0]!.adm).toBe('
ordinary
'); }); it('handles multiple seatbids with multiple bids', () => { @@ -307,11 +310,11 @@ describe('auction/parseAuctionResponse', () => { const bids = parseAuctionResponse(body); expect(bids).toHaveLength(1); - expect(bids[0].seat).toBe('unknown'); - expect(bids[0].adm).toBe(''); - expect(bids[0].width).toBe(300); - expect(bids[0].height).toBe(250); - expect(bids[0].adomain).toEqual([]); + expect(bids[0]!.seat).toBe('unknown'); + expect(bids[0]!.adm).toBe(''); + expect(bids[0]!.width).toBe(300); + expect(bids[0]!.height).toBe(250); + expect(bids[0]!.adomain).toEqual([]); }); }); @@ -365,7 +368,7 @@ describe('auction/sendAuction', () => { }) ); expect(bids).toHaveLength(1); - expect(bids[0].price).toBe(2.5); + expect(bids[0]!.price).toBe(2.5); }); it('returns empty array on network error', async () => { 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 f06527e42..51b0e3a84 100644 --- a/crates/trusted-server-js/lib/test/core/registry.test.ts +++ b/crates/trusted-server-js/lib/test/core/registry.test.ts @@ -24,6 +24,6 @@ describe('registry', () => { const all = getAllUnits(); expect(all.length).toBe(1); - expect(firstSize(all[0])!.join('x')).toBe('320x50'); + expect(firstSize(all[0]!)!.join('x')).toBe('320x50'); }); }); 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 e88ed520c..e1eaac503 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -6,6 +6,7 @@ import { APS_RENDERING_MODE_ATTRIBUTE_NAME, } from '../../src/integrations/aps/render'; import envelope from '../fixtures/aps-renderer-v1.json'; +import type { addAdUnits } from '../../src/core/registry'; async function flushRequestAds(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -74,7 +75,7 @@ describe('request.requestAds', () => { }); it('dispatches a valid APS descriptor to the opaque static renderer route', async () => { - const apsBid = envelope.seatbid[0].bid[0]; + const apsBid = envelope.seatbid[0]!.bid[0]!; const renderer = { type: 'aps', version: 1, @@ -131,7 +132,7 @@ describe('request.requestAds', () => { expect(document.querySelector('#slot1 span')).not.toBeNull(); expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ renderer }), '*'); - const message = postMessage.mock.calls[0][0] as { nonce: string }; + const message = postMessage.mock.calls[0]![0] as { nonce: string }; window.dispatchEvent( new MessageEvent('message', { data: { message: 'trusted-server/aps/renderer-ready', nonce: message.nonce }, diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts new file mode 100644 index 000000000..3317f67e1 --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/trace.test.ts @@ -0,0 +1,515 @@ +import { execFileSync } from 'node:child_process'; + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + recordRender, + updateRender, + stampCreativeTrace, + traceOverlayEnabled, + renderTracePanel, + RENDER_EVENT_NAME, + TRACE_PANEL_ID, + TRACE_BADGE_CLASS, +} from '../../src/core/trace'; +import type { RenderRecord, TsjsApi } from '../../src/core/types'; + +function clearTraceCookie(): void { + document.cookie = 'ts-trace=; Max-Age=0; Path=/'; +} + +function removePanel(): void { + document.getElementById(TRACE_PANEL_ID)?.remove(); +} + +describe('trace/recordRender', () => { + beforeEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + clearTraceCookie(); + removePanel(); + }); + + 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); + }); + + it('allocates one sequence across separately bundled IIFEs', async () => { + const buildTraceBundle = (): string => + execFileSync( + './node_modules/.bin/esbuild', + ['--bundle', '--format=iife', '--platform=browser', '--loader=ts'], + { + cwd: process.cwd(), + encoding: 'utf8', + input: + 'import { recordRender } from "./src/core/trace.ts";' + + 'window.__recordFromTraceBundle = recordRender;', + } + ); + + const firstBundle = buildTraceBundle(); + const secondBundle = buildTraceBundle(); + const testWindow = window as typeof window & { + __recordFromTraceBundle?: typeof recordRender; + }; + + Function(firstBundle)(); + const firstRecord = testWindow.__recordFromTraceBundle!; + const first = firstRecord({ slotId: 'iife-a', path: 'auction', rendered: true }); + + Function(secondBundle)(); + const secondRecord = testWindow.__recordFromTraceBundle!; + const second = secondRecord({ slotId: 'iife-b', path: 'ssat', rendered: true }); + + expect(second.seq).toBe(first.seq + 1); + expect(window.tsjs?.renderSeq).toBe(second.seq); + delete testWindow.__recordFromTraceBundle; + }); + + it('enriches an existing impression without changing its bookkeeping', () => { + const original = recordRender({ + slotId: 'slot-enrich', + path: 'ssat', + rendered: true, + injected: false, + servedFrom: 'gam', + }); + const bookkeeping = { + seq: original.seq, + count: original.count, + at: original.at, + historyLength: window.tsjs?.renderLog?.length, + }; + + const updated = updateRender(original, { injected: true, servedFrom: 'pbs-cache' }); + + expect(updated).toBe(original); + expect(window.tsjs?.renders?.['slot-enrich']).toBe(original); + expect(window.tsjs?.renderLog?.[0]).toBe(original); + expect(updated).toEqual(expect.objectContaining({ injected: true, servedFrom: 'pbs-cache' })); + expect({ + seq: updated.seq, + count: updated.count, + at: updated.at, + historyLength: window.tsjs?.renderLog?.length, + }).toEqual(bookkeeping); + }); +}); + +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, + seq: 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, + seq: 1, + at: 1, + }; + stampCreativeTrace(el, first); + + const second: RenderRecord = { + slotId: 'slot-1', + path: 'ssat', + rendered: true, + auctionId: 'auction-new', + count: 2, + seq: 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); + }); +}); + +describe('trace/floating panel', () => { + const record: Omit = { + slotId: 'slot-1', + path: 'ssat', + rendered: true, + injected: true, + visible: true, + gamEmpty: false, + auctionId: 'ts-req-abcdef123456', + bidder: 'kargo', + admHash: 'a1b2c3d4e5f60718', + servedFrom: 'gam', + }; + + beforeEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + clearTraceCookie(); + removePanel(); + }); + + afterEach(() => { + clearTraceCookie(); + removePanel(); + }); + + it('reports the overlay disabled without the ts-trace cookie', () => { + expect(traceOverlayEnabled()).toBe(false); + }); + + it('does not create a panel when the overlay is disarmed', () => { + recordRender(record); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + }); + + it('renders a panel row per traced slot with honest status', () => { + document.cookie = 'ts-trace=1; Path=/'; + // slot-1: TS placed + visible → ok. slot-2: nothing rendered → empty. + recordRender(record); + recordRender({ + slotId: 'slot-2', + path: 'auction', + rendered: false, + injected: false, + visible: false, + bidder: 'appnexus', + }); + + const panel = document.getElementById(TRACE_PANEL_ID); + expect(panel).toBeTruthy(); + // Only slot-1 is honestly ok; slot-2 rendered nothing. + expect(panel!.textContent).toContain('TS Render Trace · 1/2 slots ok'); + expect(panel!.textContent).toContain('✓ slot-1 · ok'); + expect(panel!.textContent).toContain('✗ slot-2 · empty'); + expect(panel!.textContent).toContain('ssat · kargo'); + expect(panel!.textContent).toContain('auction · appnexus'); + }); + + it('marks a rendered-but-hidden slot as hidden, not ok', () => { + document.cookie = 'ts-trace=1; Path=/'; + // GAM rendered non-empty, TS injected, but a reveal gate keeps it hidden. + recordRender({ ...record, visible: false }); + + const panel = document.getElementById(TRACE_PANEL_ID); + expect(panel!.textContent).toContain('0/1 slots ok'); + expect(panel!.textContent).toContain('⚠ slot-1 · hidden'); + }); + + it('marks a targeting-only GAM slot as gam-only, not a confirmed TS render', () => { + document.cookie = 'ts-trace=1; Path=/'; + // GAM rendered something, but TS never placed it (prod targeting path). + recordRender({ ...record, injected: false, gamEmpty: false, visible: true }); + + const panel = document.getElementById(TRACE_PANEL_ID); + expect(panel!.textContent).toContain('0/1 slots ok'); + expect(panel!.textContent).toContain('◐ slot-1 · gam-only'); + }); + + it('never claims ok when a render path did not report placement', () => { + document.cookie = 'ts-trace=1; Path=/'; + // Regression: an unset `injected` must not fall through to ok — that would + // claim a confirmed TS render for a slot TS only targeted. + const { injected: _omitted, ...withoutInjected } = record; + recordRender({ ...withoutInjected, gamEmpty: false, visible: true }); + + const panel = document.getElementById(TRACE_PANEL_ID); + expect(panel!.textContent).toContain('0/1 slots ok'); + expect(panel!.textContent).toContain('◐ slot-1 · gam-only'); + }); + + it('reuses a single panel across renders and reflects the latest count', () => { + document.cookie = 'ts-trace=1; Path=/'; + recordRender(record); + recordRender(record); + + const panels = document.querySelectorAll(`#${TRACE_PANEL_ID}`); + expect(panels).toHaveLength(1); + // Second render of the same slot bumps the count and appends a history row. + expect(panels[0]!.textContent).toContain('TS Render Trace · 1/1 slots ok'); + expect(panels[0]!.textContent).toContain('×2'); + }); + + it("keeps GAM's fill signal and drops ? placeholders on an unattributed refresh", () => { + document.cookie = 'ts-trace=1; Path=/'; + // A publisher-driven GAM refresh: TS ran no auction for it, so there is no + // bidder/hash/auction id — but GAM still reported whether it filled, and + // that is the most useful field on the row. + recordRender({ + slotId: 'slot-1', + path: 'gam-refresh', + rendered: true, + gamEmpty: false, + injected: false, + visible: true, + servedFrom: 'gam', + }); + + const panel = document.getElementById(TRACE_PANEL_ID)!; + expect(panel.textContent).toContain('gam:filled'); + expect(panel.textContent).toContain('no TS attribution'); + // Absent attribution must not render as a failed lookup, and an auction + // segment must not appear at all when there is no auction to name. + expect(panel.textContent).not.toContain('· ? ·'); + expect(panel.textContent).not.toContain('auction ?'); + }); + + it('still reports gam:empty for a refresh GAM declined to fill', () => { + document.cookie = 'ts-trace=1; Path=/'; + recordRender({ + slotId: 'slot-1', + path: 'gam-refresh', + rendered: false, + gamEmpty: true, + injected: false, + visible: true, + }); + + const panel = document.getElementById(TRACE_PANEL_ID)!; + expect(panel.textContent).toContain('gam:empty'); + expect(panel.textContent).toContain('✗ slot-1 · empty'); + }); + + it('gives each render a page-global seq the badge and its panel row share', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + el.id = 'slot-el'; + document.body.appendChild(el); + + // Two slots interleaved: seq must be unique page-wide, not per-slot, so a + // badge reading #N identifies exactly one row. + const first = recordRender(record); + const other = recordRender({ ...record, slotId: 'slot-2' }); + expect(other.seq).toBe(first.seq + 1); + + stampCreativeTrace(el, other); + const badge = el.querySelector(`.${TRACE_BADGE_CLASS}`) as HTMLElement; + expect(badge.textContent).toContain(`#${other.seq}`); + // The same number appears on that render's row in the panel. + expect(document.getElementById(TRACE_PANEL_ID)!.textContent).toContain(`#${other.seq}`); + + el.remove(); + }); + + it('marks only the live render for a slot as current', () => { + document.cookie = 'ts-trace=1; Path=/'; + recordRender(record); + const latest = recordRender(record); + + const panel = document.getElementById(TRACE_PANEL_ID)!; + // Both renders are in the log, but only the newest is still on screen. + expect(panel.textContent).toContain(`#${latest.seq}`); + expect(panel.textContent!.match(/◂ current/g)).toHaveLength(1); + }); + + it('uses record identity when duplicate sequence values exist', () => { + 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 = { + renders: { 'slot-1': liveRecord }, + renderLog: [oldRecord, liveRecord], + } as unknown as TsjsApi; + + renderTracePanel(); + + const rows = [...document.querySelectorAll(`#${TRACE_PANEL_ID} div[style*="cursor"]`)]; + const oldRow = rows.find((row) => row.getAttribute('title')?.includes('auction: auction-old')); + const liveRow = rows.find((row) => + row.getAttribute('title')?.includes('auction: auction-live') + ); + expect(oldRow?.textContent).not.toContain('◂ current'); + expect(liveRow?.textContent).toContain('◂ current'); + }); + + it('close button removes the panel', () => { + document.cookie = 'ts-trace=1; Path=/'; + recordRender(record); + const panel = document.getElementById(TRACE_PANEL_ID)!; + const close = panel.querySelector('button') as HTMLButtonElement; + close.click(); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + }); + + it('renderTracePanel is a no-op while disarmed even if renders exist', () => { + (window as { tsjs?: TsjsApi }).tsjs = { + renders: { 'slot-1': { ...record, count: 1, seq: 1, at: 1 } }, + } as unknown as TsjsApi; + renderTracePanel(); + expect(document.getElementById(TRACE_PANEL_ID)).toBeNull(); + }); + + it('clicking a row logs the full record', async () => { + document.cookie = 'ts-trace=1; Path=/'; + const { log } = await import('../../src/core/log'); + const infoSpy = vi.spyOn(log, 'info').mockImplementation(() => undefined); + + recordRender(record); + const row = document.getElementById(TRACE_PANEL_ID)!.querySelector('div[style*="cursor"]'); + (row as HTMLElement).click(); + + const call = infoSpy.mock.calls.find(([m]) => m === 'trace: render record'); + expect(call?.[1]).toEqual( + expect.objectContaining({ slotId: 'slot-1', auctionId: record.auctionId }) + ); + infoSpy.mockRestore(); + }); +}); + +describe('trace/confirmation badge', () => { + beforeEach(() => { + delete (window as { tsjs?: TsjsApi }).tsjs; + clearTraceCookie(); + document.body.innerHTML = ''; + }); + afterEach(() => { + clearTraceCookie(); + document.body.innerHTML = ''; + }); + + const okRecord: RenderRecord = { + slotId: 'slot-1', + path: 'ssat', + rendered: true, + injected: true, + visible: true, + gamEmpty: false, + bidder: 'mocktioneer', + admHash: 'a1b2c3d4e5f60718', + servedFrom: 'gam', + count: 1, + seq: 1, + at: 1, + }; + + it('badges an ok slot when armed', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, okRecord); + const badge = el.querySelector(`.${TRACE_BADGE_CLASS}`) as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.textContent).toBe('TS ✓ #1 · mocktioneer'); + }); + + it('does not badge a hidden slot', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, { ...okRecord, visible: false }); + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + }); + + it('removes the previous badge when a filled slot becomes empty', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, okRecord); + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeTruthy(); + + stampCreativeTrace(el, { ...okRecord, rendered: false, injected: false, gamEmpty: true }); + + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + }); + + it('removes the previous badge when a visible slot becomes hidden', () => { + document.cookie = 'ts-trace=1; Path=/'; + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, okRecord); + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeTruthy(); + + stampCreativeTrace(el, { ...okRecord, visible: false }); + + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + }); + + it('does not badge when the overlay is disarmed', () => { + clearTraceCookie(); + const el = document.createElement('div'); + document.body.appendChild(el); + stampCreativeTrace(el, okRecord); + expect(el.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + }); + + it('never badges an iframe element', () => { + document.cookie = 'ts-trace=1; Path=/'; + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + stampCreativeTrace(iframe, okRecord); + expect(iframe.querySelector(`.${TRACE_BADGE_CLASS}`)).toBeNull(); + // Attributes still stamped on the iframe though. + expect(iframe.getAttribute('data-ts-slot-id')).toBe('slot-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 new file mode 100644 index 000000000..77e672aa7 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json @@ -0,0 +1,86 @@ +{ + "schemaVersion": 1, + "mode": "baseline", + "source": { + "ref": "spec/aps-tsjs-resilience-design", + "sha": "1b9753efa01a7985ae8ea804b1997a3cf3b3a7b4" + }, + "environment": { + "node": "v24.12.0", + "npm": "11.6.2", + "typescript": "5.9.3", + "chromium": "145.0.7632.6", + "ciMachineClass": "local:darwin-arm64", + "fixture": "tsjs-core-placeholder-v1" + }, + "sampling": { + "warmups": 5, + "samples": 50, + "percentile": 90 + }, + "bundles": { + "minimal": { + "files": ["tsjs-core.js"], + "rawBytes": 23317, + "gzipBytes": 8687, + "brotliBytes": 7686, + "sha256": "1e027cfb238cb6eed090b7addcdba5042059737cb319fbdef1b50e286689b851" + }, + "reference": { + "files": ["tsjs-core.js", "tsjs-creative.js", "tsjs-gpt.js", "tsjs-prebid.js"], + "rawBytes": 107265, + "gzipBytes": 33428, + "brotliBytes": 25236, + "sha256": "8b9a440310ad358c292864dfa2e088c895c59199c2518fe9a3044236e459d19d" + }, + "maximal": { + "files": [ + "tsjs-core.js", + "tsjs-creative.js", + "tsjs-datadome.js", + "tsjs-didomi.js", + "tsjs-google_tag_manager.js", + "tsjs-gpt.js", + "tsjs-gpt_diagnostics.js", + "tsjs-lockr.js", + "tsjs-osano.js", + "tsjs-permutive.js", + "tsjs-prebid.js", + "tsjs-sourcepoint.js", + "tsjs-testlight.js" + ], + "rawBytes": 187224, + "gzipBytes": 53799, + "brotliBytes": 37790, + "sha256": "ce5fa29ba8914ad7074221ae777b12c0f496c00b19c42171e42a3d6005c378d3" + } + }, + "performance": { + "bootToFirstDisplayMs": { + "samples": [ + 11.300000011920929, 11.400000005960464, 11, 10.599999994039536, 11.599999994039536, + 13.199999988079071, 11.799999982118607, 11.100000023841858, 11, 11.799999982118607, 11.5, + 10.5, 10.800000011920929, 11.899999976158142, 11.199999988079071, 10.600000023841858, + 10.799999982118607, 11.099999994039536, 10.800000011920929, 10.699999988079071, 10.5, + 11.200000017881393, 10.599999994039536, 10.5, 10.800000011920929, 10.5, 10.300000011920929, + 10.199999988079071, 10.800000011920929, 10.700000017881393, 10.299999982118607, + 10.800000011920929, 10.800000011920929, 11.099999994039536, 15.699999988079071, + 10.399999976158142, 10.599999994039536, 10.5, 10.800000011920929, 10.599999994039536, + 10.699999988079071, 10.599999994039536, 11.599999994039536, 11.699999988079071, + 11.599999994039536, 10.5, 10.699999988079071, 10.400000005960464, 11.099999994039536, + 11.700000017881393 + ], + "p90": 11.700000017881393 + }, + "retainedHeapBytes": { + "afterBoot": 1210116, + "afterFirstRender": 1213316, + "afterRefresh": 1213316, + "afterSpaNavigation": 1220364 + } + }, + "evidence": { + "evidenceId": null, + "workflowRunId": null + } +} 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 d9a92742b..67a5f1f0e 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 @@ -52,7 +52,7 @@ function encodeEnvelopeAtSize(size: number): string { } function descriptor(overrides: Partial = {}): ApsRendererV1 { - const bid = envelope.seatbid[0].bid[0]; + const bid = envelope.seatbid[0]!.bid[0]!; return { type: 'aps', version: 1, @@ -111,19 +111,19 @@ describe('APS renderer validation', () => { ['sibling seat', { seatbid: [...envelope.seatbid, envelope.seatbid[0]] }], [ 'sibling bid', - { seatbid: [{ bid: [...envelope.seatbid[0].bid, envelope.seatbid[0].bid[0]] }] }, + { seatbid: [{ bid: [...envelope.seatbid[0]!.bid, envelope.seatbid[0]!.bid[0]!] }] }, ], [ 'markup', { seatbid: [ - { bid: [{ ...envelope.seatbid[0].bid[0], adm: '' }] }, + { bid: [{ ...envelope.seatbid[0]!.bid[0]!, adm: '' }] }, ], }, ], [ 'notification', - { seatbid: [{ bid: [{ ...envelope.seatbid[0].bid[0], nurl: 'https://notify.example' }] }] }, + { seatbid: [{ bid: [{ ...envelope.seatbid[0]!.bid[0]!, nurl: 'https://notify.example' }] }] }, ], [ 'unknown extension', @@ -132,8 +132,8 @@ describe('APS renderer validation', () => { { bid: [ { - ...envelope.seatbid[0].bid[0], - ext: { ...envelope.seatbid[0].bid[0].ext, userSyncs: [] }, + ...envelope.seatbid[0]!.bid[0]!, + ext: { ...envelope.seatbid[0]!.bid[0]!.ext, userSyncs: [] }, }, ], }, @@ -171,8 +171,8 @@ describe('APS renderer validation', () => { const canonical = encodeBytes(new TextEncoder().encode(`${JSON.stringify(envelope)} `)); const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const finalDataIndex = canonical.length - 3; - const canonicalIndex = alphabet.indexOf(canonical[finalDataIndex]); - const nonCanonical = `${canonical.slice(0, finalDataIndex)}${alphabet[canonicalIndex + 1]}==`; + const canonicalIndex = alphabet.indexOf(canonical[finalDataIndex]!); + const nonCanonical = `${canonical.slice(0, finalDataIndex)}${alphabet[canonicalIndex + 1]!}==`; expect(atob(nonCanonical)).toBe(atob(canonical)); expect(validateApsRenderer(descriptor({ aaxResponse: canonical }))).toBeDefined(); @@ -185,7 +185,7 @@ describe('APS renderer validation', () => { `${window.location.origin}/creative`, ])('rejects an unsafe creative URL', (creativeUrl) => { const invalidEnvelope = structuredClone(envelope); - invalidEnvelope.seatbid[0].bid[0].ext.creativeurl = creativeUrl; + invalidEnvelope.seatbid[0]!.bid[0]!.ext.creativeurl = creativeUrl; expect( validateApsRenderer(descriptor({ creativeUrl, aaxResponse: encodeEnvelope(invalidEnvelope) })) ).toBeUndefined(); @@ -210,9 +210,9 @@ describe('APS renderer validation', () => { const atLimit = `${prefix}${'a'.repeat(4096 - prefix.length)}`; const overLimit = `${atLimit}x`; const atLimitEnvelope = structuredClone(envelope); - atLimitEnvelope.seatbid[0].bid[0].ext.creativeurl = atLimit; + atLimitEnvelope.seatbid[0]!.bid[0]!.ext.creativeurl = atLimit; const overLimitEnvelope = structuredClone(envelope); - overLimitEnvelope.seatbid[0].bid[0].ext.creativeurl = overLimit; + overLimitEnvelope.seatbid[0]!.bid[0]!.ext.creativeurl = overLimit; expect( validateApsRenderer( @@ -602,7 +602,7 @@ describe('direct APS rendering', () => { '*' ); - const message = postMessage.mock.calls[0][0] as { nonce: string }; + const message = postMessage.mock.calls[0]![0] as { nonce: string }; window.dispatchEvent( new MessageEvent('message', { data: { @@ -628,10 +628,10 @@ describe('direct APS rendering', () => { expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); const slot = document.getElementById('fictional-slot')!; - const rendererFrame = slot.querySelector('iframe')!; + const rendererFrame = slot.querySelector('iframe')!; const postMessage = vi.spyOn(rendererFrame.contentWindow!, 'postMessage'); rendererFrame.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0][0] as { nonce: string }; + const sent = postMessage.mock.calls[0]![0] as { nonce: string }; const foreignFrame = document.createElement('iframe'); document.body.appendChild(foreignFrame); @@ -667,7 +667,7 @@ describe('direct APS rendering', () => { expect(document.querySelector('#fictional-slot iframe')).toBeNull(); expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const iframe = document.querySelector('#fictional-slot iframe')!; + const iframe = document.querySelector('#fictional-slot iframe')!; iframe.dispatchEvent(new Event('error')); expect(document.querySelector('#fictional-slot span')).not.toBeNull(); expect(document.querySelector('#fictional-slot iframe')).toBeNull(); @@ -677,7 +677,7 @@ describe('direct APS rendering', () => { vi.useFakeTimers(); try { expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const iframe = document.querySelector('#fictional-slot iframe')!; + const iframe = document.querySelector('#fictional-slot iframe')!; iframe.dispatchEvent(new Event('load')); vi.advanceTimersByTime(10_000); @@ -695,20 +695,20 @@ describe('direct APS rendering', () => { try { const baselineTimers = vi.getTimerCount(); expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const firstFrame = document.querySelector('#fictional-slot iframe')!; + const firstFrame = document.querySelector('#fictional-slot iframe')!; const firstPostMessage = vi.spyOn(firstFrame.contentWindow!, 'postMessage'); firstFrame.dispatchEvent(new Event('load')); - const firstSent = firstPostMessage.mock.calls[0][0] as { nonce: string }; + const firstSent = firstPostMessage.mock.calls[0]![0] as { nonce: string }; const timersAfterFirst = vi.getTimerCount(); expect(timersAfterFirst).toBeGreaterThan(baselineTimers); expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const secondFrame = document.querySelector('#fictional-slot iframe')!; + const secondFrame = document.querySelector('#fictional-slot iframe')!; expect(firstFrame.isConnected).toBe(false); expect(vi.getTimerCount()).toBe(timersAfterFirst); const postMessage = vi.spyOn(secondFrame.contentWindow!, 'postMessage'); secondFrame.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0][0] as { nonce: string }; + const sent = postMessage.mock.calls[0]![0] as { nonce: string }; window.dispatchEvent( new MessageEvent('message', { @@ -774,14 +774,14 @@ describe('Universal Creative APS source', () => { undefined, window ); - const iframe = document.body.querySelector('iframe')!; + const iframe = document.body.querySelector('iframe')!; expect(iframe.src).toMatch(/\/integrations\/aps\/renderer#tsaps=[A-Za-z0-9_-]{22}$/); expect(iframe.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); expect(iframe.getAttribute('sandbox')).not.toContain('allow-same-origin'); const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); iframe.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0][0] as { nonce: string; renderer: ApsRendererV1 }; + const sent = postMessage.mock.calls[0]![0] as { nonce: string; renderer: ApsRendererV1 }; expect(sent.renderer).toEqual(renderer); let settled = false; 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 85a231553..acdc3bd52 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 @@ -64,7 +64,7 @@ describe('creative/click.ts', () => { await vi.runAllTimersAsync(); expect(fetchMock).toHaveBeenCalled(); - const call = fetchMock.mock.calls[0]; + const call = fetchMock.mock.calls[0]!; expect(call[0]).toBe('/first-party/proxy-rebuild'); const payload = JSON.parse(call[1]?.body as string); expect(payload).toEqual({ @@ -247,7 +247,7 @@ describe('creative/click.ts', () => { await vi.runAllTimersAsync(); expect(openMock).toHaveBeenCalled(); - const navigated = String(openMock.mock.calls[0][0]); + const navigated = String(openMock.mock.calls[0]![0]); expect(navigated.startsWith(REBUILD_PREFIX)).toBe(true); expect(navigated).toContain('add=%7B%22bar%22%3A%222%22%7D'); expect(navigated).not.toBe(absolute(FIRST_PARTY_CLICK)); diff --git a/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts index 971d396f4..933ece79c 100644 --- a/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts @@ -372,10 +372,10 @@ describe('GTM Beacon Guard', () => { originalFetch = window.fetch; sendBeaconSpy = vi.fn(() => true); - navigator.sendBeacon = sendBeaconSpy; + navigator.sendBeacon = sendBeaconSpy as typeof navigator.sendBeacon; fetchSpy = vi.fn(() => Promise.resolve(new Response('', { status: 200 }))); - window.fetch = fetchSpy; + window.fetch = fetchSpy as typeof window.fetch; resetBeaconGuardState(); }); @@ -397,7 +397,7 @@ describe('GTM Beacon Guard', () => { navigator.sendBeacon('https://www.google-analytics.com/g/collect?v=2&tid=G-JGPCNWGVHC', ''); - const calledUrl = sendBeaconSpy.mock.calls[0][0]; + const calledUrl = sendBeaconSpy.mock.calls[0]![0]; expect(calledUrl).toContain('/integrations/google_tag_manager/g/collect'); expect(calledUrl).not.toContain('google-analytics.com'); }); @@ -407,7 +407,7 @@ describe('GTM Beacon Guard', () => { navigator.sendBeacon('https://analytics.google.com/g/collect?v=2&tid=G-DQMZGMPHXN', ''); - const calledUrl = sendBeaconSpy.mock.calls[0][0]; + const calledUrl = sendBeaconSpy.mock.calls[0]![0]; expect(calledUrl).toContain('/integrations/google_tag_manager/g/collect'); expect(calledUrl).not.toContain('analytics.google.com'); }); @@ -417,7 +417,7 @@ describe('GTM Beacon Guard', () => { await window.fetch('https://www.google-analytics.com/g/collect?v=2&tid=G-TEST'); - const calledUrl = fetchSpy.mock.calls[0][0]; + const calledUrl = fetchSpy.mock.calls[0]![0]; expect(calledUrl).toContain('/integrations/google_tag_manager/g/collect'); expect(calledUrl).not.toContain('google-analytics.com'); }); @@ -435,7 +435,7 @@ describe('GTM Beacon Guard', () => { navigator.sendBeacon('https://www.google-analytics.com/g/collect?v=2&tid=G-TEST&cid=123', ''); - const calledUrl = sendBeaconSpy.mock.calls[0][0]; + const calledUrl = sendBeaconSpy.mock.calls[0]![0]; expect(calledUrl).toContain('v=2&tid=G-TEST&cid=123'); }); }); 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 70a75140e..030630d6b 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 @@ -6,47 +6,10 @@ import { resolve } from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import envelope from '../../fixtures/aps-renderer-v1.json'; -import { registerPublisherFirstImpressionAuctions } from '../../../src/core/first_impression'; -import type { AuctionBidData, TsjsApi } from '../../../src/core/types'; -import { - APS_PREBID_CREATIVE_RUNNER_URL, - APS_RENDERING_MODE_ATTRIBUTE_NAME, -} from '../../../src/integrations/aps/render'; - -let publisherNativeScript: HTMLScriptElement | undefined; - -function enablePublisherNativeMode(): { remove(): void } { - publisherNativeScript = document.createElement('script'); - publisherNativeScript.setAttribute(APS_RENDERING_MODE_ATTRIBUTE_NAME, 'publisher_native'); - return { - remove: () => { - publisherNativeScript = undefined; - }, - }; -} - -function nativeRunnerIn(divId: string): { - frame: HTMLIFrameElement; - runner: HTMLScriptElement; - event: CustomEvent<{ aaxResponse: string; seatBidId: string }>; -} { - const container = document.getElementById(divId)!; - const frame = Array.from(container.querySelectorAll('iframe')).find( - (candidate) => candidate.title === 'Ad content' - ); - expect(frame).not.toBeUndefined(); - const runner = frame!.contentDocument?.querySelector('script'); - const frameWindow = frame!.contentWindow as unknown as { - _aps: Map> }>; - }; - const event = Array.from(frameWindow._aps.values())[0]?.queue[0]; - expect(runner?.src).toBe(APS_PREBID_CREATIVE_RUNNER_URL); - expect(event).not.toBeUndefined(); - return { frame: frame!, runner: runner!, event }; -} +import type { GptSlotHandoff, TsjsApi } from '../../../src/core/types'; function apsRenderer() { - const bid = envelope.seatbid[0].bid[0]; + const bid = envelope.seatbid[0]!.bid[0]!; return { type: 'aps' as const, version: 1 as const, @@ -114,10 +77,14 @@ interface PrebidResponseMessage { // `tsjs` is declared globally as the full `TsjsApi` (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. +type TestGptSlotHandoff = Omit & { formats: number[][] }; +type TestTsjsApi = Omit, 'gptSlotHandoffs'> & { + gptSlotHandoffs?: Record | undefined; +}; type TestWindow = Omit & { googletag?: unknown; apstag?: { setDisplayBids?: () => void }; - tsjs?: Partial; + tsjs?: TestTsjsApi; }; async function runGptBootstrapWithGoogleTag(googletag: object): Promise { @@ -923,8 +890,8 @@ describe('installTsAdInit', () => { 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_'] + expect((window as TestWindow).tsjs!.gptSlotHandoffs![hydratedId]).toBe( + (window as TestWindow).tsjs!.gptSlotHandoffs!['ad-header-0-_R_0_'] ); } ); @@ -1090,9 +1057,9 @@ describe('installTsAdInit', () => { 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 = { + 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', }; @@ -1417,7 +1384,7 @@ describe('installTsAdInit', () => { (pubads.refresh as () => void)(); expect(nativeRefresh).toHaveBeenCalledWith([unrelatedSlot]); - expect((window as TestWindow).tsjs!.gptSlotHandoffs['div-claimed']).toEqual( + expect((window as TestWindow).tsjs!.gptSlotHandoffs!['div-claimed']).toEqual( expect.objectContaining({ suppressPublisherRefresh: false }) ); }); @@ -2570,7 +2537,7 @@ describe('installTsAdInit', () => { atf_sidebar_ad: { hb_pb: '1.50', hb_bidder: 'aps', - hb_adid: envelope.seatbid[0].bid[0].id, + hb_adid: envelope.seatbid[0]!.bid[0]!.id, renderer: apsRenderer(), }, }, @@ -3423,7 +3390,7 @@ describe('installTsRenderBridge', () => { it('serves a server APS renderer once and rejects a repeated request', async () => { const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { + (window as TestWindow).tsjs!.bids!.homepage_header = { hb_adid: renderer.bidId, hb_bidder: 'aps', hb_pb: '1.23', @@ -3455,10 +3422,10 @@ describe('installTsRenderBridge', () => { expect(stopSpy).toHaveBeenCalledTimes(2); expect(fetchStub).not.toHaveBeenCalled(); expect(beaconSpy).not.toHaveBeenCalled(); - // Server-rendered APS capabilities are one-shot per slot and ad ID. A - // repeated Universal Creative request is claimed but receives no payload. - expect(portMessages).toHaveLength(1); - const response = JSON.parse(portMessages[0]) as Record; + // 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', @@ -3505,7 +3472,7 @@ describe('installTsRenderBridge', () => { const rendererPost = vi.spyOn(outerFrame.contentWindow!, 'postMessage'); outerFrame.dispatchEvent(new Event('load')); - const sent = rendererPost.mock.calls[0][0] as { nonce: string }; + 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 }, @@ -3595,8 +3562,9 @@ describe('installTsRenderBridge', () => { 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 markUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { + const markWinner = vi.fn(); + const markRendered = vi.fn(); + (window as TestWindow).tsjs!.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', renderer, @@ -3632,8 +3600,9 @@ describe('installTsRenderBridge', () => { expect(stopSpy).toHaveBeenCalledTimes(2); expect(portMessages).toHaveLength(1); - expect(markUsed).toHaveBeenCalledTimes(1); - expect(JSON.parse(portMessages[0])).toEqual( + expect(markWinner).toHaveBeenCalledTimes(1); + expect(markRendered).toHaveBeenCalledTimes(1); + expect(JSON.parse(portMessages[0]!)).toEqual( expect.objectContaining({ message: 'Prebid Response', adId: prebidAdId, @@ -3643,20 +3612,19 @@ describe('installTsRenderBridge', () => { }) ); expect(renderer.bidId).not.toBe(prebidAdId); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); - expect(collapsed.iframe.width).toBe('300'); - expect(collapsed.iframe.height).toBe('250'); - expect(collapsed.wrapper.style.width).toBe('300px'); - expect(collapsed.wrapper.style.height).toBe('250px'); + expect((window as TestWindow).tsjs!.apsPrebidRenderers![prebidAdId]).toBeUndefined(); expect(fetchStub).not.toHaveBeenCalled(); foreignIframe.remove(); }); it('contract test: fails a registered APS runner without a Universal Creative response or markUsed', async () => { const renderer = apsRenderer(); - const prebidAdId = 'native-prebid-decline-ad-id'; - const markUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { + 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, @@ -3684,14 +3652,16 @@ describe('installTsRenderBridge', () => { await Promise.resolve(); bridgeListener(request); - expect(markUsed).not.toHaveBeenCalled(); - expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); - expect(document.querySelector('iframe[title="Ad content"]')).toBeNull(); - expect(document.querySelector('iframe[src*="/integrations/aps/renderer"]')).toBeNull(); - } finally { - marker.remove(); - } + 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('contract test: consumes a registered APS capability and marks it used only after runner load', async () => { @@ -3781,7 +3751,7 @@ describe('installTsRenderBridge', () => { const markUsed = vi.fn(() => { throw new Error('fictional markUsed failure'); }); - (window as TestWindow).tsjs.apsPrebidRenderers = { + (window as TestWindow).tsjs!.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', renderer, @@ -3807,7 +3777,7 @@ describe('installTsRenderBridge', () => { ).not.toThrow(); expect(portMessages).toHaveLength(1); - expect(JSON.parse(portMessages[0])).toEqual( + expect(JSON.parse(portMessages[0]!)).toEqual( expect.objectContaining({ message: 'Prebid Response', adId: prebidAdId, @@ -3823,9 +3793,11 @@ describe('installTsRenderBridge', () => { const renderer = apsRenderer(); const prebidAdId = 'expiring-consumed-ad-id'; const start = Date.now(); - const firstMarkUsed = vi.fn(); - const secondMarkUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { + 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, @@ -3852,7 +3824,7 @@ describe('installTsRenderBridge', () => { sendRequest(); vi.advanceTimersByTime(60_001); - (window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId] = { + (window as TestWindow).tsjs!.apsPrebidRenderers![prebidAdId] = { adUnitCode: 'div-header', renderer, registeredAt: Date.now(), @@ -3888,7 +3860,7 @@ describe('installTsRenderBridge', () => { }, ]) ); - (window as TestWindow).tsjs.apsPrebidRenderers = entries; + (window as TestWindow).tsjs!.apsPrebidRenderers = entries; const bridgeListener = await captureBridgeListener(); const source = createTrustedSlotIframe(); @@ -3912,16 +3884,17 @@ describe('installTsRenderBridge', () => { sendRequest('capacity-ad-0'); expect(portMessages).toHaveLength(capacity); - expect(callbacks[capacity].markUsed).not.toHaveBeenCalled(); + expect(callbacks[capacity]!.markWinner).not.toHaveBeenCalled(); + expect(callbacks[capacity]!.markRendered).not.toHaveBeenCalled(); expect(entries[`capacity-ad-${capacity}`]).toBeDefined(); - expect(callbacks[0].markUsed).toHaveBeenCalledTimes(1); + 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 = { + (window as TestWindow).tsjs!.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', renderer, @@ -3951,13 +3924,13 @@ describe('installTsRenderBridge', () => { expect(stopSpy).toHaveBeenCalledTimes(1); expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeDefined(); + 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 = { + (window as TestWindow).tsjs!.apsPrebidRenderers = { [prebidAdId]: { adUnitCode: 'div-header', renderer: apsRenderer(), @@ -3982,12 +3955,12 @@ describe('installTsRenderBridge', () => { expect(stopSpy).not.toHaveBeenCalled(); expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); + expect((window as TestWindow).tsjs!.apsPrebidRenderers![prebidAdId]).toBeUndefined(); }); it('claims a TS-owned request before rejecting invalid APS data', async () => { const renderer = { ...apsRenderer(), aaxResponse: 'invalid' }; - (window as TestWindow).tsjs.bids.homepage_header = { + (window as TestWindow).tsjs!.bids!.homepage_header = { hb_adid: renderer.bidId, hb_bidder: 'aps', renderer, @@ -4013,12 +3986,12 @@ describe('installTsRenderBridge', () => { 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 = { + (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-'; + (window as TestWindow).tsjs!.adSlots![0]!.div_id = 'div-header-'; const bridgeListener = await captureBridgeListener(); const source = createTrustedSlotIframe('div-header-dynamic'); @@ -4038,12 +4011,12 @@ describe('installTsRenderBridge', () => { it('does not let an overlapping slot prefix claim another slot iframe', async () => { const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { + (window as TestWindow).tsjs!.bids!.homepage_header = { hb_adid: renderer.bidId, hb_bidder: 'aps', renderer, }; - (window as TestWindow).tsjs.adSlots.push({ + (window as TestWindow).tsjs!.adSlots!.push({ id: 'homepage_header_mobile', formats: [[320, 50]], gam_unit_path: '/a/b/mobile', @@ -4071,12 +4044,12 @@ describe('installTsRenderBridge', () => { it('ignores an APS ad ID requested by another configured slot', async () => { const renderer = apsRenderer(); - (window as TestWindow).tsjs.bids.homepage_header = { + (window as TestWindow).tsjs!.bids!.homepage_header = { hb_adid: renderer.bidId, hb_bidder: 'aps', renderer, }; - (window as TestWindow).tsjs.adSlots.push({ + (window as TestWindow).tsjs!.adSlots!.push({ id: 'homepage_footer', formats: [[300, 250]], gam_unit_path: '/a/b/footer', @@ -4173,7 +4146,7 @@ describe('installTsRenderBridge', () => { expect(stopSpy).toHaveBeenCalled(); expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + 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); @@ -4590,7 +4563,7 @@ describe('installTsRenderBridge', () => { expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; expect(parsed.ad).toBe(rawAd); expect(beaconSpy).toHaveBeenCalledTimes(2); beaconSpy.mockRestore(); @@ -4622,7 +4595,7 @@ describe('installTsRenderBridge', () => { expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; expect(parsed.width).toBe(300); expect(parsed.height).toBe(250); expect(collapsed.iframe.width).toBe('300'); @@ -4694,7 +4667,7 @@ describe('installTsRenderBridge', () => { expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; expect(parsed.ad).toContain('p=2.5'); expect(parsed.ad).not.toContain('${AUCTION_PRICE}'); beaconSpy.mockRestore(); @@ -4899,7 +4872,7 @@ describe('installTsRenderBridge', () => { expect(stopSpy).toHaveBeenCalled(); expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + 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); @@ -4961,7 +4934,7 @@ describe('installTsRenderBridge', () => { expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + const parsed = JSON.parse(portMessages[0]!) as PrebidResponseMessage; expect(parsed.width).toBe(300); expect(parsed.height).toBe(250); } finally { @@ -5037,7 +5010,7 @@ describe('installTsRenderBridge', () => { expect(portMessages).toHaveLength(1); - const parsed = JSON.parse(portMessages[0]) as PrebidResponseMessage; + 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); 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 f684d7188..f4f79daaa 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 @@ -44,7 +44,7 @@ describe('GPT shim – patchCommandQueue', () => { it('preserves googletag.cmd array identity', () => { const originalCmd: Array<() => void> = []; - win.googletag = { cmd: originalCmd } as GptWindow['googletag']; + win.googletag = { cmd: originalCmd }; installGptShim(); @@ -65,7 +65,7 @@ describe('GPT shim – patchCommandQueue', () => { }; cmd.push = gptCustomPush; - win.googletag = { cmd, _loaded_: true } as GptWindow['googletag']; + win.googletag = { cmd, _loaded_: true }; installGptShim(); @@ -79,7 +79,7 @@ describe('GPT shim – patchCommandQueue', () => { }); it('wraps callbacks pushed after patching with error handling', () => { - win.googletag = { cmd: [] } as GptWindow['googletag']; + win.googletag = { cmd: [] }; installGptShim(); @@ -92,7 +92,7 @@ describe('GPT shim – patchCommandQueue', () => { // The wrapped callback should be in the queue — execute it. const wrappedFn = win.googletag!.cmd[win.googletag!.cmd.length - 1]; - expect(() => wrappedFn()).not.toThrow(); + expect(() => wrappedFn!()).not.toThrow(); errorSpy.mockRestore(); }); @@ -101,7 +101,7 @@ describe('GPT shim – patchCommandQueue', () => { const callOrder: string[] = []; const pending = [() => callOrder.push('first'), () => callOrder.push('second')]; - win.googletag = { cmd: pending } as GptWindow['googletag']; + win.googletag = { cmd: pending }; installGptShim(); @@ -123,7 +123,7 @@ describe('GPT shim – patchCommandQueue', () => { () => callOrder.push('after-error'), ]; - win.googletag = { cmd: pending } as GptWindow['googletag']; + win.googletag = { cmd: pending }; installGptShim(); @@ -137,7 +137,7 @@ describe('GPT shim – patchCommandQueue', () => { it('is idempotent — calling installGptShim twice does not double-wrap', () => { const calls: number[] = []; - win.googletag = { cmd: [] } as GptWindow['googletag']; + win.googletag = { cmd: [] }; installGptShim(); const pushAfterFirst = win.googletag!.cmd.push; @@ -151,7 +151,7 @@ describe('GPT shim – patchCommandQueue', () => { // 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(); + fn!(); expect(calls).toEqual([1]); }); 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 5a1773a59..67b57b77d 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 @@ -118,7 +118,7 @@ describe('GptDiagnosticsBindingManager', () => { status: 'unbound', reason: 'missing_slot_element_id', }); - expect(store.snapshot().slots[0].slotElementId).toBeUndefined(); + expect(store.snapshot().slots[0]!.slotElementId).toBeUndefined(); }); it('treats duplicate DOM IDs as ambiguous', () => { 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 fa50d6366..247dba650 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 @@ -158,8 +158,8 @@ describe('GPT diagnostics integration composition', () => { await settle(); 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).toHaveLength(1); + expect(api.snapshot().slots[0]!.requests[0]!.isEmpty).toBe(false); api.show(); expect(document.getElementById(GPT_DIAGNOSTICS_HOST_ID)).not.toBeNull(); @@ -212,7 +212,7 @@ describe('GPT diagnostics integration composition', () => { binding: { status: 'bound' }, currentVisibilityPercentage: 75, }); - expect(snapshot.slots[0].requests.map((cycle) => cycle.requestNumber)).toEqual([1, 2, 3, 4]); + expect(snapshot.slots[0]!.requests.map((cycle) => cycle.requestNumber)).toEqual([1, 2, 3, 4]); expect(snapshot.callbackIssues).toContainEqual( expect.objectContaining({ kind: 'slotResponseReceived', 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 47d82697b..aeee31c6d 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 @@ -82,7 +82,7 @@ describe('GptDiagnosticsObserver', () => { expect(gpt.googletag.cmd).toHaveLength(1); expect(gpt.pubads.addEventListener).not.toHaveBeenCalled(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); expect(gpt.pubads.addEventListener.mock.calls.map(([name]) => name)).toEqual(EVENT_NAMES); @@ -98,9 +98,9 @@ describe('GptDiagnosticsObserver', () => { observer.install(); expect(gpt.googletag.cmd).toHaveLength(1); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); observer.install(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); expect(store.markGptObserved).toHaveBeenCalledTimes(1); @@ -218,7 +218,7 @@ describe('GptDiagnosticsObserver', () => { expect(delayedWindow.googletag?.cmd).toHaveLength(1); const gpt = controlledGpt(); delayedWindow.googletag!.pubads = gpt.googletag.pubads; - delayedWindow.googletag!.cmd[0](); + delayedWindow.googletag!.cmd[0]!(); expect(gpt.pubads.addEventListener).toHaveBeenCalledTimes(EVENT_NAMES.length); }); @@ -253,7 +253,7 @@ describe('GptDiagnosticsObserver', () => { const slot = fakeSlot(); const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); observer.install(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); gpt.emit('slotRequested', { slot }); gpt.emit('slotResponseReceived', { slot }); @@ -347,7 +347,7 @@ describe('GptDiagnosticsObserver', () => { const slot = fakeSlot(); const observer = new GptDiagnosticsObserver(store, { window: gpt.window }); observer.install(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); gpt.emit('slotRenderEnded', { slot, isEmpty: false, size: 'fluid' }); @@ -366,7 +366,7 @@ describe('GptDiagnosticsObserver', () => { const gpt = controlledGpt(); const observer = new GptDiagnosticsObserver(store, { window: gpt.window, logger }); observer.install(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); const event = { get slot(): GptDiagnosticsSlotLike { throw new Error('slot accessor failed'); @@ -406,7 +406,7 @@ describe('GptDiagnosticsObserver', () => { }); listenerObserver.install(); - expect(() => gpt.googletag.cmd[0]()).not.toThrow(); + expect(() => gpt.googletag.cmd[0]!()).not.toThrow(); expect(logger.warn).toHaveBeenCalledTimes(2); }); @@ -425,7 +425,7 @@ describe('GptDiagnosticsObserver', () => { }; observer.install(); - gpt.googletag.cmd[0](); + gpt.googletag.cmd[0]!(); expect(gpt.googletag.display).toBe(references.display); expect(gpt.googletag.defineSlot).toBe(references.defineSlot); 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 52aef6a7f..146616328 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 @@ -98,8 +98,8 @@ describe('GptDiagnosticsStore', () => { store.recordSlotVisibilityChanged(slot, 20); const snapshot = store.snapshot(); - const recordedSlot = snapshot.slots[0]; - const cycle = recordedSlot.requests[0]; + const recordedSlot = snapshot.slots[0]!; + const cycle = recordedSlot.requests[0]!; expect(snapshot.gptObserved).toBe(true); expect(recordedSlot).toMatchObject({ @@ -209,9 +209,9 @@ describe('GptDiagnosticsStore', () => { viewableAtMs: 8, durations: { renderToLoadMs: 2, renderToViewableMs: 5 }, }); - expect(emptyCycle.loadAtMs).toBe(8); - expect(emptyCycle.viewableAtMs).toBeUndefined(); - expect(store.snapshot().coverage.slotOnload).toMatchObject({ matched: 2, unmatched: 0 }); + expect(emptyCycle!.loadAtMs).toBeUndefined(); + expect(emptyCycle!.viewableAtMs).toBeUndefined(); + expect(store.snapshot().coverage.slotOnload).toMatchObject({ matched: 1, unmatched: 1 }); expect(store.snapshot().coverage.impressionViewable).toMatchObject({ matched: 1, unmatched: 1, @@ -242,10 +242,10 @@ describe('GptDiagnosticsStore', () => { .snapshot() .slots.map((slot) => slot.requests[0]); - expect(requestingCycle.incompleteSequence).toBe(false); - expect(requestingCycle.responseAtMs).toBeUndefined(); - expect(respondedCycle.incompleteSequence).toBe(false); - expect(respondedCycle.renderAtMs).toBeUndefined(); + expect(requestingCycle!.incompleteSequence).toBe(false); + expect(requestingCycle!.responseAtMs).toBeUndefined(); + expect(respondedCycle!.incompleteSequence).toBe(false); + expect(respondedCycle!.renderAtMs).toBeUndefined(); expect(emptyCycle).toMatchObject({ isEmpty: true, incompleteSequence: false }); }); @@ -260,7 +260,7 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRenderEnded(slot, { isEmpty: request === 1 }); } - expect(store.snapshot().slots[0].requests.map((cycle) => cycle.requestNumber)).toEqual([ + expect(store.snapshot().slots[0]!.requests.map((cycle) => cycle.requestNumber)).toEqual([ 1, 2, 3, ]); assertCoverageEquation(store); @@ -291,8 +291,8 @@ describe('GptDiagnosticsStore', () => { expect(() => store.recordSlotRequested(slot)).not.toThrow(); expect(store.snapshot().slots[0]).toMatchObject({ runtimeSlotNumber: 1 }); - expect(store.snapshot().slots[0].slotElementId).toBeUndefined(); - expect(store.snapshot().slots[0].adUnitPath).toBeUndefined(); + expect(store.snapshot().slots[0]!.slotElementId).toBeUndefined(); + expect(store.snapshot().slots[0]!.adUnitPath).toBeUndefined(); }); it('records callbacks without a request as unmatched issues', () => { @@ -305,7 +305,7 @@ describe('GptDiagnosticsStore', () => { store.recordImpressionViewable(slot); const snapshot = store.snapshot(); - expect(snapshot.slots[0].requests).toEqual([]); + expect(snapshot.slots[0]!.requests).toEqual([]); expect(snapshot.callbackIssues).toHaveLength(4); expect(snapshot.callbackIssues.every((issue) => issue.disposition === 'unmatched')).toBe(true); expect( @@ -325,11 +325,11 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRenderEnded(slot, { isEmpty: false }); const snapshot = store.snapshot(); - expect(snapshot.slots[0].requests).toHaveLength(2); - expect(snapshot.slots[0].requests.every((cycle) => cycle.responseAtMs === undefined)).toBe( + expect(snapshot.slots[0]!.requests).toHaveLength(2); + expect(snapshot.slots[0]!.requests.every((cycle) => cycle.responseAtMs === undefined)).toBe( true ); - expect(snapshot.slots[0].requests.every((cycle) => cycle.renderAtMs === undefined)).toBe(true); + expect(snapshot.slots[0]!.requests.every((cycle) => cycle.renderAtMs === undefined)).toBe(true); expect(snapshot.callbackIssues).toMatchObject([ { kind: 'slotResponseReceived', @@ -357,7 +357,7 @@ describe('GptDiagnosticsStore', () => { store.recordSlotResponseReceived(slot); const snapshot = store.snapshot(); - const cycle = snapshot.slots[0].requests[0]; + const cycle = snapshot.slots[0]!.requests[0]!; expect(cycle.incompleteSequence).toBe(true); expect(cycle.durations.requestToResponseMs).toBe(20); expect(cycle.durations.requestToRenderMs).toBe(10); @@ -389,10 +389,10 @@ describe('GptDiagnosticsStore', () => { let snapshot = store.snapshot(); expect(snapshot.slots).toHaveLength(MAX_DIAGNOSTIC_SLOTS); - expect(snapshot.slots[0].runtimeSlotNumber).toBe(2); + expect(snapshot.slots[0]!.runtimeSlotNumber).toBe(2); expect(snapshot.metadata.evictedSlots).toBe(1); - store.recordSlotResponseReceived(slots[0]); + store.recordSlotResponseReceived(slots[0]!); snapshot = store.snapshot(); expect(snapshot.callbackIssues[snapshot.callbackIssues.length - 1]).toMatchObject({ runtimeSlotNumber: 1, @@ -400,14 +400,14 @@ describe('GptDiagnosticsStore', () => { reason: 'evicted_slot', }); - const retainedSlot = slots[slots.length - 1]; + const retainedSlot = slots[slots.length - 1]!; for (let index = 0; index < MAX_REQUEST_CYCLES_PER_SLOT; index += 1) { store.recordSlotRequested(retainedSlot); } snapshot = store.snapshot(); - const retainedRecord = snapshot.slots[snapshot.slots.length - 1]; + const retainedRecord = snapshot.slots[snapshot.slots.length - 1]!; expect(retainedRecord.requests).toHaveLength(MAX_REQUEST_CYCLES_PER_SLOT); - expect(retainedRecord.requests[0].requestNumber).toBe(2); + expect(retainedRecord.requests[0]!.requestNumber).toBe(2); expect(snapshot.metadata.evictedRequestCycles).toBe(1); const issueSlot = fakeSlot('issues'); @@ -430,23 +430,23 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRequested(retained); } - store.recordSlotVisibilityChanged(slots[0], 10); - store.recordSlotRequested(slots[MAX_DIAGNOSTIC_SLOTS]); + store.recordSlotVisibilityChanged(slots[0]!, 10); + store.recordSlotRequested(slots[MAX_DIAGNOSTIC_SLOTS]!); expect(store.snapshot().slots.some((slot) => slot.runtimeSlotNumber === 1)).toBe(true); expect(store.snapshot().slots.some((slot) => slot.runtimeSlotNumber === 2)).toBe(false); - store.recordSlotResponseReceived(slots[1]); - expect(last(store.snapshot().callbackIssues)).toMatchObject({ + store.recordSlotResponseReceived(slots[1]!); + expect(store.snapshot().callbackIssues.slice(-1)[0]).toMatchObject({ runtimeSlotNumber: 2, reason: 'evicted_slot', }); - store.recordSlotRequested(slots[1]); - store.recordSlotResponseReceived(slots[1]); + store.recordSlotRequested(slots[1]!); + store.recordSlotResponseReceived(slots[1]!); const reentered = store.snapshot().slots.find((slot) => slot.slotElementId === 'lru-1'); expect(reentered).toMatchObject({ runtimeSlotNumber: 66 }); expect(reentered?.requests[0]).toMatchObject({ requestNumber: 2 }); - expect(reentered?.requests[0].responseAtMs).toBeDefined(); + expect(reentered?.requests[0]!.responseAtMs).toBeDefined(); expect(store.snapshot().slots).toHaveLength(MAX_DIAGNOSTIC_SLOTS); expect(store.snapshot().metadata.evictedSlots).toBe(2); assertCoverageEquation(store); @@ -465,8 +465,8 @@ describe('GptDiagnosticsStore', () => { { runtimeSlotNumber: 1, slotElementId: 'first' }, { runtimeSlotNumber: 2, slotElementId: 'second' }, ]); - inputs[0].slotElementId = 'changed'; - expect(store.bindingInputs()[0].slotElementId).toBe('first'); + inputs[0]!.slotElementId = 'changed'; + expect(store.bindingInputs()[0]!.slotElementId).toBe('first'); }); it('coalesces notifications and isolates throwing subscribers', () => { @@ -1924,11 +1924,11 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRequested(slot); const first = store.snapshot(); - first.slots[0].requests[0].requestNumber = 999; + first.slots[0]!.requests[0]!.requestNumber = 999; first.coverage.slotRequested.matched = 999; const second = store.snapshot(); - expect(second.slots[0].requests[0].requestNumber).toBe(1); + expect(second.slots[0]!.requests[0]!.requestNumber).toBe(1); expect(second.coverage.slotRequested.matched).toBe(1); }); it('ignores malformed publisher refresh inputs without recording intent', () => { 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 891fd5540..811be1f38 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 @@ -24,7 +24,7 @@ type UspCallback = (data?: { uspString?: string }, success?: boolean) => void; function clearAllCookies(): void { document.cookie.split(';').forEach((cookie) => { - const name = cookie.split('=')[0].trim(); + const name = cookie.split('=')[0]?.trim() ?? ''; if (name) document.cookie = `${name}=; path=/; Max-Age=0`; }); } 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 20e449c0c..20481e9e9 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,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; function apsRenderer() { - const bid = envelope.seatbid[0].bid[0]; + const bid = envelope.seatbid[0]!.bid[0]!; return { type: 'aps' as const, version: 1 as const, @@ -30,13 +30,19 @@ const DEFAULT_BUNDLE_MANIFEST = { /** Loose bid shape used by the requestBids shim tests. */ interface TestBid { bidder: string; - params?: Record; + params?: Record | undefined; } /** Loose ad unit shape used by the requestBids shim tests. */ interface TestAdUnit { - code?: string; - bids?: TestBid[]; + code?: string | undefined; + bids?: TestBid[] | undefined; +} + +function trustedServerBid(unit: TestAdUnit): TestBid & { params: Record } { + const bid = unit.bids?.find((candidate) => candidate.bidder === 'trustedServer'); + if (!bid?.params) throw new Error('expected a trustedServer bid with params'); + return bid as TestBid & { params: Record }; } /** Window properties the prebid shim reads and writes in these tests. */ @@ -61,25 +67,32 @@ interface ApsPrebidTestEntry { interface PrebidTestWindow { pbjs?: unknown; - tsjs?: { - apsPrebidRenderers?: Record; - [key: string]: unknown; - }; - googletag?: TestGoogletag; - __tsjs_prebid?: InjectedPrebidTestConfig; - __tsjsPrebidShimInstalled?: boolean; + tsjs?: Partial | undefined; + googletag?: unknown; + __tsjs_prebid?: Record | undefined; + __tsjsPrebidShimInstalled?: boolean | undefined; __tsjs_prebid_bundle?: unknown; - __tsjs_prebid_diagnostics?: { - userIdModules?: { - includedModules: string[]; - configuredUserIdNames: string[]; - missingConfiguredUserIdNames: string[]; - }; - }; + __tsjs_prebid_diagnostics?: + | { + userIdModules?: + | { + includedModules: string[]; + configuredUserIdNames: string[]; + missingConfiguredUserIdNames: string[]; + } + | undefined; + } + | undefined; } const testWindow = window as unknown as PrebidTestWindow; +function apsPrebidRenderers() { + const registry = testWindow.tsjs?.apsPrebidRenderers; + if (!registry) throw new Error('expected the APS Prebid renderer registry to be installed'); + return registry; +} + /** Argument type accepted by the shimmed `pbjs.requestBids`. */ type RequestBidsArg = Parameters['requestBids']>[0]; @@ -94,7 +107,7 @@ interface TestAdapterSpec { ) => { method: string; url: string; - data: Record; + data: string; options: Record; }; interpretResponse: ( @@ -126,7 +139,10 @@ const { const mockMarkWinningBidAsUsed = vi.fn(); const mockOnEvent = vi.fn(); const mockGetUserIdsAsEids = vi.fn( - () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> + (): Array<{ + source: string; + uids?: Array<{ id: string; atype?: number; ext?: Record }>; + }> => [] ); const mockGetConfig = vi.fn(); @@ -136,7 +152,7 @@ const { return; } const codes = new Set(Array.isArray(adUnitCode) ? adUnitCode : [adUnitCode]); - mockPbjs.adUnits = mockPbjs.adUnits.filter((unit) => !codes.has(unit.code)); + mockPbjs.adUnits = mockPbjs.adUnits.filter((unit) => !unit.code || !codes.has(unit.code)); }); const mockPbjs: { setConfig: typeof mockSetConfig; @@ -148,7 +164,7 @@ const { removeAdUnit: ReturnType; markWinningBidAsUsed: typeof mockMarkWinningBidAsUsed; adUnits: TestAdUnit[]; - setTargetingForGPTAsync?: (adUnitCodes?: string[]) => void; + setTargetingForGPTAsync?: ((adUnitCodes?: string[]) => void) | undefined; [key: string]: unknown; } = { setConfig: mockSetConfig, @@ -201,7 +217,7 @@ import { installRefreshHandler, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; -import { claimFirstImpressionForTrustedServer } from '../../../src/core/first_impression'; +import type { TsjsApi } from '../../../src/core/types'; import { log } from '../../../src/core/log'; import type { TsjsApi } from '../../../src/core/types'; import { GptDiagnosticsObserver } from '../../../src/integrations/gpt_diagnostics/observer'; @@ -363,8 +379,8 @@ describe('prebid/auctionBidsToPrebidBids', () => { const result = auctionBidsToPrebidBids(auctionBids, [], true); expect(result).toHaveLength(1); - expect(result[0].requestId).toBe('div-gpt-2'); - expect(result[0].cpm).toBe(2.0); + expect(result[0]!.requestId).toBe('div-gpt-2'); + expect(result[0]!.cpm).toBe(2.0); }); it('handles multiple bids across different impids', () => { @@ -398,8 +414,8 @@ describe('prebid/auctionBidsToPrebidBids', () => { const result = auctionBidsToPrebidBids(auctionBids, bidRequests, true); expect(result).toHaveLength(2); - expect(result[0].requestId).toBe('req-a'); - expect(result[1].requestId).toBe('req-b'); + expect(result[0]!.requestId).toBe('req-a'); + expect(result[1]!.requestId).toBe('req-b'); }); }); @@ -461,7 +477,7 @@ describe('prebid/installPrebidNpm', () => { }; bidAcceptedListener!(normalizedBid); - const entry = testWindow.tsjs?.apsPrebidRenderers?.['prebid-generated-ad-id']; + const entry = apsPrebidRenderers()['prebid-generated-ad-id']!; expect(entry).toEqual( expect.objectContaining({ adUnitCode: 'div-aps', @@ -573,7 +589,7 @@ describe('prebid/installPrebidNpm', () => { }; bidResponseListener!(delivered); - const entry = testWindow.tsjs?.apsPrebidRenderers?.['stripped-field-ad-id']; + const entry = apsPrebidRenderers()['stripped-field-ad-id']; expect(entry).toEqual( expect.objectContaining({ adUnitCode: 'div-aps', renderer, markUsed: expect.any(Function) }) ); @@ -623,8 +639,8 @@ describe('prebid/installPrebidNpm', () => { }); } - const registry = testWindow.tsjs?.apsPrebidRenderers; - expect(registry?.['shared-imp-ad-id-0']).toEqual( + const registry = apsPrebidRenderers(); + expect(registry['shared-imp-ad-id-0']).toEqual( expect.objectContaining({ renderer: firstRenderer }) ); expect(registry?.['shared-imp-ad-id-1']).toEqual( @@ -650,7 +666,7 @@ describe('prebid/installPrebidNpm', () => { requestId: 'req-reused', trustedServerRenderer: apsRenderer(), }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['surviving-field-ad-id']).toBeDefined(); + 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. @@ -663,7 +679,7 @@ describe('prebid/installPrebidNpm', () => { requestId: 'req-reused', meta: { advertiserDomains: [] }, }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['reused-request-ad-id']).toBeUndefined(); + expect(apsPrebidRenderers()['reused-request-ad-id']).toBeUndefined(); }); it('registers and scrubs on bidAccepted before later events can observe the descriptor', () => { @@ -707,7 +723,7 @@ describe('prebid/installPrebidNpm', () => { }; bidAcceptedListener!(accepted); - expect(testWindow.tsjs?.apsPrebidRenderers?.['accepted-ad-id']).toEqual( + expect(apsPrebidRenderers()['accepted-ad-id']).toEqual( expect.objectContaining({ adUnitCode: 'div-aps', renderer }) ); expect(accepted).not.toHaveProperty('trustedServerRenderer'); @@ -716,9 +732,7 @@ describe('prebid/installPrebidNpm', () => { // The later bidResponse pass sees the already-scrubbed object and no-ops. const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); bidResponseListener!(accepted); - expect(testWindow.tsjs?.apsPrebidRenderers?.['accepted-ad-id']).toEqual( - expect.objectContaining({ renderer }) - ); + expect(apsPrebidRenderers()['accepted-ad-id']).toEqual(expect.objectContaining({ renderer })); expect(warnSpy).not.toHaveBeenCalled(); }); @@ -752,7 +766,7 @@ describe('prebid/installPrebidNpm', () => { meta: 'corrupted', trustedServerRenderer: apsRenderer(), }); - expect(testWindow.tsjs?.apsPrebidRenderers?.['corrupt-meta-with-field-ad-id']).toBeDefined(); + expect(apsPrebidRenderers()['corrupt-meta-with-field-ad-id']).toBeDefined(); }); it('does not register malformed or non-trusted APS renderer capabilities', () => { @@ -813,7 +827,7 @@ describe('prebid/installPrebidNpm', () => { it('reports the User ID modules selected by the generated bundle', () => { installPrebidNpm(); - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ + expect(testWindow.__tsjs_prebid_diagnostics!.userIdModules).toEqual({ includedModules: ['sharedIdSystem'], configuredUserIdNames: [], missingConfiguredUserIdNames: [], @@ -830,7 +844,7 @@ describe('prebid/installPrebidNpm', () => { mockPbjs.requestBids({ adUnits: [] }); mockPbjs.requestBids({ adUnits: [] }); - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ + expect(testWindow.__tsjs_prebid_diagnostics!.userIdModules).toEqual({ includedModules: ['sharedIdSystem'], configuredUserIdNames: ['pairId', 'sharedId'], missingConfiguredUserIdNames: ['pairId'], @@ -866,7 +880,7 @@ describe('prebid/installPrebidNpm', () => { installPrebidNpm(); mockPbjs.requestBids({ adUnits: [] }); - expect(testWindow.__tsjs_prebid_diagnostics.userIdModules).toEqual({ + expect(testWindow.__tsjs_prebid_diagnostics!.userIdModules).toEqual({ includedModules: [], configuredUserIdNames: ['pairId', 'sharedId'], missingConfiguredUserIdNames: [], @@ -886,7 +900,7 @@ describe('prebid/installPrebidNpm', () => { describe('adapter spec', () => { function getAdapterSpec(): TestAdapterSpec { installPrebidNpm(); - return mockRegisterBidAdapter.mock.calls[0][2] as TestAdapterSpec; + return mockRegisterBidAdapter.mock.calls[0]![2] as TestAdapterSpec; } it('isBidRequestValid always returns true', () => { @@ -1035,7 +1049,7 @@ describe('prebid/installPrebidNpm', () => { it('buildRequests uses custom endpoint when configured', () => { mockRegisterBidAdapter.mockClear(); installPrebidNpm({ endpoint: '/custom/auction' }); - const spec = mockRegisterBidAdapter.mock.calls[0][2]; + const spec = mockRegisterBidAdapter.mock.calls[0]![2]; const result = spec.buildRequests([ { @@ -1151,8 +1165,8 @@ describe('prebid/installPrebidNpm', () => { 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'); + expect(bidsA[0]!.requestId).toBe('bid-a'); + expect(bidsB[0]!.requestId).toBe('bid-b'); }); }); @@ -1207,10 +1221,10 @@ describe('prebid/installPrebidNpm', () => { expect(hasTsBidder).toBe(true); } - const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); - expect(trustedServerBid.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']); + 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(); @@ -1222,7 +1236,7 @@ describe('prebid/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; + const tsCount = adUnits[0]!.bids.filter((b: TestBid) => b.bidder === 'trustedServer').length; expect(tsCount).toBe(1); }); @@ -1246,64 +1260,12 @@ describe('prebid/installPrebidNpm', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const trustedServerBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer'); - expect(trustedServerBid?.params?.bidderParams).toEqual({ - pbsRoute: { placement: 'pbs' }, - standardRoute: { placement: 'standard' }, - }); - expect(adUnits[0].bids.map((b: TestBid) => b.bidder)).toEqual([ - 'exampleBrowser', - 'aps', - 'pbs-provider-id', - 'trustedServer', - ]); - }); - - it('preserves prototype-named server-side bidders as owned JSON properties', () => { - testWindow.__tsjs_prebid = { serverSideBidders: ['__proto__'] }; - const pbjs = installPrebidNpm(); - const adUnits = [ - { - bids: [{ bidder: '__proto__', params: { placement: 'server-owned' } }], - }, - ]; - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const trustedServerBid = adUnits[0].bids.find((bid) => bid.bidder === 'trustedServer'); - const bidderParams = trustedServerBid?.params?.bidderParams as Record; - expect(Object.prototype.hasOwnProperty.call(bidderParams, '__proto__')).toBe(true); - expect(bidderParams['__proto__']).toEqual({ placement: 'server-owned' }); - expect(JSON.parse(JSON.stringify(bidderParams))).toEqual( - Object.fromEntries([['__proto__', { placement: 'server-owned' }]]) - ); - expect(adUnits[0].bids.map((bid) => bid.bidder)).toEqual(['trustedServer']); - }); - - it('does not let returned bidder aliases or APS renderer aliases affect folding', () => { - testWindow.__tsjs_prebid = { serverSideBidders: ['configuredRoute'] }; - const pbjs = installPrebidNpm(); - const adUnits = [ - { - bids: [ - { bidder: 'configuredRoute', params: { placement: 1 } }, - { bidder: 'alternateReturnedSeat', params: { placement: 2 } }, - { bidder: 'apsRendererAlias', params: { placement: 3 } }, - ], - }, - ]; - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - const trustedServerBid = adUnits[0].bids.find((bid) => bid.bidder === 'trustedServer'); - expect(trustedServerBid?.params?.bidderParams).toEqual({ - configuredRoute: { placement: 1 }, + const tsBid = trustedServerBid(adUnits[0]!); + expect(tsBid.params.bidderParams).toEqual({ + appnexus: { placementId: 123 }, + rubicon: { accountId: 'abc' }, }); - expect(adUnits[0].bids.map((bid) => bid.bidder)).toEqual([ - 'alternateReturnedSeat', - 'apsRendererAlias', - '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', () => { @@ -1326,10 +1288,8 @@ describe('prebid/installPrebidNpm', () => { // overwrite the captured params with an empty object. pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const trustedServerBid = adUnits[0].bids.find( - (b: TestBid) => b.bidder === 'trustedServer' - ) as TestBid; - expect(trustedServerBid.params.bidderParams).toEqual({ + const tsBid = trustedServerBid(adUnits[0]!); + expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, }); @@ -1341,19 +1301,19 @@ describe('prebid/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'); + 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 TestAdUnit[]; + ] as unknown as TestAdUnit[]; expect(() => pbjs.requestBids({ adUnits } as unknown as RequestBidsArg)).not.toThrow(); - expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); + expect(adUnits[0]!.bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); }); it('preserves the empty stored-request envelope on initial and repeated requests', () => { @@ -1388,10 +1348,10 @@ describe('prebid/installPrebidNpm', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid0 = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid0 = trustedServerBid(adUnits[0]!); expect(tsBid0.params.zone).toBe('header'); - const tsBid1 = adUnits[1].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid1 = trustedServerBid(adUnits[1]!); expect(tsBid1.params.zone).toBe('fixed_bottom'); }); @@ -1407,7 +1367,7 @@ describe('prebid/installPrebidNpm', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid = trustedServerBid(adUnits[0]!); expect(tsBid.params.zone).toBeUndefined(); }); @@ -1417,7 +1377,7 @@ describe('prebid/installPrebidNpm', () => { const adUnits = [{ bids: [{ bidder: 'rubicon', params: {} }] }]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid = trustedServerBid(adUnits[0]!); expect(tsBid.params.zone).toBeUndefined(); }); @@ -1437,14 +1397,14 @@ describe('prebid/installPrebidNpm', () => { pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - let tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + let tsBid = trustedServerBid(adUnits[0]!); expect(tsBid.params.zone).toBe('header'); expect(tsBid.params.custom).toBe('keep'); - delete adUnits[0].mediaTypes.banner.name; + delete (adUnits[0]!.mediaTypes.banner as { name?: string }).name; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + tsBid = trustedServerBid(adUnits[0]!); expect(tsBid.params.zone).toBeUndefined(); expect(tsBid.params.custom).toBe('keep'); }); @@ -1455,7 +1415,7 @@ describe('prebid/installPrebidNpm', () => { mockPbjs.adUnits = [{ bids: [{ bidder: 'openx', params: {} }] }] as TestAdUnit[]; pbjs.requestBids({} as RequestBidsArg); - const hasTsBidder = (mockPbjs.adUnits[0].bids ?? []).some( + const hasTsBidder = (mockPbjs.adUnits[0]!.bids ?? []).some( (b: TestBid) => b.bidder === 'trustedServer' ); expect(hasTsBidder).toBe(true); @@ -2084,7 +2044,7 @@ describe('prebid/installRefreshHandler', () => { expect(clearTargeting).not.toHaveBeenCalledWith('ts'); expect(originalRefresh).not.toHaveBeenCalled(); - const bidsBackHandler = mockRequestBids.mock.calls[0][0].bidsBackHandler; + const bidsBackHandler = mockRequestBids.mock.calls[0]![0].bidsBackHandler; bidsBackHandler(); expect(setTargetingForGPTAsync).toHaveBeenCalled(); @@ -2625,7 +2585,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { installedGptSlots = slots; for (const slot of slots) { if (!slot || typeof slot !== 'object') continue; - const originalGetTargeting = slot.getTargeting?.bind(slot); + 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]; @@ -2646,11 +2610,20 @@ describe('prebid publisher snapshots and delivery refreshes', () => { return { originalRefresh, pubads }; } - function refreshAdUnitFromLastRequest(): - | (Record & { code?: string; bids?: TestBid[] }) - | undefined { + function refreshAdUnitFromLastRequest(): Record & { + code?: string; + bids: TestBid[]; + } { const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; - return lastCall?.[0]?.adUnits?.[0]; + 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( @@ -2668,7 +2641,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }; if (options.applyTargeting !== false) { const slot = installedGptSlots.find((candidate) => { - const elementId = candidate?.getSlotElementId?.(); + 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); @@ -3120,9 +3097,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, ], } as unknown as RequestBidsArg); - serverParams.placement.rules[0].label = 'changed-rule'; + serverParams.placement.rules[0]!.label = 'changed-rule'; serverParams.placement.sizes.push(999); - browserParams.groups[0].values[0] = 'changed-value'; + browserParams.groups[0]!.values[0] = 'changed-value'; pubads.refresh([slot]); @@ -3148,10 +3125,18 @@ describe('prebid publisher snapshots and delivery refreshes', () => { const firstRefreshBids = refreshAdUnitFromLastRequest().bids; expect(firstRefreshBids).toEqual(expectedBids); - firstRefreshBids[0].params.bidderParams.exampleServer.placement.rules[0].label = + 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'; - firstRefreshBids[0].params.bidderParams.exampleServer.placement.sizes.push(777); - firstRefreshBids[1].params.groups[0].values[0] = 'changed-refresh-value'; + mutableServerParams.bidderParams.exampleServer.placement.sizes.push(777); + mutableBrowserParams.groups[0]!.values[0] = 'changed-refresh-value'; pubads.refresh([slot]); expect(refreshAdUnitFromLastRequest().bids).toEqual(expectedBids); @@ -3178,13 +3163,13 @@ describe('prebid publisher snapshots and delivery refreshes', () => { ], } as unknown as RequestBidsArg); pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: { exampleServer: { placement: 'one' } }, zone: 'example-zone-one', }); pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: { exampleServer: { placement: 'one' } }, zone: 'example-zone-one', }); @@ -3200,7 +3185,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } as unknown as RequestBidsArg); pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: { exampleServer: { placement: 'two' } }, zone: 'example-zone-two', }); @@ -3248,15 +3233,15 @@ describe('prebid publisher snapshots and delivery refreshes', () => { ]; pubads.refresh([slotOne]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ exampleServer: { placement: 'one' }, }); pubads.refresh([slotTwo]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ exampleServer: { placement: 'two' }, }); pubads.refresh([globalSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ exampleFallback: { placement: 'global' }, }); }); @@ -3382,24 +3367,24 @@ describe('prebid publisher snapshots and delivery refreshes', () => { })), } as unknown as RequestBidsArg); (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit( - codes[0] + codes[0]! ); (pbjs as unknown as { removeAdUnit: (adUnitCode?: string | string[]) => void }).removeAdUnit([ - codes[1], + 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] }, + 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(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + pubads.refresh([slots[2]!]); + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); }); it('bounds snapshots with LRU eviction while retaining a recently refreshed entry', () => { @@ -3443,9 +3428,9 @@ describe('prebid publisher snapshots and delivery refreshes', () => { } as unknown as RequestBidsArg); pubads.refresh([oldestSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ bidderParams: {} }); + expect(refreshBidFromLastRequest().params).toEqual({ bidderParams: {} }); pubads.refresh([activeSlot]); - expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + expect(refreshBidFromLastRequest().params.bidderParams).toEqual({ exampleServer: { placement: capacity - 1 }, }); }); @@ -3462,7 +3447,15 @@ describe('prebid publisher snapshots and delivery refreshes', () => { clearTargeting: vi.fn(), }; testWindow.tsjs = { - adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], + 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)); @@ -3599,10 +3592,10 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(mockRequestBids).toHaveBeenCalledTimes(3); expect( - mockRequestBids.mock.calls[1][0].adUnits.map((unit: { code?: string }) => unit.code) + 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) + 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'); @@ -3769,7 +3762,12 @@ describe('prebid publisher snapshots and delivery refreshes', () => { adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], bidsBackHandler: () => { ( - pbjs as unknown as { setTargetingForGPTAsync: (codes?: string[]) => void } + pbjs as unknown as { + setTargetingForGPTAsync: ( + codes?: string[] | null, + customSlotMatching?: () => (slot: unknown) => boolean + ) => void; + } ).setTargetingForGPTAsync(null, () => () => true); pubads.refresh([slot]); }, @@ -4145,8 +4143,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { vi.advanceTimersByTime(640); expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-missing-refresh-callback']); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] + expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]!).toBeLessThan( + originalRefresh.mock.invocationCallOrder[0]! ); expect(originalRefresh).toHaveBeenCalledTimes(1); expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); @@ -4179,8 +4177,8 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(setTargetingForGPTAsync).toHaveBeenCalledTimes(1); expect(setTargetingForGPTAsync).toHaveBeenCalledWith(['example-late-refresh-callback']); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] + expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]!).toBeLessThan( + originalRefresh.mock.invocationCallOrder[0]! ); expect(originalRefresh).toHaveBeenCalledTimes(1); } finally { @@ -4344,7 +4342,7 @@ describe('prebid/client-side bidders', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid = trustedServerBid(adUnits[0]!); expect(tsBid).toBeDefined(); // rubicon should NOT be in bidderParams — it runs client-side expect(tsBid.params.bidderParams).toEqual({ @@ -4372,10 +4370,10 @@ describe('prebid/client-side bidders', () => { 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; + 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(); + expect(adUnits[0]!.bids.find((b: TestBid) => b.bidder === 'appnexus')).toBeUndefined(); }); it('handles multiple client-side bidders', () => { @@ -4397,16 +4395,16 @@ describe('prebid/client-side bidders', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + 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(); + 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('leaves all unowned bidders in browser demand when no routes are configured', () => { @@ -4423,13 +4421,11 @@ describe('prebid/client-side bidders', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; - expect(tsBid.params?.bidderParams).toEqual({}); - expect(adUnits[0].bids.map((bid) => bid.bidder)).toEqual([ - 'appnexus', - 'rubicon', - 'trustedServer', - ]); + 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', () => { @@ -4450,7 +4446,7 @@ describe('prebid/client-side bidders', () => { ]; pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid = trustedServerBid(adUnits[0]!); expect(tsBid.params.bidderParams).toEqual({ appnexus: { placementId: 123 }, rubicon: { accountId: 'abc' }, @@ -4476,7 +4472,7 @@ describe('prebid/client-side bidders', () => { pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); // trustedServer should still be present (even with empty bidderParams) - const tsBid = adUnits[0].bids.find((b: TestBid) => b.bidder === 'trustedServer') as TestBid; + const tsBid = trustedServerBid(adUnits[0]!); expect(tsBid).toBeDefined(); expect(tsBid.params.bidderParams).toEqual({}); }); 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 fc29e14c2..e7906f111 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 @@ -71,7 +71,7 @@ function sourcepointPayload(gppString = 'DBABLA~BVQqAAAAAgA.QA', applicableSecti describe('integrations/sourcepoint', () => { function clearAllCookies(): void { document.cookie.split(';').forEach((c) => { - const name = c.split('=')[0].trim(); + const name = c.split('=')[0]?.trim() ?? ''; if (name) document.cookie = `${name}=; path=/; Max-Age=0`; }); } 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 881a4515f..f36a1a500 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,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { createBeaconGuard, BeaconGuardConfig } from '../../src/shared/beacon_guard'; +import { createBeaconGuard } from '../../src/shared/beacon_guard'; +import type { BeaconGuardConfig } from '../../src/shared/beacon_guard'; describe('Beacon Guard', () => { let originalSendBeacon: typeof navigator.sendBeacon; @@ -16,10 +17,10 @@ describe('Beacon Guard', () => { // Create spies that simulate real sendBeacon/fetch behaviour sendBeaconSpy = vi.fn(() => true); - navigator.sendBeacon = sendBeaconSpy; + navigator.sendBeacon = sendBeaconSpy as typeof navigator.sendBeacon; fetchSpy = vi.fn(() => Promise.resolve(new Response('', { status: 200 }))); - window.fetch = fetchSpy; + window.fetch = fetchSpy as typeof window.fetch; config = { name: 'Test', @@ -130,7 +131,7 @@ describe('Beacon Guard', () => { await window.fetch(request); // The spy should receive a new Request with the rewritten URL - const calledArg = fetchSpy.mock.calls[0][0]; + const calledArg = fetchSpy.mock.calls[0]![0] as Request; expect(calledArg).toBeInstanceOf(Request); expect(calledArg.url).toContain('/proxy/g/collect?tid=G-TEST'); }); diff --git a/crates/trusted-server-js/lib/tsconfig.json b/crates/trusted-server-js/lib/tsconfig.json index b17377a14..4c2fed413 100644 --- a/crates/trusted-server-js/lib/tsconfig.json +++ b/crates/trusted-server-js/lib/tsconfig.json @@ -1,16 +1,21 @@ { "compilerOptions": { "target": "ES2018", - "lib": ["ES2020", "DOM"], + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "noImplicitOverride": true, + "useUnknownInCatchVariables": true, "skipLibCheck": true, "noEmit": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "types": ["vitest/globals", "node"] + "types": ["vitest/globals", "node", "vite/client"] }, "include": ["src", "test"] } diff --git a/scripts/dispatch-workflow-run.mjs b/scripts/dispatch-workflow-run.mjs new file mode 100644 index 000000000..871d907e0 --- /dev/null +++ b/scripts/dispatch-workflow-run.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; + +const POLL_INTERVAL_MS = 2_000; +const POLL_TIMEOUT_MS = 120_000; + +function fail(message) { + throw new Error(`[dispatch-workflow-run] ${message}`); +} + +function run(command, args, options = {}) { + try { + return execFileSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch (error) { + const stderr = + error && typeof error === "object" && "stderr" in error + ? error.stderr + : ""; + fail( + `${command} ${args.join(" ")} failed${stderr ? `: ${String(stderr).trim()}` : ""}`, + ); + } +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function parseInputs(argumentsList) { + const inputs = new Map(); + for (const argument of argumentsList) { + const separator = argument.indexOf("="); + if (separator <= 0) + fail(`workflow input must use key=value syntax: ${argument}`); + const key = argument.slice(0, separator); + const value = argument.slice(separator + 1); + if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) || value.length === 0) { + fail(`invalid workflow input: ${argument}`); + } + if (inputs.has(key)) fail(`duplicate workflow input: ${key}`); + inputs.set(key, value); + } + return inputs; +} + +function remoteShaForRef(ref) { + const escapedRef = ref.replace(/^refs\/(heads|tags)\//, ""); + const output = run("git", [ + "ls-remote", + "--heads", + "--tags", + "origin", + `refs/heads/${escapedRef}`, + `refs/tags/${escapedRef}`, + `refs/tags/${escapedRef}^{}`, + ]); + const rows = output + .split("\n") + .filter(Boolean) + .map((row) => row.split(/\s+/, 2)); + if (rows.length === 0) fail(`ref is not pushed to origin: ${ref}`); + const dereferencedTag = rows.find(([, remoteRef]) => + remoteRef?.endsWith("^{}"), + ); + return (dereferencedTag ?? rows[0])?.[0]; +} + +function listRuns(workflow) { + const output = run("gh", [ + "run", + "list", + "--workflow", + workflow, + "--event", + "workflow_dispatch", + "--limit", + "100", + "--json", + "databaseId,displayTitle,headBranch,headSha,createdAt", + ]); + try { + return JSON.parse(output); + } catch (error) { + fail( + `gh returned invalid run JSON: ${error instanceof Error ? error.message : error}`, + ); + } +} + +function matchingRuns(runs, evidenceId, sha, dispatchedAfter) { + return runs.filter((candidate) => { + const createdAt = Date.parse(candidate.createdAt); + return ( + candidate.headSha === sha && + candidate.displayTitle?.includes(evidenceId) && + Number.isFinite(createdAt) && + createdAt >= dispatchedAfter + ); + }); +} + +async function main() { + const [workflow, ref, ...inputArguments] = process.argv.slice(2); + if (!workflow || !ref) { + fail( + "usage: dispatch-workflow-run.mjs key=value [...]", + ); + } + const inputs = parseInputs(inputArguments); + const evidenceId = inputs.get("evidence_id"); + if (!evidenceId) fail("required workflow input is missing: evidence_id"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/.test(evidenceId)) { + fail("evidence_id must be 8-128 URL-safe characters"); + } + + const localSha = run("git", ["rev-parse", `${ref}^{commit}`]); + const remoteSha = remoteShaForRef(ref); + if (localSha !== remoteSha) { + fail( + `ref is not pushed at the local commit: local ${localSha}, origin ${remoteSha}`, + ); + } + + const existing = listRuns(workflow).filter((runRecord) => + runRecord.displayTitle?.includes(evidenceId), + ); + if (existing.length > 0) + fail(`evidence_id has already been used: ${evidenceId}`); + + const dispatchedAfter = Date.now() - 5_000; + const dispatchArguments = ["workflow", "run", workflow, "--ref", ref]; + for (const [key, value] of inputs) + dispatchArguments.push("-f", `${key}=${value}`); + run("gh", dispatchArguments); + + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + const matches = matchingRuns( + listRuns(workflow), + evidenceId, + localSha, + dispatchedAfter, + ); + if (matches.length > 1) + fail(`more than one workflow run matched evidence_id ${evidenceId}`); + if (matches.length === 1) { + const runId = matches[0].databaseId; + if (!Number.isSafeInteger(runId) || runId <= 0) + fail("matched run has an invalid numeric id"); + process.stdout.write(`${runId}\n`); + return; + } + await sleep(POLL_INTERVAL_MS); + } + fail(`timed out waiting for workflow run with evidence_id ${evidenceId}`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : error}\n`); + process.exitCode = 1; +}); diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index 714de510b..debedff8d 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -9,7 +9,7 @@ # - Docker running # - Viceroy installed: cargo install viceroy --version 0.17.0 --locked --force # - wasm32-wasip1 target: rustup target add wasm32-wasip1 -# - Node.js with npx available +# - Node.js with npm available # set -euo pipefail @@ -20,12 +20,30 @@ ORIGIN_PORT="${INTEGRATION_ORIGIN_PORT:-8888}" BROWSER_DIR="crates/trusted-server-integration-tests/browser" TSJS_LIB_DIR="crates/trusted-server-js/lib" 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" if [ -z "$NODE_VERSION" ]; then echo "Failed to detect Node.js version from .tool-versions" >&2 exit 1 fi +if [ "${#FRAMEWORKS[@]}" -eq 0 ]; then + echo "TS_BROWSER_FRAMEWORKS must select at least one framework" >&2 + exit 1 +fi + +for framework in "${FRAMEWORKS[@]}"; do + case "$framework" in + nextjs|wordpress) ;; + *) + echo "Unsupported browser framework: $framework" >&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" \ @@ -40,29 +58,30 @@ INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" ./scripts/generate-integration-viceroy-co GENERATED_VICEROY_CONFIG_PATH="$REPO_ROOT/target/integration-test-artifacts/configs/viceroy.toml" # --- Build Docker images --- -echo "==> Building WordPress test container..." -docker build -t test-wordpress:latest \ - crates/trusted-server-integration-tests/fixtures/frameworks/wordpress/ - -echo "==> Building Next.js test container..." -docker build \ - --build-arg NODE_VERSION="$NODE_VERSION" \ - -t test-nextjs:latest \ - crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/ +for framework in "${FRAMEWORKS[@]}"; do + if [ "$framework" = "wordpress" ]; then + echo "==> Building WordPress test container..." + docker build -t test-wordpress:latest \ + crates/trusted-server-integration-tests/fixtures/frameworks/wordpress/ + else + echo "==> Building Next.js test container..." + docker build \ + --build-arg NODE_VERSION="$NODE_VERSION" \ + -t test-nextjs:latest \ + crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/ + fi +done # --- Install Playwright --- echo "==> Installing Playwright dependencies..." -cd "$REPO_ROOT/$BROWSER_DIR" -npm ci -npx playwright install chromium +npm --prefix "$BROWSER_DIR" ci +npm --prefix "$BROWSER_DIR" exec -- playwright install chromium # --- Build browser-side Trusted Server and external Prebid fixtures --- echo "==> Building TSJS browser fixtures..." -cd "$REPO_ROOT/$TSJS_LIB_DIR" -npm ci -npm run build -npm run build:prebid-external -cd "$REPO_ROOT/$BROWSER_DIR" +npm --prefix "$TSJS_LIB_DIR" ci +npm --prefix "$TSJS_LIB_DIR" run build +npm --prefix "$TSJS_LIB_DIR" run build:prebid-external # --- Export env vars for global-setup.ts --- export WASM_BINARY_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm" @@ -80,15 +99,17 @@ stop_matching_containers() { } cleanup() { - stop_matching_containers test-nextjs:latest - stop_matching_containers test-wordpress:latest + for framework in "${FRAMEWORKS[@]}"; do + stop_matching_containers "test-$framework:latest" + done } trap cleanup EXIT # --- Run tests for each framework --- -for framework in nextjs wordpress; do +for framework in "${FRAMEWORKS[@]}"; do echo "==> Running Playwright tests for $framework..." - TEST_FRAMEWORK="$framework" npx playwright test "$@" + TEST_FRAMEWORK="$framework" npm --prefix "$BROWSER_DIR" exec -- \ + playwright test --config "$BROWSER_DIR/playwright.config.ts" "$@" done echo "==> All browser tests passed." From a526383eeff1aad1c11baf2c15ec7c1f8a4905cd Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:56:46 -0700 Subject: [PATCH 554/844] Attach Ubuntu baseline evidence --- .../performance/aps-tsjs-prechange.json | 90 ++++++++++++++----- 1 file changed, 68 insertions(+), 22 deletions(-) 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 77e672aa7..12f8df903 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 @@ -3,14 +3,14 @@ "mode": "baseline", "source": { "ref": "spec/aps-tsjs-resilience-design", - "sha": "1b9753efa01a7985ae8ea804b1997a3cf3b3a7b4" + "sha": "88f1432e33f310a202177feb656eba21ff0173de" }, "environment": { "node": "v24.12.0", "npm": "11.6.2", "typescript": "5.9.3", "chromium": "145.0.7632.6", - "ciMachineClass": "local:darwin-arm64", + "ciMachineClass": "github-hosted:ubuntu-24.04", "fixture": "tsjs-core-placeholder-v1" }, "sampling": { @@ -20,14 +20,21 @@ }, "bundles": { "minimal": { - "files": ["tsjs-core.js"], + "files": [ + "tsjs-core.js" + ], "rawBytes": 23317, "gzipBytes": 8687, "brotliBytes": 7686, "sha256": "1e027cfb238cb6eed090b7addcdba5042059737cb319fbdef1b50e286689b851" }, "reference": { - "files": ["tsjs-core.js", "tsjs-creative.js", "tsjs-gpt.js", "tsjs-prebid.js"], + "files": [ + "tsjs-core.js", + "tsjs-creative.js", + "tsjs-gpt.js", + "tsjs-prebid.js" + ], "rawBytes": 107265, "gzipBytes": 33428, "brotliBytes": 25236, @@ -58,29 +65,68 @@ "performance": { "bootToFirstDisplayMs": { "samples": [ - 11.300000011920929, 11.400000005960464, 11, 10.599999994039536, 11.599999994039536, - 13.199999988079071, 11.799999982118607, 11.100000023841858, 11, 11.799999982118607, 11.5, - 10.5, 10.800000011920929, 11.899999976158142, 11.199999988079071, 10.600000023841858, - 10.799999982118607, 11.099999994039536, 10.800000011920929, 10.699999988079071, 10.5, - 11.200000017881393, 10.599999994039536, 10.5, 10.800000011920929, 10.5, 10.300000011920929, - 10.199999988079071, 10.800000011920929, 10.700000017881393, 10.299999982118607, - 10.800000011920929, 10.800000011920929, 11.099999994039536, 15.699999988079071, - 10.399999976158142, 10.599999994039536, 10.5, 10.800000011920929, 10.599999994039536, - 10.699999988079071, 10.599999994039536, 11.599999994039536, 11.699999988079071, - 11.599999994039536, 10.5, 10.699999988079071, 10.400000005960464, 11.099999994039536, - 11.700000017881393 + 23, + 24.099999999976717, + 23.20000000001164, + 25.100000000034925, + 26.699999999953434, + 25.20000000001164, + 24.79999999998836, + 22.900000000023283, + 23.79999999998836, + 26.899999999965075, + 26, + 26.300000000046566, + 22.20000000001164, + 25.29999999998836, + 24.100000000034925, + 23.099999999976717, + 25, + 25.70000000001164, + 24.70000000001164, + 23.599999999976717, + 24.199999999953434, + 25.400000000023283, + 26.70000000001164, + 24.20000000001164, + 24.5, + 23.5, + 24.79999999998836, + 23.79999999998836, + 26, + 25.5, + 22.599999999976717, + 24.70000000001164, + 24.79999999998836, + 24.400000000023283, + 25, + 24.899999999965075, + 23.5, + 23.400000000023283, + 24.099999999976717, + 24.79999999998836, + 23.29999999998836, + 24, + 23.5, + 27.20000000001164, + 25.899999999965075, + 23.5, + 24.20000000001164, + 22.5, + 23.599999999976717, + 25 ], - "p90": 11.700000017881393 + "p90": 26 }, "retainedHeapBytes": { - "afterBoot": 1210116, - "afterFirstRender": 1213316, - "afterRefresh": 1213316, - "afterSpaNavigation": 1220364 + "afterBoot": 1208816, + "afterFirstRender": 1212016, + "afterRefresh": 1212016, + "afterSpaNavigation": 1219472 } }, "evidence": { - "evidenceId": null, - "workflowRunId": null + "evidenceId": "aps-tsjs-baseline-88f1432e33f310a202177feb656eba21ff0173de", + "workflowRunId": 31074816129 } } From dfb97ce0ade671ff616d84427c32282ab2880739 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:16:16 -0700 Subject: [PATCH 555/844] Make APS descriptor a cross-language executable contract --- .github/workflows/test.yml | 6 + .../src/auction/formats.rs | 75 ++- .../src/auction/orchestrator.rs | 4 +- .../trusted-server-core/src/auction/types.rs | 302 ++++++++++- .../src/integrations/adserver_mock.rs | 4 +- .../src/integrations/aps.rs | 509 ++++++++++++++--- .../generated/aps_renderer_validator_v1.js | 138 +++++ crates/trusted-server-core/src/openrtb.rs | 4 +- crates/trusted-server-core/src/publisher.rs | 28 +- crates/trusted-server-js/lib/.prettierignore | 3 +- crates/trusted-server-js/lib/package.json | 2 + .../trusted-server-js/lib/src/core/types.ts | 21 +- .../aps/generated/renderer_validator_v1.ts | 141 +++++ .../lib/src/integrations/aps/render.ts | 137 +---- .../test/contract/aps-renderer-es5.test.mjs | 165 ++++++ .../test/fixtures/aps-renderer-v1-corpus.json | 511 ++++++++++++++++++ .../test/fixtures/aps-renderer-v1.schema.json | 88 +++ .../lib/test/integrations/aps/render.test.ts | 230 ++++++++ scripts/generate-aps-renderer-contract.mjs | 315 +++++++++++ 19 files changed, 2427 insertions(+), 256 deletions(-) create mode 100644 crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js create mode 100644 crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts create mode 100644 crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs create mode 100644 crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json create mode 100644 crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json create mode 100644 scripts/generate-aps-renderer-contract.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c9c511af..536335e73 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -274,6 +274,12 @@ jobs: - name: Lint full TSJS package run: npm run lint + - name: Verify generated APS renderer contract + run: npm run check:aps-contract + + - name: Run embedded APS renderer contract + run: node --test test/contract/aps-renderer-es5.test.mjs + - name: Verify rc/july adoption manifest run: node --test test/contract/rc-july-adoption.test.mjs diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 571d9d484..225810992 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -370,29 +370,25 @@ pub(crate) fn convert_to_openrtb_response_with_report( let width = to_openrtb_i32(bid.width, "width", &bid_context); let height = to_openrtb_i32(bid.height, "height", &bid_context); - // Ordinary markup goes through the configured creative processing: - // sanitization is opt-in, rewriting is on by default, and with both - // disabled the creative ships exactly as the bidder returned it. A typed - // renderer is serialized separately and never enters that pipeline. - let serialize_renderer = |renderer: &BidRenderer| { - (BidExt { - trusted_server: BidTrustedServerExt { renderer }, - }) - .to_ext() - }; - let (adm, ext) = if let Some(raw_creative) = bid + let creative = bid .creative .as_deref() - .filter(|creative| !creative.trim().is_empty()) - { - if bid.renderer.is_some() { - log::warn!( - "Auction {}: winning bid for slot '{}' from '{}' has both creative markup and a renderer; using creative markup when it remains renderable", - auction_request.id, - slot_id, - bid.bidder - ); - } + .filter(|creative| !creative.trim().is_empty()); + if creative.is_some() && bid.renderer.is_some() { + log::warn!( + "Auction {}: skipping winning bid for slot '{}' from '{}' because it has multiple render sources", + auction_request.id, + slot_id, + bid.bidder + ); + delivery.record_drop("multiple_render_sources"); + continue; + } + + // Ordinary markup remains on the mandatory sanitize/rewrite path. A + // typed render source is serialized separately and never enters the + // HTML sanitizer. + let (adm, ext) = if let Some(raw_creative) = creative { let processed = creative::process_auction_creative(settings, raw_creative); log::debug!( @@ -557,7 +553,7 @@ mod tests { }; use crate::auction::routing::route_auction; use crate::auction::types::{ - ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderer, BidStatus, + ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderSourceV1, BidStatus, }; use crate::openrtb::{Eid, Uid}; use crate::platform::test_support::noop_services; @@ -1520,7 +1516,7 @@ mod tests { renderer.creative = Some("".to_string()); renderer.bid_id = Some("upstream-renderer-bid".to_string()); renderer.creative_id = None; - renderer.renderer = Some(BidRenderer::Aps(ApsRendererV1 { + renderer.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "upstream-renderer-bid".to_string(), @@ -1614,11 +1610,12 @@ mod tests { } #[test] - fn convert_to_openrtb_response_prefers_creative_when_both_render_sources_exist() { - let settings = make_settings(); + fn convert_to_openrtb_response_rejects_multiple_render_sources() { + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let mut bid = make_bid("div-gpt-top", "aps", Some(2.75)); - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { + bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "fictional-bid".to_string(), @@ -1631,21 +1628,21 @@ mod tests { })); let result = make_result(bid); - let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) - .expect("should prefer ordinary creative markup"); - let json = response_json(response); - let bid = &json["seatbid"][0]["bid"][0]; - - // Rewriting is on by default and a body-less fragment still receives the - // creative runtime, so the markup is carried rather than returned verbatim. - let adm = bid["adm"].as_str().expect("should serialize adm"); + let conversion = + convert_to_openrtb_response_with_report(&result, &settings, &auction_request, false) + .expect("should reject an ambiguous render source"); + let json = response_json(conversion.response); assert!( - adm.contains("
Ad
"), - "should carry the creative markup: {adm}" + json["seatbid"].as_array().is_none_or(Vec::is_empty), + "should not serialize an ambiguous winner" ); + assert_eq!(conversion.delivery.dropped_winner_count, 1); assert!( - bid.get("ext").is_none(), - "should omit renderer extension when creative markup wins precedence" + conversion + .delivery + .dropped_winner_reasons + .contains_key("multiple_render_sources"), + "should report the exact ambiguous-source reason" ); } @@ -1658,7 +1655,7 @@ mod tests { bid.bid_id = Some("fictional-bid".to_string()); bid.ad_id = Some("fictional-ad".to_string()); bid.creative_id = Some("fictional-creative".to_string()); - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { + bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "fictional-bid".to_string(), diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 763c3bef5..c373663d7 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -2541,7 +2541,7 @@ 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, BidRenderSourceV1, BidStatus, MediaType, PublisherInfo, UserInfo, }; use crate::error::TrustedServerError; use crate::integrations::adserver_mock::{AdServerMockConfig, AdServerMockProvider}; @@ -3499,7 +3499,7 @@ mod tests { fn auction_bid(bidder: &str, price: f64) -> Bid { let renderer = (bidder == "aps").then(|| { - BidRenderer::Aps(ApsRendererV1 { + BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "aps-selected-bid".to_string(), diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 406915706..af13c2238 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -1,9 +1,11 @@ //! Core types for auction requests and responses. +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use edgezero_core::body::Body as EdgeBody; use http::Request; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; +use url::Url; use crate::auction::context::ContextValue; use crate::geo::GeoInfo; @@ -194,7 +196,7 @@ pub enum ApsTagType { /// Version 1 APS renderer descriptor shared with browser clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ApsRendererV1 { /// Renderer contract version. pub version: u8, @@ -217,24 +219,308 @@ pub struct ApsRendererV1 { pub height: u32, } -/// Typed browser renderer capability carried by a bid. +/// Version 1 inline ADM render source shared with browser clients. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AdmRenderSourceV1 { + /// Render-source contract version. + pub version: u8, + /// Exact creative markup. + pub adm: String, + /// Creative width. + pub width: u32, + /// Creative height. + pub height: u32, +} + +/// Version 1 trusted cache render source shared with browser clients. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CacheRenderSourceV1 { + /// Render-source contract version. + pub version: u8, + /// Exact validated PBS Cache UUID. + pub cache_id: String, + /// Server-constructed trusted cache fetch URL. + pub fetch_url: String, + /// Creative width. + pub width: u32, + /// Creative height. + pub height: u32, +} + +/// Typed browser render source carried by a bid. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] -pub enum BidRenderer { +pub enum BidRenderSourceV1 { /// APS renderer version 1. Aps(ApsRendererV1), + /// Inline ADM version 1. + Adm(AdmRenderSourceV1), + /// Trusted cache fetch version 1. + Cache(CacheRenderSourceV1), } -impl BidRenderer { +impl BidRenderSourceV1 { /// Return the APS renderer descriptor when this is an APS renderer. #[must_use] pub fn as_aps(&self) -> Option<&ApsRendererV1> { match self { Self::Aps(renderer) => Some(renderer), + Self::Adm(_) | Self::Cache(_) => None, } } } +/// Smallest accepted renderer dimension in CSS pixels. +pub const RENDER_DIMENSION_MIN: u64 = 1; +/// Largest accepted renderer dimension in CSS pixels. +pub const RENDER_DIMENSION_MAX: u64 = 4096; + +const MAX_APS_ACCOUNT_ID_BYTES: usize = 1024; +const MAX_APS_BID_ID_BYTES: usize = 64; +const MAX_APS_CREATIVE_ID_BYTES: usize = 1024; +const MAX_APS_CREATIVE_URL_BYTES: usize = 4096; +const MAX_APS_RENDER_ENVELOPE_BYTES: usize = 256 * 1024; +const MAX_APS_RENDER_ENVELOPE_BASE64_BYTES: usize = 4 * MAX_APS_RENDER_ENVELOPE_BYTES.div_ceil(3); + +/// Cross-language APS descriptor validation result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApsRendererValidationResult { + /// Descriptor and decoded envelope are valid and agree. + Accepted, + /// Descriptor or decoded envelope is malformed. + DescriptorInvalid, + /// A dimension has the wrong type or is nonfinite, fractional, zero, or negative. + InvalidDimensions, + /// An otherwise integral positive dimension is outside the supported range. + DimensionsOutOfRange, +} + +impl ApsRendererValidationResult { + /// Return the exact browser failure/result literal. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::DescriptorInvalid => "descriptor_invalid", + Self::InvalidDimensions => "invalid_dimensions", + Self::DimensionsOutOfRange => "dimensions_out_of_range", + } + } +} + +fn has_exact_json_keys(value: &serde_json::Value, expected: &[&str]) -> bool { + value.as_object().is_some_and(|object| { + object.len() == expected.len() && expected.iter().all(|key| object.contains_key(*key)) + }) +} + +fn classify_render_dimension(value: &serde_json::Value) -> ApsRendererValidationResult { + let Some(number) = value.as_f64() else { + return ApsRendererValidationResult::InvalidDimensions; + }; + if !number.is_finite() || number.fract() != 0.0 || number <= 0.0 { + return ApsRendererValidationResult::InvalidDimensions; + } + if number < RENDER_DIMENSION_MIN as f64 || number > RENDER_DIMENSION_MAX as f64 { + return ApsRendererValidationResult::DimensionsOutOfRange; + } + ApsRendererValidationResult::Accepted +} + +fn valid_aps_creative_url(value: &str, publisher_origin: &str) -> bool { + if value.len() > MAX_APS_CREATIVE_URL_BYTES { + return false; + } + let Ok(url) = Url::parse(value) else { + return false; + }; + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.origin().ascii_serialization() != publisher_origin +} + +/// Classify a raw APS renderer descriptor using the cross-language version-1 contract. +#[must_use] +pub fn classify_aps_renderer_v1( + value: &serde_json::Value, + publisher_origin: &str, +) -> ApsRendererValidationResult { + const REQUIRED_KEYS: &[&str] = &[ + "aaxResponse", + "accountId", + "bidId", + "creativeUrl", + "height", + "tagType", + "type", + "version", + "width", + ]; + const KEYS_WITH_CREATIVE_ID: &[&str] = &[ + "aaxResponse", + "accountId", + "bidId", + "creativeId", + "creativeUrl", + "height", + "tagType", + "type", + "version", + "width", + ]; + + if !has_exact_json_keys(value, REQUIRED_KEYS) + && !has_exact_json_keys(value, KEYS_WITH_CREATIVE_ID) + { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let Some(descriptor) = value.as_object() else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if descriptor.get("type").and_then(serde_json::Value::as_str) != Some("aps") + || descriptor + .get("version") + .and_then(serde_json::Value::as_f64) + .is_none_or(|version| version != 1.0) + { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let Some(account_id) = descriptor + .get("accountId") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + let Some(bid_id) = descriptor.get("bidId").and_then(serde_json::Value::as_str) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if account_id.is_empty() + || account_id.len() > MAX_APS_ACCOUNT_ID_BYTES + || bid_id.is_empty() + || bid_id.len() > MAX_APS_BID_ID_BYTES + || bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) + { + return ApsRendererValidationResult::DescriptorInvalid; + } + if let Some(creative_id) = descriptor.get("creativeId") { + let Some(creative_id) = creative_id.as_str() else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if creative_id.is_empty() || creative_id.len() > MAX_APS_CREATIVE_ID_BYTES { + return ApsRendererValidationResult::DescriptorInvalid; + } + } + let Some(tag_type) = descriptor + .get("tagType") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if tag_type != "iframe" && tag_type != "script" { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let width_result = + classify_render_dimension(descriptor.get("width").unwrap_or(&serde_json::Value::Null)); + if width_result != ApsRendererValidationResult::Accepted { + return width_result; + } + let height_result = + classify_render_dimension(descriptor.get("height").unwrap_or(&serde_json::Value::Null)); + if height_result != ApsRendererValidationResult::Accepted { + return height_result; + } + + let Some(creative_url) = descriptor + .get("creativeUrl") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + let Some(aax_response) = descriptor + .get("aaxResponse") + .and_then(serde_json::Value::as_str) + else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if !valid_aps_creative_url(creative_url, publisher_origin) + || aax_response.is_empty() + || aax_response.len() > MAX_APS_RENDER_ENVELOPE_BASE64_BYTES + { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Ok(decoded_bytes) = BASE64_STANDARD.decode(aax_response) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if decoded_bytes.len() > MAX_APS_RENDER_ENVELOPE_BYTES + || BASE64_STANDARD.encode(&decoded_bytes) != aax_response + { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Ok(decoded_utf8) = core::str::from_utf8(&decoded_bytes) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + let Ok(decoded) = serde_json::from_str::(decoded_utf8) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if !has_exact_json_keys(&decoded, &["seatbid"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Some(seats) = decoded.get("seatbid").and_then(serde_json::Value::as_array) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if seats.len() != 1 || !has_exact_json_keys(&seats[0], &["bid"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + let Some(bids) = seats[0].get("bid").and_then(serde_json::Value::as_array) else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if bids.len() != 1 || !has_exact_json_keys(&bids[0], &["ext", "h", "id", "price", "w"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + let bid = &bids[0]; + let Some(ext) = bid.get("ext") else { + return ApsRendererValidationResult::DescriptorInvalid; + }; + if !has_exact_json_keys(ext, &["creativeurl", "tagtype"]) { + return ApsRendererValidationResult::DescriptorInvalid; + } + + let bid_width_result = + classify_render_dimension(bid.get("w").unwrap_or(&serde_json::Value::Null)); + if bid_width_result != ApsRendererValidationResult::Accepted { + return bid_width_result; + } + let bid_height_result = + classify_render_dimension(bid.get("h").unwrap_or(&serde_json::Value::Null)); + if bid_height_result != ApsRendererValidationResult::Accepted { + return bid_height_result; + } + let price_is_valid = bid + .get("price") + .and_then(serde_json::Value::as_f64) + .is_some_and(|price| price.is_finite() && price >= 0.0); + if bid.get("id").and_then(serde_json::Value::as_str) != Some(bid_id) + || bid.get("w").and_then(serde_json::Value::as_f64) + != descriptor.get("width").and_then(serde_json::Value::as_f64) + || bid.get("h").and_then(serde_json::Value::as_f64) + != descriptor.get("height").and_then(serde_json::Value::as_f64) + || ext.get("creativeurl").and_then(serde_json::Value::as_str) != Some(creative_url) + || ext.get("tagtype").and_then(serde_json::Value::as_str) != Some(tag_type) + || !price_is_valid + { + return ApsRendererValidationResult::DescriptorInvalid; + } + + ApsRendererValidationResult::Accepted +} + /// Individual bid from a provider. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Bid { @@ -246,7 +532,7 @@ pub struct Bid { pub currency: String, /// Creative markup (HTML/VAST). /// - /// `None` when the bid uses a typed [`BidRenderer`] instead. + /// `None` when the bid uses a typed [`BidRenderSourceV1`] instead. pub creative: Option, /// Advertiser domain pub adomain: Option>, @@ -277,7 +563,7 @@ pub struct Bid { pub creative_id: Option, /// Typed browser renderer capability. #[serde(skip_serializing_if = "Option::is_none")] - pub renderer: Option, + pub renderer: Option, /// Prebid Cache UUID for this bid. /// /// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response. @@ -601,7 +887,7 @@ mod tests { #[test] fn aps_renderer_serializes_to_versioned_camel_case_contract() { - let renderer = BidRenderer::Aps(ApsRendererV1 { + let renderer = BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account-id".to_string(), bid_id: "fictional-bid-id".to_string(), @@ -635,7 +921,7 @@ mod tests { #[test] fn aps_renderer_omits_absent_creative_id() { - let renderer = BidRenderer::Aps(ApsRendererV1 { + let renderer = BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account-id".to_string(), bid_id: "fictional-bid-id".to_string(), diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index b9e628111..3a15e6166 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -653,7 +653,7 @@ mod tests { bid_id: Some(bid_id.to_string()), ad_id: None, creative_id: Some(format!("creative-{bid_id}")), - renderer: Some(BidRenderer::Aps(ApsRendererV1 { + renderer: Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: bid_id.to_string(), @@ -853,7 +853,7 @@ mod tests { bid_id: Some("source-bid-id".to_string()), ad_id: Some("bid-impression-id".to_string()), creative_id: Some("source-creative-id".to_string()), - renderer: Some(BidRenderer::Aps(ApsRendererV1 { + renderer: Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "source-bid-id".to_string(), diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 6bffddf16..34fc54e0f 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -27,7 +27,9 @@ use crate::auction::routing::ProviderAuctionInput; #[cfg(test)] use crate::auction::types::{AdSlot, AuctionContext, AuctionRequest}; use crate::auction::types::{ - ApsRendererV1, ApsTagType, AuctionResponse, Bid, BidRenderer, MediaType, + AdSlot, ApsRendererV1, ApsRendererValidationResult, ApsTagType, AuctionContext, AuctionRequest, + AuctionResponse, Bid, BidRenderSourceV1, MediaType, RENDER_DIMENSION_MAX, + classify_aps_renderer_v1, }; use crate::error::TrustedServerError; use crate::integrations::{ @@ -68,51 +70,25 @@ const MAX_PAGE_URL_BYTES: usize = 8192; const MAX_RENDER_ENVELOPE_BYTES: usize = 256 * 1024; const APS_RENDERER_CSP: &str = "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; script-src 'unsafe-inline' https:; connect-src https:; frame-src https:; img-src https: data:; media-src https: blob:; style-src 'unsafe-inline' https:; font-src https: data:;"; -const APS_RENDERER_DOCUMENT: &str = r#" +const APS_RENDERER_DOCUMENT: &str = concat!( + r#" -"#; +"# +); /// Rendering owner for selected APS bids. #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] @@ -1279,7 +1256,7 @@ impl ApsAuctionProvider { }) } - fn valid_creative_url(&self, value: &str, publisher_domain: &str) -> bool { + fn valid_creative_url(&self, value: &str, publisher_origin: &str) -> bool { if value.len() > MAX_CREATIVE_URL_BYTES { return false; } @@ -1287,14 +1264,17 @@ impl ApsAuctionProvider { return false; }; parsed.scheme() == "https" - && parsed - .host_str() - .is_some_and(|host| !host.eq_ignore_ascii_case(publisher_domain)) + && parsed.host_str().is_some() && parsed.username().is_empty() && parsed.password().is_none() + && parsed.origin().ascii_serialization() != publisher_origin } - fn build_renderer(&self, input: ApsRendererInput<'_>) -> Option { + fn build_renderer( + &self, + input: ApsRendererInput<'_>, + publisher_origin: &str, + ) -> Option { let tag_type_value = match input.tag_type { ApsTagType::Iframe => "iframe", ApsTagType::Script => "script", @@ -1317,7 +1297,7 @@ impl ApsAuctionProvider { if serialized.len() > MAX_RENDER_ENVELOPE_BYTES { return None; } - Some(BidRenderer::Aps(ApsRendererV1 { + let renderer = BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: self.config.account_id.clone(), bid_id: input.bid_id.to_string(), @@ -1327,7 +1307,11 @@ impl ApsAuctionProvider { aax_response: BASE64_STANDARD.encode(serialized), width: input.width, height: input.height, - })) + }); + let value = serde_json::to_value(&renderer).ok()?; + (classify_aps_renderer_v1(&value, publisher_origin) + == ApsRendererValidationResult::Accepted) + .then_some(renderer) } fn increment_reason(reasons: &mut BTreeMap, reason: &'static str) { @@ -1338,13 +1322,18 @@ impl ApsAuctionProvider { &self, value: &Json, slots: &HashMap<&str, &AdSlot>, - publisher_domain: &str, + publisher_origin: &str, ) -> Result { let bid_id = value .get("id") .and_then(Json::as_str) - .filter(|value| !value.is_empty()) .ok_or("missing_render_source")?; + if bid_id.is_empty() + || bid_id.len() > 64 + || bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) + { + return Err("invalid_bid_id"); + } let slot_id = value .get("impid") .and_then(Json::as_str) @@ -1361,16 +1350,19 @@ impl ApsAuctionProvider { { return Err("unsupported_media_type"); } - let width = value - .get("w") - .and_then(Json::as_u64) - .and_then(|value| u32::try_from(value).ok()) - .ok_or("invalid_dimensions")?; - let height = value - .get("h") - .and_then(Json::as_u64) - .and_then(|value| u32::try_from(value).ok()) - .ok_or("invalid_dimensions")?; + let parse_dimension = |field: &str| { + let number = value + .get(field) + .and_then(Json::as_f64) + .filter(|number| number.is_finite() && number.fract() == 0.0 && *number > 0.0) + .ok_or("invalid_dimensions")?; + if number > RENDER_DIMENSION_MAX as f64 { + return Err("dimensions_out_of_range"); + } + u32::try_from(number as u64).map_err(|_| "dimensions_out_of_range") + }; + let width = parse_dimension("w")?; + let height = parse_dimension("h")?; if !Self::compatible_dimensions(slot, width, height) { return Err("invalid_dimensions"); } @@ -1382,7 +1374,7 @@ impl ApsAuctionProvider { .get("creativeurl") .and_then(Json::as_str) .ok_or("missing_render_source")?; - if !self.valid_creative_url(creative_url, publisher_domain) { + if !self.valid_creative_url(creative_url, publisher_origin) { return Err("invalid_creative_url"); } let tag_type = match ext.get("tagtype").and_then(Json::as_str) { @@ -1403,15 +1395,18 @@ impl ApsAuctionProvider { return Err("creative_id_too_large"); } let renderer = self - .build_renderer(ApsRendererInput { - bid_id, - creative_id: creative_id.clone(), - tag_type, - creative_url, - price, - width, - height, - }) + .build_renderer( + ApsRendererInput { + bid_id, + creative_id: creative_id.clone(), + tag_type, + creative_url, + price, + width, + height, + }, + publisher_origin, + ) .ok_or("render_payload_too_large")?; let adomain = value .get("adomain") @@ -1485,6 +1480,14 @@ impl ApsAuctionProvider { let mut selected: HashMap = HashMap::new(); let mut dropped = 0_u64; + let publisher_origin = request + .publisher + .page_url + .as_deref() + .and_then(|page_url| Url::parse(page_url).ok()) + .map(|page_url| page_url.origin().ascii_serialization()) + .unwrap_or_else(|| format!("https://{}", request.publisher.domain)); + for seatbid in seatbids.into_iter().flatten() { let Some(bids) = seatbid.get("bid").and_then(Json::as_array) else { dropped += 1; @@ -1492,7 +1495,7 @@ impl ApsAuctionProvider { continue; }; for value in bids { - match self.parse_bid(value, &slots, &request.publisher.domain) { + match self.parse_bid(value, &slots, &publisher_origin) { Ok(candidate) => { let replace = selected.get(&candidate.slot_id).is_none_or(|current| { let candidate_price = candidate.price.unwrap_or_default(); @@ -2037,6 +2040,380 @@ mod tests { }) } + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct RendererCorpus { + publisher_origin: String, + base_descriptor: Json, + vectors: Vec, + } + + #[derive(serde::Deserialize)] + struct RendererCorpusVector { + id: String, + expected: String, + operation: Json, + } + + fn corpus_value<'a>(operation: &'a Json, field: &str) -> &'a Json { + operation + .get(field) + .unwrap_or_else(|| panic!("should include corpus operation field {field}")) + } + + fn corpus_string<'a>(operation: &'a Json, field: &str) -> &'a str { + corpus_value(operation, field) + .as_str() + .unwrap_or_else(|| panic!("corpus operation field {field} should be a string")) + } + + fn corpus_usize(operation: &Json, field: &str) -> usize { + corpus_value(operation, field) + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or_else(|| panic!("corpus operation field {field} should be a usize")) + } + + fn set_json_path(root: &mut Json, path: &[Json], value: Json) { + let (segment, tail) = path + .split_first() + .expect("corpus JSON path should not be empty"); + if tail.is_empty() { + if let Some(field) = segment.as_str() { + root.as_object_mut() + .expect("corpus string path should address an object") + .insert(field.to_string(), value); + } else { + let index = segment + .as_u64() + .and_then(|index| usize::try_from(index).ok()) + .expect("corpus numeric path should be a usize"); + let slot = root + .as_array_mut() + .and_then(|array| array.get_mut(index)) + .expect("corpus numeric path should address an array element"); + *slot = value; + } + return; + } + + let child = if let Some(field) = segment.as_str() { + root.as_object_mut() + .and_then(|object| object.get_mut(field)) + .expect("corpus string path should address an object field") + } else { + let index = segment + .as_u64() + .and_then(|index| usize::try_from(index).ok()) + .expect("corpus numeric path should be a usize"); + root.as_array_mut() + .and_then(|array| array.get_mut(index)) + .expect("corpus numeric path should address an array element") + }; + set_json_path(child, tail, value); + } + + fn delete_json_path(root: &mut Json, path: &[Json]) { + let (segment, tail) = path + .split_first() + .expect("corpus JSON path should not be empty"); + if tail.is_empty() { + let field = segment + .as_str() + .expect("corpus delete path should end in an object field"); + root.as_object_mut() + .expect("corpus delete path should address an object") + .remove(field); + return; + } + + let child = if let Some(field) = segment.as_str() { + root.as_object_mut() + .and_then(|object| object.get_mut(field)) + .expect("corpus string path should address an object field") + } else { + let index = segment + .as_u64() + .and_then(|index| usize::try_from(index).ok()) + .expect("corpus numeric path should be a usize"); + root.as_array_mut() + .and_then(|array| array.get_mut(index)) + .expect("corpus numeric path should address an array element") + }; + delete_json_path(child, tail); + } + + fn descriptor_field(descriptor: &mut Json, field: &str, value: Json) { + descriptor + .as_object_mut() + .expect("corpus descriptor should be an object") + .insert(field.to_string(), value); + } + + fn materialize_renderer_corpus_vector( + corpus: &RendererCorpus, + vector: &RendererCorpusVector, + ) -> Json { + let mut descriptor = corpus.base_descriptor.clone(); + let mut envelope: Json = serde_json::from_str(include_str!( + "../../../trusted-server-js/lib/test/fixtures/aps-renderer-v1.json" + )) + .expect("should parse shared APS renderer fixture"); + let operation = &vector.operation; + let kind = corpus_string(operation, "kind"); + let mut encoded_envelope = None; + + match kind { + "none" => {} + "descriptor-delete" => { + descriptor + .as_object_mut() + .expect("corpus descriptor should be an object") + .remove(corpus_string(operation, "field")); + } + "descriptor-set" => descriptor_field( + &mut descriptor, + corpus_string(operation, "field"), + corpus_value(operation, "value").clone(), + ), + "descriptor-repeat" => { + let mut repeated = + corpus_string(operation, "unit").repeat(corpus_usize(operation, "count")); + if let Some(suffix) = operation.get("suffix").and_then(Json::as_str) { + repeated.push_str(suffix); + } + descriptor_field( + &mut descriptor, + corpus_string(operation, "field"), + json!(repeated), + ); + } + "bid-id-repeat" => { + let mut repeated = + corpus_string(operation, "unit").repeat(corpus_usize(operation, "count")); + if let Some(suffix) = operation.get("suffix").and_then(Json::as_str) { + repeated.push_str(suffix); + } + descriptor_field(&mut descriptor, "bidId", json!(repeated)); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!("id"), + ], + json!(repeated), + ); + } + "dimension" => { + let field = corpus_string(operation, "field"); + let envelope_field = match field { + "width" => "w", + "height" => "h", + _ => panic!("corpus dimension field should be width or height"), + }; + let value = corpus_value(operation, "value").clone(); + descriptor_field(&mut descriptor, field, value.clone()); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!(envelope_field), + ], + value, + ); + } + "dimensions" => { + let width = corpus_value(operation, "width").clone(); + let height = corpus_value(operation, "height").clone(); + descriptor_field(&mut descriptor, "width", width.clone()); + descriptor_field(&mut descriptor, "height", height.clone()); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!("w"), + ], + width, + ); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!("h"), + ], + height, + ); + } + "creative-url" => { + let value = corpus_string(operation, "value").to_string(); + descriptor_field(&mut descriptor, "creativeUrl", json!(value)); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!("ext"), + json!("creativeurl"), + ], + json!(value), + ); + } + "creative-url-bytes" => { + let prefix = "https://creative.example/"; + let bytes = corpus_usize(operation, "bytes"); + let value = format!( + "{prefix}{}", + "a".repeat( + bytes + .checked_sub(prefix.len()) + .expect("corpus URL size should include its prefix") + ) + ); + descriptor_field(&mut descriptor, "creativeUrl", json!(value)); + set_json_path( + &mut envelope, + &[ + json!("seatbid"), + json!(0), + json!("bid"), + json!(0), + json!("ext"), + json!("creativeurl"), + ], + json!(value), + ); + } + "aax-literal" => { + encoded_envelope = Some(corpus_string(operation, "value").to_string()); + } + "aax-bytes" => { + let bytes: Vec = corpus_value(operation, "values") + .as_array() + .expect("corpus byte vector should be an array") + .iter() + .map(|value| { + value + .as_u64() + .and_then(|value| u8::try_from(value).ok()) + .expect("corpus byte vector should contain u8 values") + }) + .collect(); + encoded_envelope = Some(BASE64_STANDARD.encode(bytes)); + } + "aax-raw-json" => { + encoded_envelope = + Some(BASE64_STANDARD.encode(corpus_string(operation, "value").as_bytes())); + } + "aax-decoded-bytes" => { + let mut serialized = + serde_json::to_string(&envelope).expect("should serialize corpus envelope"); + let target = corpus_usize(operation, "bytes"); + serialized.push_str( + &" ".repeat( + target + .checked_sub(serialized.len()) + .expect("corpus decoded size should exceed fixture size"), + ), + ); + encoded_envelope = Some(BASE64_STANDARD.encode(serialized.as_bytes())); + } + "aax-raw-price" => { + let serialized = + serde_json::to_string(&envelope).expect("should serialize corpus envelope"); + let replacement = format!("\"price\":{}", corpus_string(operation, "value")); + let raw = serialized.replacen("\"price\":1.23", &replacement, 1); + assert_ne!(raw, serialized, "should replace the corpus fixture price"); + encoded_envelope = Some(BASE64_STANDARD.encode(raw.as_bytes())); + } + "envelope-set" => { + let path = corpus_value(operation, "path") + .as_array() + .expect("corpus path should be an array"); + set_json_path( + &mut envelope, + path, + corpus_value(operation, "value").clone(), + ); + } + "envelope-delete" => { + let path = corpus_value(operation, "path") + .as_array() + .expect("corpus path should be an array"); + delete_json_path(&mut envelope, path); + } + "duplicate-seat" => { + let seats = envelope + .get_mut("seatbid") + .and_then(Json::as_array_mut) + .expect("corpus fixture should contain a seat array"); + let first = seats + .first() + .cloned() + .expect("corpus fixture should contain one seat"); + seats.push(first); + } + "duplicate-bid" => { + let bids = envelope + .get_mut("seatbid") + .and_then(Json::as_array_mut) + .and_then(|seats| seats.first_mut()) + .and_then(|seat| seat.get_mut("bid")) + .and_then(Json::as_array_mut) + .expect("corpus fixture should contain a bid array"); + let first = bids + .first() + .cloned() + .expect("corpus fixture should contain one bid"); + bids.push(first); + } + _ => panic!("unknown APS renderer corpus operation: {kind}"), + } + + let encoded = encoded_envelope.unwrap_or_else(|| { + BASE64_STANDARD.encode( + serde_json::to_vec(&envelope).expect("should serialize corpus renderer envelope"), + ) + }); + descriptor_field(&mut descriptor, "aaxResponse", json!(encoded)); + descriptor + } + + #[test] + fn aps_renderer_matches_shared_cross_language_contract_corpus() { + let corpus: RendererCorpus = serde_json::from_str(include_str!( + "../../../trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json" + )) + .expect("should parse shared APS renderer corpus"); + assert_eq!( + corpus.publisher_origin, "https://publisher.example", + "should pin the corpus publisher origin" + ); + + for vector in &corpus.vectors { + let descriptor = materialize_renderer_corpus_vector(&corpus, vector); + let actual = classify_aps_renderer_v1(&descriptor, &corpus.publisher_origin).as_str(); + assert_eq!( + actual, vector.expected, + "should match APS renderer corpus vector {}", + vector.id + ); + } + } + fn parse_with_context( provider: &ApsAuctionProvider, response: PlatformResponse, @@ -3003,6 +3380,8 @@ mod tests { let mut uppercase_publisher = request(); uppercase_publisher.publisher.domain = "Creative.Example".to_string(); + uppercase_publisher.publisher.page_url = + Some("https://Creative.Example/article".to_string()); let response = provider.parse_aps_response( &json!({"seatbid": [{"bid": [bid("same-origin", 1.0, "iframe")]}]}), 12, 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 new file mode 100644 index 000000000..a81408244 --- /dev/null +++ b/crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js @@ -0,0 +1,138 @@ +// @generated by scripts/generate-aps-renderer-contract.mjs +// schema-sha256: e7ed370e6ccbeb30660b63ae0837dbde6ddd56e81f606a544d37b9d0b99f5d1d +// 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"]; +var ENVELOPE_ROOT_KEYS = ["seatbid"]; +var ENVELOPE_SEAT_KEYS = ["bid"]; +var ENVELOPE_BID_KEYS = ["ext","h","id","price","w"]; +var ENVELOPE_EXT_KEYS = ["creativeurl","tagtype"]; +var MAX_ACCOUNT_ID_BYTES = 1024; +var MAX_BID_ID_BYTES = 64; +var MAX_CREATIVE_ID_BYTES = 1024; +var MAX_CREATIVE_URL_BYTES = 4096; +var MAX_RENDER_ENVELOPE_BYTES = 262144; +var MAX_RENDER_ENVELOPE_BASE64_BYTES = 349528; +var STANDARD_BASE64_PATTERN = "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"; +var RENDER_DIMENSION_MIN = 1; +var RENDER_DIMENSION_MAX = 4096; +function apsExactRecord(value, expectedKeys) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + var prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + if (typeof Object.getOwnPropertySymbols === 'function' && Object.getOwnPropertySymbols(value).length !== 0) return false; + var actual = Object.getOwnPropertyNames(value).sort(); + if (actual.length !== expectedKeys.length) return false; + for (var index = 0; index < actual.length; index += 1) { + var propertyName = actual[index]; + if (propertyName === undefined || propertyName !== expectedKeys[index]) return false; + var property = Object.getOwnPropertyDescriptor(value, propertyName); + if (!property || !Object.prototype.hasOwnProperty.call(property, 'value')) return false; + } + return true; +} + +function apsUtf8Length(value) { + return (new TextEncoder()).encode(value).length; +} + +function apsHasAsciiControl(value) { + return /[\x00-\x1f\x7f]/.test(value); +} + +function apsDimensionResult(value) { + if (typeof value !== 'number' || !isFinite(value) || Math.floor(value) !== value || value <= 0) { + return 'invalid_dimensions'; + } + if (value < RENDER_DIMENSION_MIN || value > RENDER_DIMENSION_MAX) { + return 'dimensions_out_of_range'; + } + return 'accepted'; +} + +function apsValidCreativeUrl(value, publisherOrigin) { + try { + var url = new URL(value); + return url.protocol === 'https:' && url.hostname !== '' && url.username === '' && + url.password === '' && url.origin !== publisherOrigin; + } catch (_error) { + return false; + } +} + +function apsDecodeEnvelope(value) { + if (value.length === 0 || value.length > MAX_RENDER_ENVELOPE_BASE64_BYTES || + value.length % 4 !== 0 || !(new RegExp(STANDARD_BASE64_PATTERN)).test(value)) { + return undefined; + } + try { + var binary = atob(value); + if (binary.length > MAX_RENDER_ENVELOPE_BYTES || btoa(binary) !== value) return undefined; + var bytes = new Uint8Array(binary.length); + for (var index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch (_error) { + return undefined; + } +} + +function classifyApsRendererDescriptorV1( + value +) { + var renderer = value; + if (!apsExactRecord(renderer, DESCRIPTOR_KEYS) && + !apsExactRecord(renderer, DESCRIPTOR_KEYS_WITH_CREATIVE_ID)) return 'descriptor_invalid'; + if (renderer.type !== 'aps' || renderer.version !== 1 || + typeof renderer.accountId !== 'string' || renderer.accountId.length === 0 || + apsUtf8Length(renderer.accountId) > MAX_ACCOUNT_ID_BYTES || + typeof renderer.bidId !== 'string' || renderer.bidId.length === 0 || + apsUtf8Length(renderer.bidId) > MAX_BID_ID_BYTES || + apsHasAsciiControl(renderer.bidId)) return 'descriptor_invalid'; + if (Object.prototype.hasOwnProperty.call(renderer, 'creativeId') && + (typeof renderer.creativeId !== 'string' || renderer.creativeId.length === 0 || + apsUtf8Length(renderer.creativeId) > MAX_CREATIVE_ID_BYTES)) return 'descriptor_invalid'; + if (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') return 'descriptor_invalid'; + + var widthResult = apsDimensionResult(renderer.width); + if (widthResult !== 'accepted') return widthResult; + var heightResult = apsDimensionResult(renderer.height); + if (heightResult !== 'accepted') return heightResult; + + if (typeof renderer.creativeUrl !== 'string' || + apsUtf8Length(renderer.creativeUrl) > MAX_CREATIVE_URL_BYTES || + typeof renderer.aaxResponse !== 'string' || + renderer.aaxResponse.length > MAX_RENDER_ENVELOPE_BASE64_BYTES) return 'descriptor_invalid'; + return 'accepted'; +} + +function classifyApsRendererV1( + value, + publisherOrigin +) { + var renderer = value; + var descriptorResult = + classifyApsRendererDescriptorV1(renderer); + if (descriptorResult !== 'accepted') return descriptorResult; + if (!apsValidCreativeUrl(renderer.creativeUrl, publisherOrigin)) return 'descriptor_invalid'; + + var decoded = apsDecodeEnvelope(renderer.aaxResponse); + if (!apsExactRecord(decoded, ENVELOPE_ROOT_KEYS) || !Array.isArray(decoded.seatbid) || + decoded.seatbid.length !== 1) return 'descriptor_invalid'; + var seat = decoded.seatbid[0]; + if (!apsExactRecord(seat, ENVELOPE_SEAT_KEYS) || !Array.isArray(seat.bid) || + seat.bid.length !== 1) return 'descriptor_invalid'; + var bid = seat.bid[0]; + if (!apsExactRecord(bid, ENVELOPE_BID_KEYS) || + !apsExactRecord(bid.ext, ENVELOPE_EXT_KEYS)) return 'descriptor_invalid'; + + var bidWidthResult = apsDimensionResult(bid.w); + if (bidWidthResult !== 'accepted') return bidWidthResult; + var bidHeightResult = apsDimensionResult(bid.h); + if (bidHeightResult !== 'accepted') return bidHeightResult; + if (bid.id !== renderer.bidId || bid.w !== renderer.width || bid.h !== renderer.height || + bid.ext.creativeurl !== renderer.creativeUrl || bid.ext.tagtype !== renderer.tagType || + typeof bid.price !== 'number' || !isFinite(bid.price) || bid.price < 0) { + return 'descriptor_invalid'; + } + return 'accepted'; +} diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 4aded7488..c27a5c988 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::auction::types::{BidRenderer, OrchestratorExt}; +use crate::auction::types::{BidRenderSourceV1, OrchestratorExt}; pub type OpenRtbRequest = trusted_server_openrtb::BidRequest; pub type OpenRtbResponse = trusted_server_openrtb::BidResponse; @@ -180,7 +180,7 @@ impl ToExt for BidExt<'_> {} #[derive(Debug, Serialize)] pub struct BidTrustedServerExt<'a> { - pub renderer: &'a BidRenderer, + pub renderer: &'a BidRenderSourceV1, } #[derive(Debug, Serialize)] diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c31a89001..8be27585e 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -17798,7 +17798,7 @@ mod tests { build_bid_map, build_bids_script, diagnostics_auction_id, html_escape_for_script, write_bids_to_state, }; - use crate::auction::types::{ApsRendererV1, ApsTagType, Bid, BidRenderer, MediaType}; + use crate::auction::types::{ApsRendererV1, ApsTagType, Bid, BidRenderSourceV1, MediaType}; use crate::consent::ConsentContext; use crate::creative_opportunities::{ CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, @@ -18063,13 +18063,23 @@ mod tests { /// Guards the browser-visible token every auction path shares: it must /// be fresh per auction and absent unless diagnostics can consume it. #[test] - fn diagnostics_auction_id_is_fresh_and_gated() { - let mut settings = test_settings(); - assert_eq!( - diagnostics_auction_id(&settings), - None, - "no token should be minted without the diagnostics integration" - ); + 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)]); settings .integrations @@ -18976,7 +18986,7 @@ mod tests { bid.creative = Some("".to_string()); bid.nurl = None; bid.burl = None; - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { + bid.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, account_id: "example-account".to_string(), bid_id: "selected-bid".to_string(), diff --git a/crates/trusted-server-js/lib/.prettierignore b/crates/trusted-server-js/lib/.prettierignore index 72274829b..9f9fd6d92 100644 --- a/crates/trusted-server-js/lib/.prettierignore +++ b/crates/trusted-server-js/lib/.prettierignore @@ -1,4 +1,5 @@ node_modules dist coverage - +src/integrations/aps/generated/renderer_validator_v1.ts +test/fixtures/performance/aps-tsjs-prechange.json diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index e8e732736..770530be2 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -7,6 +7,8 @@ "scripts": { "build": "node build-all.mjs", "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", "dev": "vite build --watch", "test": "vitest run", "test:watch": "vitest", diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 1a14775fd..51edc30a2 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -66,7 +66,24 @@ export interface ApsRendererV1 { height: number; } -export type AuctionBidRenderer = ApsRendererV1; +export interface AdmRenderSourceV1 { + type: 'adm'; + version: 1; + adm: string; + width: number; + height: number; +} + +export interface CacheRenderSourceV1 { + type: 'cache'; + version: 1; + cacheId: string; + fetchUrl: string; + width: number; + height: number; +} + +export type BidRenderSourceV1 = ApsRendererV1 | AdmRenderSourceV1 | CacheRenderSourceV1; /** A client-side Prebid bid's generated ad ID bound to its APS render capability. */ export interface ApsPrebidRendererEntry { @@ -96,7 +113,7 @@ export interface AuctionBidData { nurl?: string | undefined; burl?: string | undefined; /** Typed winning-bid renderer capability. */ - renderer?: AuctionBidRenderer | undefined; + renderer?: BidRenderSourceV1 | undefined; /** Winning creative width used by the inline render bridge. */ w?: number | undefined; /** Winning creative height used by the inline render bridge. */ diff --git a/crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts b/crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts new file mode 100644 index 000000000..33d714185 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts @@ -0,0 +1,141 @@ +// @generated by scripts/generate-aps-renderer-contract.mjs +// schema-sha256: e7ed370e6ccbeb30660b63ae0837dbde6ddd56e81f606a544d37b9d0b99f5d1d +// corpus-sha256: 3aea612e3316e6df4852e80cb3aa8882ca43d842455a22ba29e63fa88291c7b9 +/* eslint-disable */ +export type ApsRendererValidationResult = 'accepted' | 'descriptor_invalid' | 'invalid_dimensions' | 'dimensions_out_of_range'; +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"]; +var ENVELOPE_ROOT_KEYS = ["seatbid"]; +var ENVELOPE_SEAT_KEYS = ["bid"]; +var ENVELOPE_BID_KEYS = ["ext","h","id","price","w"]; +var ENVELOPE_EXT_KEYS = ["creativeurl","tagtype"]; +var MAX_ACCOUNT_ID_BYTES = 1024; +var MAX_BID_ID_BYTES = 64; +var MAX_CREATIVE_ID_BYTES = 1024; +var MAX_CREATIVE_URL_BYTES = 4096; +var MAX_RENDER_ENVELOPE_BYTES = 262144; +var MAX_RENDER_ENVELOPE_BASE64_BYTES = 349528; +var STANDARD_BASE64_PATTERN = "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"; +export { MAX_ACCOUNT_ID_BYTES, MAX_BID_ID_BYTES, MAX_CREATIVE_ID_BYTES, MAX_RENDER_ENVELOPE_BASE64_BYTES }; +export const RENDER_DIMENSION_MIN = 1; +export const RENDER_DIMENSION_MAX = 4096; +function apsExactRecord(value: any, expectedKeys: string[]): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + var prototype: any = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + if (typeof Object.getOwnPropertySymbols === 'function' && Object.getOwnPropertySymbols(value).length !== 0) return false; + var actual: string[] = Object.getOwnPropertyNames(value).sort(); + if (actual.length !== expectedKeys.length) return false; + for (var index = 0; index < actual.length; index += 1) { + var propertyName: string | undefined = actual[index]; + if (propertyName === undefined || propertyName !== expectedKeys[index]) return false; + var property: any = Object.getOwnPropertyDescriptor(value, propertyName); + if (!property || !Object.prototype.hasOwnProperty.call(property, 'value')) return false; + } + return true; +} + +function apsUtf8Length(value: string): number { + return (new TextEncoder()).encode(value).length; +} + +function apsHasAsciiControl(value: string): boolean { + return /[\x00-\x1f\x7f]/.test(value); +} + +function apsDimensionResult(value: any): ApsRendererValidationResult { + if (typeof value !== 'number' || !isFinite(value) || Math.floor(value) !== value || value <= 0) { + return 'invalid_dimensions'; + } + if (value < RENDER_DIMENSION_MIN || value > RENDER_DIMENSION_MAX) { + return 'dimensions_out_of_range'; + } + return 'accepted'; +} + +function apsValidCreativeUrl(value: string, publisherOrigin: string): boolean { + try { + var url: URL = new URL(value); + return url.protocol === 'https:' && url.hostname !== '' && url.username === '' && + url.password === '' && url.origin !== publisherOrigin; + } catch (_error) { + return false; + } +} + +function apsDecodeEnvelope(value: string): any | undefined { + if (value.length === 0 || value.length > MAX_RENDER_ENVELOPE_BASE64_BYTES || + value.length % 4 !== 0 || !(new RegExp(STANDARD_BASE64_PATTERN)).test(value)) { + return undefined; + } + try { + var binary: string = atob(value); + if (binary.length > MAX_RENDER_ENVELOPE_BYTES || btoa(binary) !== value) return undefined; + var bytes: Uint8Array = new Uint8Array(binary.length); + for (var index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch (_error) { + return undefined; + } +} + +export function classifyApsRendererDescriptorV1( + value: unknown +): ApsRendererValidationResult { + var renderer: any = value; + if (!apsExactRecord(renderer, DESCRIPTOR_KEYS) && + !apsExactRecord(renderer, DESCRIPTOR_KEYS_WITH_CREATIVE_ID)) return 'descriptor_invalid'; + if (renderer.type !== 'aps' || renderer.version !== 1 || + typeof renderer.accountId !== 'string' || renderer.accountId.length === 0 || + apsUtf8Length(renderer.accountId) > MAX_ACCOUNT_ID_BYTES || + typeof renderer.bidId !== 'string' || renderer.bidId.length === 0 || + apsUtf8Length(renderer.bidId) > MAX_BID_ID_BYTES || + apsHasAsciiControl(renderer.bidId)) return 'descriptor_invalid'; + if (Object.prototype.hasOwnProperty.call(renderer, 'creativeId') && + (typeof renderer.creativeId !== 'string' || renderer.creativeId.length === 0 || + apsUtf8Length(renderer.creativeId) > MAX_CREATIVE_ID_BYTES)) return 'descriptor_invalid'; + if (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') return 'descriptor_invalid'; + + var widthResult: ApsRendererValidationResult = apsDimensionResult(renderer.width); + if (widthResult !== 'accepted') return widthResult; + var heightResult: ApsRendererValidationResult = apsDimensionResult(renderer.height); + if (heightResult !== 'accepted') return heightResult; + + if (typeof renderer.creativeUrl !== 'string' || + apsUtf8Length(renderer.creativeUrl) > MAX_CREATIVE_URL_BYTES || + typeof renderer.aaxResponse !== 'string' || + renderer.aaxResponse.length > MAX_RENDER_ENVELOPE_BASE64_BYTES) return 'descriptor_invalid'; + return 'accepted'; +} + +export function classifyApsRendererV1( + value: unknown, + publisherOrigin: string +): ApsRendererValidationResult { + var renderer: any = value; + var descriptorResult: ApsRendererValidationResult = + classifyApsRendererDescriptorV1(renderer); + if (descriptorResult !== 'accepted') return descriptorResult; + if (!apsValidCreativeUrl(renderer.creativeUrl, publisherOrigin)) return 'descriptor_invalid'; + + var decoded: any = apsDecodeEnvelope(renderer.aaxResponse); + if (!apsExactRecord(decoded, ENVELOPE_ROOT_KEYS) || !Array.isArray(decoded.seatbid) || + decoded.seatbid.length !== 1) return 'descriptor_invalid'; + var seat: any = decoded.seatbid[0]; + if (!apsExactRecord(seat, ENVELOPE_SEAT_KEYS) || !Array.isArray(seat.bid) || + seat.bid.length !== 1) return 'descriptor_invalid'; + var bid: any = seat.bid[0]; + if (!apsExactRecord(bid, ENVELOPE_BID_KEYS) || + !apsExactRecord(bid.ext, ENVELOPE_EXT_KEYS)) return 'descriptor_invalid'; + + var bidWidthResult: ApsRendererValidationResult = apsDimensionResult(bid.w); + if (bidWidthResult !== 'accepted') return bidWidthResult; + var bidHeightResult: ApsRendererValidationResult = apsDimensionResult(bid.h); + if (bidHeightResult !== 'accepted') return bidHeightResult; + if (bid.id !== renderer.bidId || bid.w !== renderer.width || bid.h !== renderer.height || + bid.ext.creativeurl !== renderer.creativeUrl || bid.ext.tagtype !== renderer.tagType || + typeof bid.price !== 'number' || !isFinite(bid.price) || bid.price < 0) { + return 'descriptor_invalid'; + } + return 'accepted'; +} 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 adec0b036..70329e734 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -2,6 +2,11 @@ import { log } from '../../core/log'; import { findSlot } from '../../core/render'; import type { ApsPrebidRendererEntry, ApsRendererV1, TsjsApi } from '../../core/types'; +import { + classifyApsRendererDescriptorV1, + classifyApsRendererV1, +} from './generated/renderer_validator_v1'; + export const APS_RENDERER_PATH = '/integrations/aps/renderer'; export const APS_RENDERING_MODE_ATTRIBUTE_NAME = 'data-ts-aps-rendering-mode'; export const APS_PREBID_CREATIVE_RUNNER_URL = @@ -11,23 +16,6 @@ export const APS_RENDERER_SANDBOX = 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; export const APS_UNIVERSAL_CREATIVE_RENDERER_VERSION = 4; -const MAX_ACCOUNT_ID_BYTES = 1024; -const MAX_CREATIVE_ID_BYTES = 1024; -const MAX_CREATIVE_URL_BYTES = 4096; -const MAX_RENDER_ENVELOPE_BYTES = 256 * 1024; -const MAX_RENDER_ENVELOPE_BASE64_BYTES = 4 * Math.ceil(MAX_RENDER_ENVELOPE_BYTES / 3); -const DESCRIPTOR_KEYS = [ - 'aaxResponse', - 'accountId', - 'bidId', - 'creativeUrl', - 'height', - 'tagType', - 'type', - 'version', - 'width', -] as const; -const DESCRIPTOR_KEYS_WITH_CREATIVE_ID = [...DESCRIPTOR_KEYS, 'creativeId'].sort(); const activeFrames = new WeakMap(); const pendingFrameCancels = new WeakMap void>(); const RENDERER_READY_MESSAGE = 'trusted-server/aps/renderer-ready'; @@ -134,89 +122,21 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function hasExactKeys( - value: unknown, - expected: readonly string[] -): value is Record { +function isExactRendererResult(value: unknown): value is Record { if (!isRecord(value)) return false; const actual = Object.keys(value).sort(); - const sortedExpected = [...expected].sort(); - return ( - actual.length === sortedExpected.length && - actual.every((key, index) => key === sortedExpected[index]) - ); + return actual.length === 2 && actual[0] === 'message' && actual[1] === 'nonce'; } /** Parse only the versioned descriptor shape; decoded-envelope trust checks happen separately. */ export function parseApsRendererDescriptor(value: unknown): ApsRendererV1 | undefined { - if ( - !hasExactKeys(value, DESCRIPTOR_KEYS) && - !hasExactKeys(value, DESCRIPTOR_KEYS_WITH_CREATIVE_ID) - ) { - return undefined; - } - - if ( - value.type !== 'aps' || - value.version !== 1 || - typeof value.accountId !== 'string' || - value.accountId.length === 0 || - new TextEncoder().encode(value.accountId).length > MAX_ACCOUNT_ID_BYTES || - typeof value.bidId !== 'string' || - value.bidId.length === 0 || - (Object.prototype.hasOwnProperty.call(value, 'creativeId') && - (typeof value.creativeId !== 'string' || - value.creativeId.length === 0 || - new TextEncoder().encode(value.creativeId).length > MAX_CREATIVE_ID_BYTES)) || - (value.tagType !== 'iframe' && value.tagType !== 'script') || - typeof value.creativeUrl !== 'string' || - typeof value.aaxResponse !== 'string' || - value.aaxResponse.length > MAX_RENDER_ENVELOPE_BASE64_BYTES || - !Number.isSafeInteger(value.width) || - (value.width as number) <= 0 || - !Number.isSafeInteger(value.height) || - (value.height as number) <= 0 - ) { + if (classifyApsRendererDescriptorV1(value) !== 'accepted') { return undefined; } return value as unknown as ApsRendererV1; } -function decodeStandardBase64(value: string): Uint8Array | undefined { - if ( - value.length === 0 || - value.length % 4 !== 0 || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) - ) { - return undefined; - } - - try { - const binary = atob(value); - if (binary.length > MAX_RENDER_ENVELOPE_BYTES || btoa(binary) !== value) return undefined; - return Uint8Array.from(binary, (character) => character.charCodeAt(0)); - } catch { - return undefined; - } -} - -function validCreativeUrl(value: string, publisherOrigin: string): boolean { - if (new TextEncoder().encode(value).length > MAX_CREATIVE_URL_BYTES) return false; - - try { - const url = new URL(value); - return ( - url.protocol === 'https:' && - url.username === '' && - url.password === '' && - url.origin !== publisherOrigin - ); - } catch { - return false; - } -} - /** Fully validate the exact APS envelope and cross-check every duplicated descriptor field. */ export function validateApsRenderer( value: unknown, @@ -227,43 +147,8 @@ export function validateApsRenderer( if (cached?.publisherOrigin === publisherOrigin) return cached.renderer; } - const renderer = parseApsRendererDescriptor(value); - if (!renderer || !validCreativeUrl(renderer.creativeUrl, publisherOrigin)) return undefined; - - const bytes = decodeStandardBase64(renderer.aaxResponse); - if (!bytes) return undefined; - - let decoded: unknown; - try { - decoded = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); - } catch { - return undefined; - } - - if (!hasExactKeys(decoded, ['seatbid'])) return undefined; - const seatbids = decoded.seatbid; - if (!Array.isArray(seatbids) || seatbids.length !== 1) return undefined; - const seat = seatbids[0]; - if (!hasExactKeys(seat, ['bid']) || !Array.isArray(seat.bid) || seat.bid.length !== 1) { - return undefined; - } - - const bid = seat.bid[0]; - if (!hasExactKeys(bid, ['ext', 'h', 'id', 'price', 'w'])) return undefined; - if (!hasExactKeys(bid.ext, ['creativeurl', 'tagtype'])) return undefined; - - if ( - bid.id !== renderer.bidId || - bid.w !== renderer.width || - bid.h !== renderer.height || - bid.ext.creativeurl !== renderer.creativeUrl || - bid.ext.tagtype !== renderer.tagType || - typeof bid.price !== 'number' || - !Number.isFinite(bid.price) || - bid.price < 0 - ) { - return undefined; - } + if (classifyApsRendererV1(value, publisherOrigin) !== 'accepted') return undefined; + const renderer = value as ApsRendererV1; const validated = Object.freeze({ ...renderer }) as ApsRendererV1; validatedRendererCache.set(value as object, { publisherOrigin, renderer: validated }); @@ -665,7 +550,7 @@ export function renderApsCreative({ slotId, renderer: input }: RenderApsCreative iframe.style.display = ''; }; function receive(event: MessageEvent): void { - if (event.source !== iframe.contentWindow || !hasExactKeys(event.data, ['message', 'nonce'])) { + if (event.source !== iframe.contentWindow || !isExactRendererResult(event.data)) { return; } if (event.data.nonce !== nonce) return; diff --git a/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs b/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs new file mode 100644 index 000000000..7286a03a7 --- /dev/null +++ b/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import vm from 'node:vm'; + +const corpus = JSON.parse( + await readFile(new URL('../fixtures/aps-renderer-v1-corpus.json', import.meta.url), 'utf8') +); +const goldenEnvelope = JSON.parse( + await readFile(new URL('../fixtures/aps-renderer-v1.json', import.meta.url), 'utf8') +); +const validatorUrl = new URL( + '../../../../trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js', + import.meta.url +); +const validatorSource = await readFile(validatorUrl, 'utf8'); +const apsSource = await readFile( + new URL('../../../../trusted-server-core/src/integrations/aps.rs', import.meta.url), + 'utf8' +); + +function setPath(root, path, value) { + let parent = root; + for (const segment of path.slice(0, -1)) parent = parent[segment]; + parent[path.at(-1)] = value; +} + +function deletePath(root, path) { + let parent = root; + for (const segment of path.slice(0, -1)) parent = parent[segment]; + delete parent[path.at(-1)]; +} + +function encodeBytes(value) { + return Buffer.from(value).toString('base64'); +} + +function materialize(vector) { + const descriptor = structuredClone(corpus.baseDescriptor); + const envelope = structuredClone(goldenEnvelope); + const operation = vector.operation; + let encodedEnvelope; + + switch (operation.kind) { + case 'none': + break; + case 'descriptor-delete': + delete descriptor[operation.field]; + break; + case 'descriptor-set': + descriptor[operation.field] = operation.value; + break; + case 'descriptor-repeat': + descriptor[operation.field] = + operation.unit.repeat(operation.count) + (operation.suffix ?? ''); + break; + case 'bid-id-repeat': { + const value = operation.unit.repeat(operation.count) + (operation.suffix ?? ''); + descriptor.bidId = value; + setPath(envelope, ['seatbid', 0, 'bid', 0, 'id'], value); + break; + } + case 'dimension': { + descriptor[operation.field] = operation.value; + setPath( + envelope, + ['seatbid', 0, 'bid', 0, operation.field === 'width' ? 'w' : 'h'], + operation.value + ); + break; + } + case 'dimensions': + descriptor.width = operation.width; + descriptor.height = operation.height; + setPath(envelope, ['seatbid', 0, 'bid', 0, 'w'], operation.width); + setPath(envelope, ['seatbid', 0, 'bid', 0, 'h'], operation.height); + break; + case 'creative-url': + descriptor.creativeUrl = operation.value; + setPath(envelope, ['seatbid', 0, 'bid', 0, 'ext', 'creativeurl'], operation.value); + break; + case 'creative-url-bytes': { + const prefix = 'https://creative.example/'; + const value = prefix + 'a'.repeat(operation.bytes - prefix.length); + descriptor.creativeUrl = value; + setPath(envelope, ['seatbid', 0, 'bid', 0, 'ext', 'creativeurl'], value); + break; + } + case 'aax-literal': + encodedEnvelope = operation.value; + break; + case 'aax-bytes': + encodedEnvelope = encodeBytes(Uint8Array.from(operation.values)); + break; + case 'aax-raw-json': + encodedEnvelope = encodeBytes(operation.value); + break; + case 'aax-decoded-bytes': { + const serialized = JSON.stringify(envelope); + assert.ok(serialized.length <= operation.bytes, vector.id); + encodedEnvelope = encodeBytes( + serialized + ' '.repeat(operation.bytes - serialized.length) + ); + break; + } + case 'aax-raw-price': { + const serialized = JSON.stringify(envelope); + const raw = serialized.replace('"price":1.23', `"price":${operation.value}`); + assert.notEqual(raw, serialized, vector.id); + encodedEnvelope = encodeBytes(raw); + break; + } + case 'envelope-set': + setPath(envelope, operation.path, operation.value); + break; + case 'envelope-delete': + deletePath(envelope, operation.path); + break; + case 'duplicate-seat': + envelope.seatbid.push(structuredClone(envelope.seatbid[0])); + break; + case 'duplicate-bid': + envelope.seatbid[0].bid.push(structuredClone(envelope.seatbid[0].bid[0])); + break; + default: + throw new Error(`unknown APS renderer corpus operation: ${operation.kind}`); + } + + descriptor.aaxResponse = encodedEnvelope ?? encodeBytes(JSON.stringify(envelope)); + return descriptor; +} + +test('the exact embedded ES5 validator matches every shared corpus vector', () => { + assert.match( + apsSource, + /include_str!\("generated\/aps_renderer_validator_v1\.js"\)/, + 'Rust should embed the generated validator file directly' + ); + + const context = vm.createContext({ + URL, + TextEncoder, + TextDecoder, + atob, + btoa, + inputJson: '', + publisherOrigin: corpus.publisherOrigin, + }); + vm.runInContext(validatorSource, context, { + filename: 'aps_renderer_validator_v1.js', + }); + + for (const vector of corpus.vectors) { + context.inputJson = JSON.stringify(materialize(vector)); + const actual = vm.runInContext( + 'classifyApsRendererV1(JSON.parse(inputJson), publisherOrigin)', + context + ); + assert.equal(actual, vector.expected, vector.id); + } +}); + +test('the embedded validator remains ES5 syntax', () => { + assert.doesNotMatch(validatorSource, /=>|\b(?:const|let|class)\b|\?\.|\?\?/); +}); diff --git a/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json b/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json new file mode 100644 index 000000000..b782f99f4 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json @@ -0,0 +1,511 @@ +{ + "schemaVersion": 1, + "publisherOrigin": "https://publisher.example", + "baseDescriptor": { + "type": "aps", + "version": 1, + "accountId": "example-account-id", + "bidId": "fictional-selected-bid-id", + "creativeId": "fictional-creative-id", + "tagType": "iframe", + "creativeUrl": "https://creative.example/render", + "width": 300, + "height": 250 + }, + "vectors": [ + { + "id": "valid-complete", + "expected": "accepted", + "operation": { + "kind": "none" + } + }, + { + "id": "valid-without-optional-creative-id", + "expected": "accepted", + "operation": { + "kind": "descriptor-delete", + "field": "creativeId" + } + }, + { + "id": "missing-required-account-id", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-delete", + "field": "accountId" + } + }, + { + "id": "unknown-descriptor-key", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "adm", + "value": "
forbidden
" + } + }, + { + "id": "account-id-utf8-byte-limit", + "expected": "accepted", + "operation": { + "kind": "descriptor-repeat", + "field": "accountId", + "unit": "é", + "count": 512 + } + }, + { + "id": "account-id-utf8-byte-over-limit", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-repeat", + "field": "accountId", + "unit": "é", + "count": 512, + "suffix": "x" + } + }, + { + "id": "creative-id-utf8-byte-limit", + "expected": "accepted", + "operation": { + "kind": "descriptor-repeat", + "field": "creativeId", + "unit": "é", + "count": 512 + } + }, + { + "id": "creative-id-utf8-byte-over-limit", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-repeat", + "field": "creativeId", + "unit": "é", + "count": 512, + "suffix": "x" + } + }, + { + "id": "bid-id-utf8-byte-limit", + "expected": "accepted", + "operation": { + "kind": "bid-id-repeat", + "unit": "é", + "count": 32 + } + }, + { + "id": "bid-id-utf8-byte-over-limit", + "expected": "descriptor_invalid", + "operation": { + "kind": "bid-id-repeat", + "unit": "é", + "count": 32, + "suffix": "x" + } + }, + { + "id": "bid-id-nul", + "expected": "descriptor_invalid", + "operation": { + "kind": "bid-id-repeat", + "unit": "bid\u0000id", + "count": 1 + } + }, + { + "id": "bid-id-ascii-control", + "expected": "descriptor_invalid", + "operation": { + "kind": "bid-id-repeat", + "unit": "bid\n-id", + "count": 1 + } + }, + { + "id": "width-zero", + "expected": "invalid_dimensions", + "operation": { + "kind": "dimension", + "field": "width", + "value": 0 + } + }, + { + "id": "height-negative", + "expected": "invalid_dimensions", + "operation": { + "kind": "dimension", + "field": "height", + "value": -1 + } + }, + { + "id": "width-fractional", + "expected": "invalid_dimensions", + "operation": { + "kind": "dimension", + "field": "width", + "value": 1.5 + } + }, + { + "id": "height-wrong-type", + "expected": "invalid_dimensions", + "operation": { + "kind": "dimension", + "field": "height", + "value": "250" + } + }, + { + "id": "dimensions-minimum", + "expected": "accepted", + "operation": { + "kind": "dimensions", + "width": 1, + "height": 1 + } + }, + { + "id": "dimensions-maximum", + "expected": "accepted", + "operation": { + "kind": "dimensions", + "width": 4096, + "height": 4096 + } + }, + { + "id": "width-over-maximum", + "expected": "dimensions_out_of_range", + "operation": { + "kind": "dimension", + "field": "width", + "value": 4097 + } + }, + { + "id": "height-over-maximum", + "expected": "dimensions_out_of_range", + "operation": { + "kind": "dimension", + "field": "height", + "value": 4097 + } + }, + { + "id": "creative-url-http", + "expected": "descriptor_invalid", + "operation": { + "kind": "creative-url", + "value": "http://creative.example/render" + } + }, + { + "id": "creative-url-credentials", + "expected": "descriptor_invalid", + "operation": { + "kind": "creative-url", + "value": "https://user:password@creative.example/render" + } + }, + { + "id": "creative-url-publisher-origin", + "expected": "descriptor_invalid", + "operation": { + "kind": "creative-url", + "value": "https://publisher.example/render" + } + }, + { + "id": "creative-url-byte-limit", + "expected": "accepted", + "operation": { + "kind": "creative-url-bytes", + "bytes": 4096 + } + }, + { + "id": "creative-url-byte-over-limit", + "expected": "descriptor_invalid", + "operation": { + "kind": "creative-url-bytes", + "bytes": 4097 + } + }, + { + "id": "aax-empty", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-literal", + "value": "" + } + }, + { + "id": "aax-invalid-alphabet", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-literal", + "value": "not-base64" + } + }, + { + "id": "aax-missing-padding", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-literal", + "value": "e30" + } + }, + { + "id": "aax-noncanonical-trailing-bits", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-literal", + "value": "Zh==" + } + }, + { + "id": "aax-invalid-utf8", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-bytes", + "values": [195, 40] + } + }, + { + "id": "aax-malformed-json", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-raw-json", + "value": "{not json}" + } + }, + { + "id": "aax-decoded-byte-limit", + "expected": "accepted", + "operation": { + "kind": "aax-decoded-bytes", + "bytes": 262144 + } + }, + { + "id": "aax-decoded-byte-over-limit", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-decoded-bytes", + "bytes": 262145 + } + }, + { + "id": "envelope-unknown-root-key", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["forbidden"], + "value": true + } + }, + { + "id": "envelope-missing-seatbid", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-delete", + "path": ["seatbid"] + } + }, + { + "id": "envelope-zero-seats", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid"], + "value": [] + } + }, + { + "id": "envelope-two-seats", + "expected": "descriptor_invalid", + "operation": { + "kind": "duplicate-seat" + } + }, + { + "id": "envelope-unknown-seat-key", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "seat"], + "value": "forbidden" + } + }, + { + "id": "envelope-missing-bid-array", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-delete", + "path": ["seatbid", 0, "bid"] + } + }, + { + "id": "envelope-zero-bids", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "bid"], + "value": [] + } + }, + { + "id": "envelope-two-bids", + "expected": "descriptor_invalid", + "operation": { + "kind": "duplicate-bid" + } + }, + { + "id": "envelope-unknown-bid-key", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "bid", 0, "adm"], + "value": "
forbidden
" + } + }, + { + "id": "envelope-missing-ext", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-delete", + "path": ["seatbid", 0, "bid", 0, "ext"] + } + }, + { + "id": "envelope-unknown-ext-key", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "bid", 0, "ext", "forbidden"], + "value": true + } + }, + { + "id": "envelope-missing-tagtype", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-delete", + "path": ["seatbid", 0, "bid", 0, "ext", "tagtype"] + } + }, + { + "id": "bid-id-disagreement", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "bidId", + "value": "different-bid-id" + } + }, + { + "id": "width-disagreement", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "width", + "value": 728 + } + }, + { + "id": "height-disagreement", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "height", + "value": 90 + } + }, + { + "id": "creative-url-disagreement", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "creativeUrl", + "value": "https://different.example/render" + } + }, + { + "id": "tag-type-disagreement", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "tagType", + "value": "script" + } + }, + { + "id": "price-negative", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "bid", 0, "price"], + "value": -0.01 + } + }, + { + "id": "price-wrong-type", + "expected": "descriptor_invalid", + "operation": { + "kind": "envelope-set", + "path": ["seatbid", 0, "bid", 0, "price"], + "value": "1.23" + } + }, + { + "id": "price-nonfinite", + "expected": "descriptor_invalid", + "operation": { + "kind": "aax-raw-price", + "value": "1e400" + } + }, + { + "id": "unknown-descriptor-type", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "type", + "value": "renderer" + } + }, + { + "id": "equivalent-decimal-version", + "expected": "accepted", + "operation": { + "kind": "descriptor-set", + "field": "version", + "value": 1.0 + } + }, + { + "id": "unknown-descriptor-version", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "version", + "value": 2 + } + }, + { + "id": "unknown-tag-type", + "expected": "descriptor_invalid", + "operation": { + "kind": "descriptor-set", + "field": "tagType", + "value": "video" + } + } + ] +} 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 new file mode 100644 index 000000000..ef605725a --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://iabtechlab.com/trusted-server/aps-renderer-v1.schema.json", + "title": "Trusted Server APS renderer descriptor version 1", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "version", + "accountId", + "bidId", + "tagType", + "creativeUrl", + "width", + "height", + "aaxResponse" + ], + "properties": { + "type": { + "const": "aps" + }, + "version": { + "const": 1 + }, + "accountId": { + "type": "string", + "minLength": 1, + "x-utf8MaxBytes": 1024 + }, + "bidId": { + "type": "string", + "minLength": 1, + "x-utf8MaxBytes": 64, + "x-forbidNulAndAsciiControl": true + }, + "creativeId": { + "type": "string", + "minLength": 1, + "x-utf8MaxBytes": 1024 + }, + "tagType": { + "enum": ["iframe", "script"] + }, + "creativeUrl": { + "type": "string", + "format": "uri", + "x-utf8MaxBytes": 4096, + "x-requiredScheme": "https", + "x-forbidCredentials": true, + "x-forbidPublisherOrigin": true + }, + "width": { + "type": "integer", + "minimum": 1, + "maximum": 4096 + }, + "height": { + "type": "integer", + "minimum": 1, + "maximum": 4096 + }, + "aaxResponse": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", + "x-canonicalStandardBase64": true, + "x-decodedMaxBytes": 262144 + } + }, + "x-envelope": { + "rootKeys": ["seatbid"], + "seatCount": 1, + "seatKeys": ["bid"], + "bidCount": 1, + "bidKeys": ["ext", "h", "id", "price", "w"], + "extKeys": ["creativeurl", "tagtype"], + "price": { + "finite": true, + "minimum": 0 + }, + "duplicatedFields": { + "bidId": ["seatbid", 0, "bid", 0, "id"], + "width": ["seatbid", 0, "bid", 0, "w"], + "height": ["seatbid", 0, "bid", 0, "h"], + "creativeUrl": ["seatbid", 0, "bid", 0, "ext", "creativeurl"], + "tagType": ["seatbid", 0, "bid", 0, "ext", "tagtype"] + } + } +} 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 67a5f1f0e..4198d4cc0 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 @@ -1,8 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import corpusFixture from '../../fixtures/aps-renderer-v1-corpus.json'; import envelope from '../../fixtures/aps-renderer-v1.json'; import type { ApsRendererV1 } from '../../../src/core/types'; import { log } from '../../../src/core/log'; +import { classifyApsRendererV1 } from '../../../src/integrations/aps/generated/renderer_validator_v1'; import { APS_NATIVE_RENDERER_TIMEOUT_MS, APS_PREBID_CREATIVE_RUNNER_URL, @@ -68,7 +70,235 @@ function descriptor(overrides: Partial = {}): ApsRendererV1 { }; } +type CorpusResult = + | 'accepted' + | 'descriptor_invalid' + | 'invalid_dimensions' + | 'dimensions_out_of_range'; + +interface CorpusVector { + id: string; + expected: CorpusResult; + operation: Record; +} + +interface RendererCorpus { + publisherOrigin: string; + baseDescriptor: Record; + vectors: CorpusVector[]; +} + +interface MaterializedCorpusVector { + id: string; + expected: CorpusResult; + publisherOrigin: string; + descriptor: Record; +} + +const rendererCorpus = corpusFixture as unknown as RendererCorpus; + +function mutableRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function jsonPathParent( + root: unknown, + path: readonly (string | number)[] +): { parent: unknown; key: string | number } { + if (path.length === 0) throw new Error('corpus path should not be empty'); + let parent = root; + for (const segment of path.slice(0, -1)) { + if (typeof segment === 'number') { + if (!Array.isArray(parent)) throw new Error('corpus numeric path should address an array'); + parent = parent[segment]; + } else { + if (!mutableRecord(parent)) throw new Error('corpus string path should address an object'); + parent = parent[segment]; + } + } + const key = path[path.length - 1]; + if (key === undefined) throw new Error('corpus path should have a final key'); + return { parent, key }; +} + +function setJsonPath(root: unknown, path: readonly (string | number)[], value: unknown): void { + const { parent, key } = jsonPathParent(root, path); + if (typeof key === 'number') { + if (!Array.isArray(parent)) throw new Error('corpus numeric key should address an array'); + parent[key] = value; + return; + } + if (!mutableRecord(parent)) throw new Error('corpus string key should address an object'); + parent[key] = value; +} + +function deleteJsonPath(root: unknown, path: readonly (string | number)[]): void { + const { parent, key } = jsonPathParent(root, path); + if (typeof key !== 'string' || !mutableRecord(parent)) { + throw new Error('corpus delete should address an object field'); + } + delete parent[key]; +} + +function operationString(operation: Record, field: string): string { + const value = operation[field]; + if (typeof value !== 'string') throw new Error(`corpus ${field} should be a string`); + return value; +} + +function operationNumber(operation: Record, field: string): number { + const value = operation[field]; + if (typeof value !== 'number') throw new Error(`corpus ${field} should be a number`); + return value; +} + +function operationPath(operation: Record): Array { + const value = operation.path; + if ( + !Array.isArray(value) || + !value.every((segment) => typeof segment === 'string' || typeof segment === 'number') + ) { + throw new Error('corpus path should contain only string and number segments'); + } + return value; +} + +function materializeCorpusVector(vector: CorpusVector): MaterializedCorpusVector { + const descriptor = structuredClone(rendererCorpus.baseDescriptor); + const decodedEnvelope = structuredClone(envelope) as unknown; + const operation = vector.operation; + const kind = operationString(operation, 'kind'); + let encodedEnvelope: string | undefined; + + switch (kind) { + case 'none': + break; + case 'descriptor-delete': + delete descriptor[operationString(operation, 'field')]; + break; + case 'descriptor-set': + descriptor[operationString(operation, 'field')] = operation.value; + break; + case 'descriptor-repeat': { + const repeated = + operationString(operation, 'unit').repeat(operationNumber(operation, 'count')) + + (typeof operation.suffix === 'string' ? operation.suffix : ''); + descriptor[operationString(operation, 'field')] = repeated; + break; + } + case 'bid-id-repeat': { + const repeated = + operationString(operation, 'unit').repeat(operationNumber(operation, 'count')) + + (typeof operation.suffix === 'string' ? operation.suffix : ''); + descriptor.bidId = repeated; + setJsonPath(decodedEnvelope, ['seatbid', 0, 'bid', 0, 'id'], repeated); + break; + } + case 'dimension': { + const field = operationString(operation, 'field'); + if (field !== 'width' && field !== 'height') { + throw new Error('corpus dimension field should be width or height'); + } + descriptor[field] = operation.value; + setJsonPath( + decodedEnvelope, + ['seatbid', 0, 'bid', 0, field === 'width' ? 'w' : 'h'], + operation.value + ); + break; + } + case 'dimensions': + descriptor.width = operation.width; + descriptor.height = operation.height; + setJsonPath(decodedEnvelope, ['seatbid', 0, 'bid', 0, 'w'], operation.width); + setJsonPath(decodedEnvelope, ['seatbid', 0, 'bid', 0, 'h'], operation.height); + break; + case 'creative-url': { + const value = operationString(operation, 'value'); + descriptor.creativeUrl = value; + setJsonPath(decodedEnvelope, ['seatbid', 0, 'bid', 0, 'ext', 'creativeurl'], value); + break; + } + case 'creative-url-bytes': { + const prefix = 'https://creative.example/'; + const value = prefix + 'a'.repeat(operationNumber(operation, 'bytes') - prefix.length); + descriptor.creativeUrl = value; + setJsonPath(decodedEnvelope, ['seatbid', 0, 'bid', 0, 'ext', 'creativeurl'], value); + break; + } + case 'aax-literal': + encodedEnvelope = operationString(operation, 'value'); + break; + case 'aax-bytes': { + const values = operation.values; + if (!Array.isArray(values) || !values.every((value) => Number.isInteger(value))) { + throw new Error('corpus byte vector should contain integers'); + } + encodedEnvelope = encodeBytes(Uint8Array.from(values as number[])); + break; + } + case 'aax-raw-json': + encodedEnvelope = encodeBytes(new TextEncoder().encode(operationString(operation, 'value'))); + break; + case 'aax-decoded-bytes': { + const serialized = JSON.stringify(decodedEnvelope); + const target = operationNumber(operation, 'bytes'); + if (serialized.length > target) throw new Error('corpus decoded size is below fixture size'); + encodedEnvelope = encodeBytes( + new TextEncoder().encode(serialized + ' '.repeat(target - serialized.length)) + ); + break; + } + case 'aax-raw-price': { + const serialized = JSON.stringify(decodedEnvelope); + const price = operationString(operation, 'value'); + const raw = serialized.replace('"price":1.23', `"price":${price}`); + if (raw === serialized) throw new Error('corpus should replace the fixture price'); + encodedEnvelope = encodeBytes(new TextEncoder().encode(raw)); + break; + } + case 'envelope-set': + setJsonPath(decodedEnvelope, operationPath(operation), operation.value); + break; + case 'envelope-delete': + deleteJsonPath(decodedEnvelope, operationPath(operation)); + break; + case 'duplicate-seat': { + if (!mutableRecord(decodedEnvelope) || !Array.isArray(decodedEnvelope.seatbid)) { + throw new Error('corpus fixture should contain seatbid'); + } + decodedEnvelope.seatbid.push(structuredClone(decodedEnvelope.seatbid[0])); + break; + } + case 'duplicate-bid': { + const seatbid = mutableRecord(decodedEnvelope) ? decodedEnvelope.seatbid : undefined; + const seat = Array.isArray(seatbid) ? seatbid[0] : undefined; + const bids = mutableRecord(seat) ? seat.bid : undefined; + if (!Array.isArray(bids)) throw new Error('corpus fixture should contain a bid array'); + bids.push(structuredClone(bids[0])); + break; + } + default: + throw new Error(`unknown APS renderer corpus operation: ${kind}`); + } + + descriptor.aaxResponse = encodedEnvelope ?? encodeEnvelope(decodedEnvelope); + return { + id: vector.id, + expected: vector.expected, + publisherOrigin: rendererCorpus.publisherOrigin, + descriptor, + }; +} + describe('APS renderer validation', () => { + it('matches every shared cross-language contract vector', () => { + for (const vector of rendererCorpus.vectors.map(materializeCorpusVector)) { + const actual = classifyApsRendererV1(vector.descriptor, vector.publisherOrigin); + expect(actual, vector.id).toBe(vector.expected); + } + }); + it('consumes the shared fictional golden envelope and supports an omitted creative ID', () => { const withCreativeId = descriptor(); const withoutCreativeId = descriptor(); diff --git a/scripts/generate-aps-renderer-contract.mjs b/scripts/generate-aps-renderer-contract.mjs new file mode 100644 index 000000000..53c5e8b7b --- /dev/null +++ b/scripts/generate-aps-renderer-contract.mjs @@ -0,0 +1,315 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const fixtureRoot = path.join( + repositoryRoot, + 'crates/trusted-server-js/lib/test/fixtures' +); +const schemaPath = path.join(fixtureRoot, 'aps-renderer-v1.schema.json'); +const corpusPath = path.join(fixtureRoot, 'aps-renderer-v1-corpus.json'); +const es5Path = path.join( + repositoryRoot, + 'crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js' +); +const typescriptPath = path.join( + repositoryRoot, + 'crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts' +); + +const [schemaText, corpusText] = await Promise.all([ + readFile(schemaPath, 'utf8'), + readFile(corpusPath, 'utf8'), +]); +const schema = JSON.parse(schemaText); +const corpus = JSON.parse(corpusText); + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +invariant(schema?.properties?.type?.const === 'aps', 'schema type should be aps'); +invariant(schema?.properties?.version?.const === 1, 'schema version should be 1'); +invariant( + schema?.properties?.width?.minimum === 1 && + schema?.properties?.height?.minimum === 1, + 'schema minimum dimensions should be 1' +); +invariant( + schema?.properties?.width?.maximum === 4096 && + schema?.properties?.height?.maximum === 4096, + 'schema maximum dimensions should be 4096' +); +invariant( + schema?.properties?.aaxResponse?.['x-decodedMaxBytes'] === 262144, + 'schema decoded AAX limit should be 256 KiB' +); +invariant(corpus?.schemaVersion === 1, 'corpus schema version should be 1'); +invariant(Array.isArray(corpus?.vectors) && corpus.vectors.length > 0, 'corpus should have vectors'); +for (const result of [ + 'accepted', + 'descriptor_invalid', + 'invalid_dimensions', + 'dimensions_out_of_range', +]) { + invariant( + corpus.vectors.some((vector) => vector.expected === result), + 'corpus should exercise ' + result + ); +} + +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +const schemaHash = sha256(schemaText); +const corpusHash = sha256(corpusText); +const generatedHeader = + '// @generated by scripts/generate-aps-renderer-contract.mjs\n' + + '// schema-sha256: ' + + schemaHash + + '\n' + + '// corpus-sha256: ' + + corpusHash + + '\n'; + +const requiredKeys = [...schema.required].sort(); +const optionalKeys = Object.keys(schema.properties) + .filter((key) => !schema.required.includes(key)) + .sort(); +invariant( + optionalKeys.length === 1 && optionalKeys[0] === 'creativeId', + 'creativeId should be the only optional descriptor key' +); + +const constantsSource = + 'var DESCRIPTOR_KEYS = ' + + JSON.stringify(requiredKeys) + + ';\n' + + 'var DESCRIPTOR_KEYS_WITH_CREATIVE_ID = ' + + JSON.stringify([...requiredKeys, 'creativeId'].sort()) + + ';\n' + + 'var ENVELOPE_ROOT_KEYS = ' + + JSON.stringify([...schema['x-envelope'].rootKeys].sort()) + + ';\n' + + 'var ENVELOPE_SEAT_KEYS = ' + + JSON.stringify([...schema['x-envelope'].seatKeys].sort()) + + ';\n' + + 'var ENVELOPE_BID_KEYS = ' + + JSON.stringify([...schema['x-envelope'].bidKeys].sort()) + + ';\n' + + 'var ENVELOPE_EXT_KEYS = ' + + JSON.stringify([...schema['x-envelope'].extKeys].sort()) + + ';\n' + + 'var MAX_ACCOUNT_ID_BYTES = ' + + schema.properties.accountId['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_BID_ID_BYTES = ' + + schema.properties.bidId['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_CREATIVE_ID_BYTES = ' + + schema.properties.creativeId['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_CREATIVE_URL_BYTES = ' + + schema.properties.creativeUrl['x-utf8MaxBytes'] + + ';\n' + + 'var MAX_RENDER_ENVELOPE_BYTES = ' + + schema.properties.aaxResponse['x-decodedMaxBytes'] + + ';\n' + + 'var MAX_RENDER_ENVELOPE_BASE64_BYTES = ' + + 4 * Math.ceil(schema.properties.aaxResponse['x-decodedMaxBytes'] / 3) + + ';\n' + + 'var STANDARD_BASE64_PATTERN = ' + + JSON.stringify(schema.properties.aaxResponse.pattern) + + ';\n'; + +const validatorSource = String.raw` +function apsExactRecord(value/*: any*/, expectedKeys/*: string[]*/)/*: boolean*/ { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + var prototype/*: any*/ = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + if (typeof Object.getOwnPropertySymbols === 'function' && Object.getOwnPropertySymbols(value).length !== 0) return false; + var actual/*: string[]*/ = Object.getOwnPropertyNames(value).sort(); + if (actual.length !== expectedKeys.length) return false; + for (var index = 0; index < actual.length; index += 1) { + var propertyName/*: string | undefined*/ = actual[index]; + if (propertyName === undefined || propertyName !== expectedKeys[index]) return false; + var property/*: any*/ = Object.getOwnPropertyDescriptor(value, propertyName); + if (!property || !Object.prototype.hasOwnProperty.call(property, 'value')) return false; + } + return true; +} + +function apsUtf8Length(value/*: string*/)/*: number*/ { + return (new TextEncoder()).encode(value).length; +} + +function apsHasAsciiControl(value/*: string*/)/*: boolean*/ { + return /[\x00-\x1f\x7f]/.test(value); +} + +function apsDimensionResult(value/*: any*/)/*: ApsRendererValidationResult*/ { + if (typeof value !== 'number' || !isFinite(value) || Math.floor(value) !== value || value <= 0) { + return 'invalid_dimensions'; + } + if (value < RENDER_DIMENSION_MIN || value > RENDER_DIMENSION_MAX) { + return 'dimensions_out_of_range'; + } + return 'accepted'; +} + +function apsValidCreativeUrl(value/*: string*/, publisherOrigin/*: string*/)/*: boolean*/ { + try { + var url/*: URL*/ = new URL(value); + return url.protocol === 'https:' && url.hostname !== '' && url.username === '' && + url.password === '' && url.origin !== publisherOrigin; + } catch (_error) { + return false; + } +} + +function apsDecodeEnvelope(value/*: string*/)/*: any | undefined*/ { + if (value.length === 0 || value.length > MAX_RENDER_ENVELOPE_BASE64_BYTES || + value.length % 4 !== 0 || !(new RegExp(STANDARD_BASE64_PATTERN)).test(value)) { + return undefined; + } + try { + var binary/*: string*/ = atob(value); + if (binary.length > MAX_RENDER_ENVELOPE_BYTES || btoa(binary) !== value) return undefined; + var bytes/*: Uint8Array*/ = new Uint8Array(binary.length); + for (var index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch (_error) { + return undefined; + } +} + +/*EXPORT_DESCRIPTOR_CLASSIFIER*/ function classifyApsRendererDescriptorV1( + value/*: unknown*/ +)/*: ApsRendererValidationResult*/ { + var renderer/*: any*/ = value; + if (!apsExactRecord(renderer, DESCRIPTOR_KEYS) && + !apsExactRecord(renderer, DESCRIPTOR_KEYS_WITH_CREATIVE_ID)) return 'descriptor_invalid'; + if (renderer.type !== 'aps' || renderer.version !== 1 || + typeof renderer.accountId !== 'string' || renderer.accountId.length === 0 || + apsUtf8Length(renderer.accountId) > MAX_ACCOUNT_ID_BYTES || + typeof renderer.bidId !== 'string' || renderer.bidId.length === 0 || + apsUtf8Length(renderer.bidId) > MAX_BID_ID_BYTES || + apsHasAsciiControl(renderer.bidId)) return 'descriptor_invalid'; + if (Object.prototype.hasOwnProperty.call(renderer, 'creativeId') && + (typeof renderer.creativeId !== 'string' || renderer.creativeId.length === 0 || + apsUtf8Length(renderer.creativeId) > MAX_CREATIVE_ID_BYTES)) return 'descriptor_invalid'; + if (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') return 'descriptor_invalid'; + + var widthResult/*: ApsRendererValidationResult*/ = apsDimensionResult(renderer.width); + if (widthResult !== 'accepted') return widthResult; + var heightResult/*: ApsRendererValidationResult*/ = apsDimensionResult(renderer.height); + if (heightResult !== 'accepted') return heightResult; + + if (typeof renderer.creativeUrl !== 'string' || + apsUtf8Length(renderer.creativeUrl) > MAX_CREATIVE_URL_BYTES || + typeof renderer.aaxResponse !== 'string' || + renderer.aaxResponse.length > MAX_RENDER_ENVELOPE_BASE64_BYTES) return 'descriptor_invalid'; + return 'accepted'; +} + +/*EXPORT_CLASSIFIER*/ function classifyApsRendererV1( + value/*: unknown*/, + publisherOrigin/*: string*/ +)/*: ApsRendererValidationResult*/ { + var renderer/*: any*/ = value; + var descriptorResult/*: ApsRendererValidationResult*/ = + classifyApsRendererDescriptorV1(renderer); + if (descriptorResult !== 'accepted') return descriptorResult; + if (!apsValidCreativeUrl(renderer.creativeUrl, publisherOrigin)) return 'descriptor_invalid'; + + var decoded/*: any*/ = apsDecodeEnvelope(renderer.aaxResponse); + if (!apsExactRecord(decoded, ENVELOPE_ROOT_KEYS) || !Array.isArray(decoded.seatbid) || + decoded.seatbid.length !== 1) return 'descriptor_invalid'; + var seat/*: any*/ = decoded.seatbid[0]; + if (!apsExactRecord(seat, ENVELOPE_SEAT_KEYS) || !Array.isArray(seat.bid) || + seat.bid.length !== 1) return 'descriptor_invalid'; + var bid/*: any*/ = seat.bid[0]; + if (!apsExactRecord(bid, ENVELOPE_BID_KEYS) || + !apsExactRecord(bid.ext, ENVELOPE_EXT_KEYS)) return 'descriptor_invalid'; + + var bidWidthResult/*: ApsRendererValidationResult*/ = apsDimensionResult(bid.w); + if (bidWidthResult !== 'accepted') return bidWidthResult; + var bidHeightResult/*: ApsRendererValidationResult*/ = apsDimensionResult(bid.h); + if (bidHeightResult !== 'accepted') return bidHeightResult; + if (bid.id !== renderer.bidId || bid.w !== renderer.width || bid.h !== renderer.height || + bid.ext.creativeurl !== renderer.creativeUrl || bid.ext.tagtype !== renderer.tagType || + typeof bid.price !== 'number' || !isFinite(bid.price) || bid.price < 0) { + return 'descriptor_invalid'; + } + return 'accepted'; +} +`; + +function stripTypeMarkers(source) { + return source + .replaceAll('/*EXPORT_CLASSIFIER*/ ', '') + .replaceAll('/*EXPORT_DESCRIPTOR_CLASSIFIER*/ ', '') + .replace(/\/\*:[^*]+\*\//g, ''); +} + +function applyTypeMarkers(source) { + return source + .replaceAll('/*EXPORT_CLASSIFIER*/ ', 'export ') + .replaceAll('/*EXPORT_DESCRIPTOR_CLASSIFIER*/ ', 'export ') + .replace(/\/\*:([^*]+)\*\//g, ':$1'); +} + +const es5Output = + generatedHeader + + constantsSource + + 'var RENDER_DIMENSION_MIN = ' + + schema.properties.width.minimum + + ';\n' + + 'var RENDER_DIMENSION_MAX = ' + + schema.properties.width.maximum + + ';\n' + + stripTypeMarkers(validatorSource).trimStart(); + +const typescriptOutput = + generatedHeader + + '/* eslint-disable */\n' + + "export type ApsRendererValidationResult = 'accepted' | 'descriptor_invalid' | " + + "'invalid_dimensions' | 'dimensions_out_of_range';\n" + + constantsSource + + 'export { MAX_ACCOUNT_ID_BYTES, MAX_BID_ID_BYTES, MAX_CREATIVE_ID_BYTES, ' + + 'MAX_RENDER_ENVELOPE_BASE64_BYTES };\n' + + 'export const RENDER_DIMENSION_MIN = ' + + schema.properties.width.minimum + + ';\n' + + 'export const RENDER_DIMENSION_MAX = ' + + schema.properties.width.maximum + + ';\n' + + applyTypeMarkers(validatorSource).trimStart(); + +const outputs = [ + [es5Path, es5Output], + [typescriptPath, typescriptOutput], +]; +const checkOnly = process.argv.slice(2).includes('--check'); + +if (checkOnly) { + const stale = []; + for (const [outputPath, expected] of outputs) { + let actual; + try { + actual = await readFile(outputPath, 'utf8'); + } catch { + stale.push(path.relative(repositoryRoot, outputPath)); + continue; + } + if (actual !== expected) stale.push(path.relative(repositoryRoot, outputPath)); + } + if (stale.length > 0) { + throw new Error('stale APS renderer contract output: ' + stale.join(', ')); + } +} else { + for (const [outputPath, output] of outputs) { + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, output); + } +} From 6b06ea8091ccaa0dee314766e65b6f7e78559d4b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:43:11 -0700 Subject: [PATCH 556/844] Harden APS admission with typed drop reasons --- .../src/auction/formats.rs | 25 +- .../trusted-server-core/src/auction/types.rs | 187 +++++++- .../src/integrations/aps.rs | 439 ++++++++++++++---- crates/trusted-server-core/src/publisher.rs | 103 ++-- 4 files changed, 578 insertions(+), 176 deletions(-) diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 225810992..0aec3083b 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -9,7 +9,7 @@ use error_stack::{Report, ResultExt, ensure}; use http::{HeaderValue, Request, Response, StatusCode, header}; use serde::Deserialize; use serde_json::Value as JsonValue; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use url::Url; use uuid::Uuid; @@ -29,8 +29,8 @@ use crate::settings::Settings; use super::orchestrator::OrchestrationResult; use super::types::{ - AdFormat, AdSlot, AuctionRequest, BidRenderer, DeviceInfo, MediaType, OrchestratorExt, - ProviderSummary, PublisherInfo, SiteInfo, UserInfo, + AdFormat, AdSlot, AuctionDropReason, AuctionDropReasons, AuctionRequest, DeviceInfo, MediaType, + OrchestratorExt, ProviderSummary, PublisherInfo, SiteInfo, UserInfo, record_auction_drop, }; /// Request body for `POST /auction` (tsjs / Prebid.js wire format). @@ -289,16 +289,13 @@ pub(crate) struct AuctionDeliveryReport { /// Winners omitted because they could not be delivered safely. pub dropped_winner_count: usize, /// Machine-readable reasons for omitted winners. - pub dropped_winner_reasons: BTreeMap, + pub dropped_winner_reasons: AuctionDropReasons, } impl AuctionDeliveryReport { - fn record_drop(&mut self, reason: &str) { + fn record_drop(&mut self, reason: AuctionDropReason) { self.dropped_winner_count += 1; - *self - .dropped_winner_reasons - .entry(reason.to_string()) - .or_default() += 1; + record_auction_drop(&mut self.dropped_winner_reasons, reason); } } @@ -381,7 +378,7 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder ); - delivery.record_drop("multiple_render_sources"); + delivery.record_drop(AuctionDropReason::MultipleRenderSources); continue; } @@ -435,7 +432,7 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder ); - delivery.record_drop("renderer_extension_serialization_failed"); + delivery.record_drop(AuctionDropReason::RendererExtensionSerializationFailed); continue; }; (None, Some(ext)) @@ -446,7 +443,7 @@ pub(crate) fn convert_to_openrtb_response_with_report( slot_id, bid.bidder ); - delivery.record_drop("no_render_source"); + delivery.record_drop(AuctionDropReason::NoRenderSource); continue; }; @@ -1552,7 +1549,7 @@ mod tests { ); assert_eq!(conversion.delivery.dropped_winner_count, 4); assert_eq!( - conversion.delivery.dropped_winner_reasons["no_render_source"], + conversion.delivery.dropped_winner_reasons[&AuctionDropReason::NoRenderSource], 2 ); assert_eq!( @@ -1641,7 +1638,7 @@ mod tests { conversion .delivery .dropped_winner_reasons - .contains_key("multiple_render_sources"), + .contains_key(&AuctionDropReason::MultipleRenderSources), "should report the exact ambiguous-source reason" ); } diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index af13c2238..02de23f19 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -163,6 +163,121 @@ pub struct AuctionContext<'a> { pub services: &'a RuntimeServices, } +/// Closed, local reason set for rejecting provider bids or undeliverable winners. +/// +/// These values are serialized only into existing auction debug/diagnostic +/// surfaces. They are not a persistence or external-event taxonomy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuctionDropReason { + /// Optional creative ID is present with an invalid type or value. + InvalidCreativeId, + /// Optional creative ID exceeds its UTF-8 byte bound. + CreativeIdTooLarge, + /// A positive integral dimension exceeds the supported range. + DimensionsOutOfRange, + /// An otherwise valid upstream bid ID is repeated in one provider response. + DuplicateUpstreamBidId, + /// A response contains no seat bids. + #[serde(rename = "empty_seatbid")] + EmptySeatBid, + /// A seat bid contains no usable bid array. + #[serde(rename = "empty_seatbid_bids")] + EmptySeatBidBids, + /// A creative URL is malformed, unsafe, or self-origin. + InvalidCreativeUrl, + /// A dimension is missing, malformed, nonpositive, or not requested. + InvalidDimensions, + /// A price is missing, malformed, nonfinite, or negative. + InvalidPrice, + /// The provider response violates the response-level contract. + InvalidProviderResponse, + /// The APS tag type is missing or unsupported. + InvalidTagType, + /// An upstream bid ID contains a forbidden control value or has the wrong type. + InvalidUpstreamBidId, + /// A valid sibling was preferred by deterministic per-slot reduction. + LostToHigherBid, + /// A provider bid is not an object. + MalformedBid, + /// APS creative metadata does not contain `creativeurl`. + MissingCreativeUrl, + /// Provider parsing was invoked without its request-local context. + MissingRequestContext, + /// A required upstream bid ID is absent or empty. + MissingUpstreamBidId, + /// A winner carries more than one render source. + MultipleRenderSources, + /// A winner has no render source. + NoRenderSource, + /// A typed renderer extension could not be serialized. + RendererExtensionSerializationFailed, + /// A validated renderer projection exceeds its bound. + RenderPayloadTooLarge, + /// APS script rendering is disabled by configuration. + ScriptRenderingDisabled, + /// A provider bid references an impression that was not dispatched. + UnknownImpression, + /// A provider bid declares a non-banner media type. + UnsupportedMediaType, + /// An upstream bid ID exceeds 64 UTF-8 bytes. + UpstreamBidIdTooLarge, +} + +impl AuctionDropReason { + /// Return the exact existing debug/projection literal. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::InvalidCreativeId => "invalid_creative_id", + Self::CreativeIdTooLarge => "creative_id_too_large", + Self::DimensionsOutOfRange => "dimensions_out_of_range", + Self::DuplicateUpstreamBidId => "duplicate_upstream_bid_id", + Self::EmptySeatBid => "empty_seatbid", + Self::EmptySeatBidBids => "empty_seatbid_bids", + Self::InvalidCreativeUrl => "invalid_creative_url", + Self::InvalidDimensions => "invalid_dimensions", + Self::InvalidPrice => "invalid_price", + Self::InvalidProviderResponse => "invalid_provider_response", + Self::InvalidTagType => "invalid_tag_type", + Self::InvalidUpstreamBidId => "invalid_upstream_bid_id", + Self::LostToHigherBid => "lost_to_higher_bid", + Self::MalformedBid => "malformed_bid", + Self::MissingCreativeUrl => "missing_creative_url", + Self::MissingRequestContext => "missing_request_context", + Self::MissingUpstreamBidId => "missing_upstream_bid_id", + Self::MultipleRenderSources => "multiple_render_sources", + Self::NoRenderSource => "no_render_source", + Self::RendererExtensionSerializationFailed => "renderer_extension_serialization_failed", + Self::RenderPayloadTooLarge => "render_payload_too_large", + Self::ScriptRenderingDisabled => "script_rendering_disabled", + Self::UnknownImpression => "unknown_impression", + Self::UnsupportedMediaType => "unsupported_media_type", + Self::UpstreamBidIdTooLarge => "upstream_bid_id_too_large", + } + } +} + +impl Ord for AuctionDropReason { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.as_str().cmp(other.as_str()) + } +} + +impl PartialOrd for AuctionDropReason { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Typed counts projected into the existing `drop_reasons` debug object. +pub type AuctionDropReasons = BTreeMap; + +/// Increment one typed local drop reason. +pub(crate) fn record_auction_drop(reasons: &mut AuctionDropReasons, reason: AuctionDropReason) { + *reasons.entry(reason).or_default() += 1; +} + /// URL used by the orchestrator when invoking a mediator from the collect /// path. Providers can `debug_assert` against this value to catch a mediator /// that has accidentally started depending on `context.request` carrying real @@ -634,7 +749,7 @@ pub struct OrchestratorExt { pub dropped_winner_count: usize, /// Machine-readable reasons for omitted winners. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub dropped_winner_reasons: BTreeMap, + pub dropped_winner_reasons: AuctionDropReasons, } /// Status of bid response. @@ -690,6 +805,30 @@ impl AuctionResponse { self.metadata.insert(key.into(), value); self } + + /// Project typed local drop reasons into the existing provider metadata surface. + #[must_use] + pub fn with_drop_reasons(mut self, reasons: &AuctionDropReasons) -> Self { + if !reasons.is_empty() { + let values = reasons + .iter() + .map(|(reason, count)| { + (reason.as_str().to_string(), serde_json::Value::from(*count)) + }) + .collect(); + self.metadata.insert( + "drop_reasons".to_string(), + serde_json::Value::Object(values), + ); + } + self + } + + /// Project one typed local drop reason into provider metadata. + #[must_use] + pub fn with_drop_reason(self, reason: AuctionDropReason) -> Self { + self.with_drop_reasons(&BTreeMap::from([(reason, 1)])) + } } #[cfg(test)] @@ -697,6 +836,52 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn typed_drop_reasons_use_exact_literals_in_provider_summary_metadata() { + let reasons = [ + AuctionDropReason::InvalidCreativeId, + AuctionDropReason::CreativeIdTooLarge, + AuctionDropReason::DimensionsOutOfRange, + AuctionDropReason::DuplicateUpstreamBidId, + AuctionDropReason::EmptySeatBid, + AuctionDropReason::EmptySeatBidBids, + AuctionDropReason::InvalidCreativeUrl, + AuctionDropReason::InvalidDimensions, + AuctionDropReason::InvalidPrice, + AuctionDropReason::InvalidProviderResponse, + AuctionDropReason::InvalidTagType, + AuctionDropReason::InvalidUpstreamBidId, + AuctionDropReason::LostToHigherBid, + AuctionDropReason::MalformedBid, + AuctionDropReason::MissingCreativeUrl, + AuctionDropReason::MissingRequestContext, + AuctionDropReason::MissingUpstreamBidId, + AuctionDropReason::MultipleRenderSources, + AuctionDropReason::NoRenderSource, + AuctionDropReason::RendererExtensionSerializationFailed, + AuctionDropReason::RenderPayloadTooLarge, + AuctionDropReason::ScriptRenderingDisabled, + AuctionDropReason::UnknownImpression, + AuctionDropReason::UnsupportedMediaType, + AuctionDropReason::UpstreamBidIdTooLarge, + ]; + for reason in reasons { + assert_eq!( + serde_json::to_value(reason).expect("drop reason should serialize"), + json!(reason.as_str()), + "serde and diagnostic literal should agree for {reason:?}" + ); + } + + let response = AuctionResponse::no_bid("aps", 12) + .with_drop_reason(AuctionDropReason::InvalidProviderResponse); + let summary = ProviderSummary::from(&response); + assert_eq!( + summary.metadata["drop_reasons"]["invalid_provider_response"], 1, + "publisher provider-summary projection should retain the typed reason" + ); + } + fn make_bid(bidder: &str) -> Bid { Bid { slot_id: "slot-1".to_owned(), diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 34fc54e0f..7d628f52b 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -27,9 +27,9 @@ use crate::auction::routing::ProviderAuctionInput; #[cfg(test)] use crate::auction::types::{AdSlot, AuctionContext, AuctionRequest}; use crate::auction::types::{ - AdSlot, ApsRendererV1, ApsRendererValidationResult, ApsTagType, AuctionContext, AuctionRequest, - AuctionResponse, Bid, BidRenderSourceV1, MediaType, RENDER_DIMENSION_MAX, - classify_aps_renderer_v1, + AdSlot, ApsRendererV1, ApsRendererValidationResult, ApsTagType, AuctionContext, + AuctionDropReason, AuctionDropReasons, AuctionRequest, AuctionResponse, Bid, BidRenderSourceV1, + MediaType, RENDER_DIMENSION_MAX, classify_aps_renderer_v1, record_auction_drop, }; use crate::error::TrustedServerError; use crate::integrations::{ @@ -60,6 +60,7 @@ const APS_SDK_SOURCE: &str = "prebid"; #[cfg(test)] const APS_SDK_VERSION: &str = "2.2.0"; const MAX_ACCOUNT_ID_BYTES: usize = 1024; +const MAX_BID_ID_BYTES: usize = 64; const MAX_CREATIVE_ID_BYTES: usize = 1024; const MAX_DEBUG_RESPONSE_PREVIEW_BYTES: usize = 512; const MAX_CREATIVE_URL_BYTES: usize = 4096; @@ -1274,7 +1275,7 @@ impl ApsAuctionProvider { &self, input: ApsRendererInput<'_>, publisher_origin: &str, - ) -> Option { + ) -> Result { let tag_type_value = match input.tag_type { ApsTagType::Iframe => "iframe", ApsTagType::Script => "script", @@ -1293,9 +1294,10 @@ impl ApsAuctionProvider { }] }] }); - let serialized = serde_json::to_vec(&envelope).ok()?; + let serialized = serde_json::to_vec(&envelope) + .map_err(|_| AuctionDropReason::InvalidProviderResponse)?; if serialized.len() > MAX_RENDER_ENVELOPE_BYTES { - return None; + return Err(AuctionDropReason::RenderPayloadTooLarge); } let renderer = BidRenderSourceV1::Aps(ApsRendererV1 { version: 1, @@ -1308,14 +1310,14 @@ impl ApsAuctionProvider { width: input.width, height: input.height, }); - let value = serde_json::to_value(&renderer).ok()?; - (classify_aps_renderer_v1(&value, publisher_origin) - == ApsRendererValidationResult::Accepted) - .then_some(renderer) - } - - fn increment_reason(reasons: &mut BTreeMap, reason: &'static str) { - *reasons.entry(reason.to_string()).or_default() += 1; + let value = serde_json::to_value(&renderer) + .map_err(|_| AuctionDropReason::InvalidProviderResponse)?; + if classify_aps_renderer_v1(&value, publisher_origin) + != ApsRendererValidationResult::Accepted + { + return Err(AuctionDropReason::InvalidProviderResponse); + } + Ok(renderer) } fn parse_bid( @@ -1323,91 +1325,104 @@ impl ApsAuctionProvider { value: &Json, slots: &HashMap<&str, &AdSlot>, publisher_origin: &str, - ) -> Result { - let bid_id = value - .get("id") - .and_then(Json::as_str) - .ok_or("missing_render_source")?; - if bid_id.is_empty() - || bid_id.len() > 64 - || bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) - { - return Err("invalid_bid_id"); + duplicate_ids: &HashSet, + ) -> Result { + let object = value.as_object().ok_or(AuctionDropReason::MalformedBid)?; + let Some(bid_id_value) = object.get("id") else { + return Err(AuctionDropReason::MissingUpstreamBidId); + }; + let bid_id = bid_id_value + .as_str() + .ok_or(AuctionDropReason::InvalidUpstreamBidId)?; + if bid_id.is_empty() { + return Err(AuctionDropReason::MissingUpstreamBidId); + } + if bid_id.len() > MAX_BID_ID_BYTES { + return Err(AuctionDropReason::UpstreamBidIdTooLarge); + } + if bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) { + return Err(AuctionDropReason::InvalidUpstreamBidId); + } + if duplicate_ids.contains(bid_id) { + return Err(AuctionDropReason::DuplicateUpstreamBidId); } let slot_id = value .get("impid") .and_then(Json::as_str) - .ok_or("unknown_impid")?; - let slot = slots.get(slot_id).ok_or("unknown_impid")?; + .ok_or(AuctionDropReason::UnknownImpression)?; + let slot = slots + .get(slot_id) + .ok_or(AuctionDropReason::UnknownImpression)?; let price = value .get("price") .and_then(Json::as_f64) .filter(|price| price.is_finite() && *price >= 0.0) - .ok_or("invalid_price")?; + .ok_or(AuctionDropReason::InvalidPrice)?; if value .get("mtype") .is_some_and(|mtype| mtype.as_i64() != Some(1)) { - return Err("unsupported_media_type"); + return Err(AuctionDropReason::UnsupportedMediaType); } let parse_dimension = |field: &str| { let number = value .get(field) .and_then(Json::as_f64) .filter(|number| number.is_finite() && number.fract() == 0.0 && *number > 0.0) - .ok_or("invalid_dimensions")?; + .ok_or(AuctionDropReason::InvalidDimensions)?; if number > RENDER_DIMENSION_MAX as f64 { - return Err("dimensions_out_of_range"); + return Err(AuctionDropReason::DimensionsOutOfRange); } - u32::try_from(number as u64).map_err(|_| "dimensions_out_of_range") + u32::try_from(number as u64).map_err(|_| AuctionDropReason::DimensionsOutOfRange) }; let width = parse_dimension("w")?; let height = parse_dimension("h")?; if !Self::compatible_dimensions(slot, width, height) { - return Err("invalid_dimensions"); + return Err(AuctionDropReason::InvalidDimensions); } let ext = value .get("ext") .and_then(Json::as_object) - .ok_or("missing_render_source")?; - let creative_url = ext - .get("creativeurl") - .and_then(Json::as_str) - .ok_or("missing_render_source")?; + .ok_or(AuctionDropReason::MissingCreativeUrl)?; + let Some(creative_url_value) = ext.get("creativeurl") else { + return Err(AuctionDropReason::MissingCreativeUrl); + }; + let creative_url = creative_url_value + .as_str() + .ok_or(AuctionDropReason::InvalidCreativeUrl)?; if !self.valid_creative_url(creative_url, publisher_origin) { - return Err("invalid_creative_url"); + return Err(AuctionDropReason::InvalidCreativeUrl); } let tag_type = match ext.get("tagtype").and_then(Json::as_str) { Some("iframe") => ApsTagType::Iframe, Some("script") if self.config.allow_script_creatives => ApsTagType::Script, - Some("script") => return Err("script_rendering_disabled"), - _ => return Err("unsupported_tagtype"), + Some("script") => return Err(AuctionDropReason::ScriptRenderingDisabled), + _ => return Err(AuctionDropReason::InvalidTagType), + }; + let creative_id = match value.get("crid") { + None => None, + Some(Json::String(creative_id)) if creative_id.is_empty() => None, + Some(Json::String(creative_id)) => Some(creative_id.clone()), + Some(_) => return Err(AuctionDropReason::InvalidCreativeId), }; - let creative_id = value - .get("crid") - .and_then(Json::as_str) - .filter(|creative_id| !creative_id.is_empty()) - .map(str::to_string); if creative_id .as_ref() .is_some_and(|creative_id| creative_id.len() > MAX_CREATIVE_ID_BYTES) { - return Err("creative_id_too_large"); + return Err(AuctionDropReason::CreativeIdTooLarge); } - let renderer = self - .build_renderer( - ApsRendererInput { - bid_id, - creative_id: creative_id.clone(), - tag_type, - creative_url, - price, - width, - height, - }, - publisher_origin, - ) - .ok_or("render_payload_too_large")?; + let renderer = self.build_renderer( + ApsRendererInput { + bid_id, + creative_id: creative_id.clone(), + tag_type, + creative_url, + price, + width, + height, + }, + publisher_origin, + )?; let adomain = value .get("adomain") .and_then(Json::as_array) @@ -1458,15 +1473,15 @@ impl ApsAuctionProvider { .is_some_and(|seatbids| !seatbids.is_array()) { return AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms) - .with_metadata("drop_reasons", json!({"unexpected_response_shape": 1})); + .with_drop_reason(AuctionDropReason::InvalidProviderResponse); } if value .get("cur") .and_then(Json::as_str) .is_some_and(|currency| !currency.eq_ignore_ascii_case(DEFAULT_CURRENCY)) { - return AuctionResponse::no_bid(APS_INTEGRATION_ID, response_time_ms) - .with_metadata("drop_reasons", json!({"unsupported_currency": 1})); + return AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms) + .with_drop_reason(AuctionDropReason::InvalidProviderResponse); } let slots: HashMap<&str, &AdSlot> = request @@ -1476,10 +1491,31 @@ impl ApsAuctionProvider { .collect(); let seatbids = value.get("seatbid").and_then(Json::as_array); let seatbid_count = seatbids.map_or(0, Vec::len); - let mut reasons = BTreeMap::new(); + let mut reasons = AuctionDropReasons::new(); let mut selected: HashMap = HashMap::new(); let mut dropped = 0_u64; + let mut id_counts = HashMap::<&str, usize>::new(); + for candidate in seatbids + .into_iter() + .flatten() + .filter_map(|seatbid| seatbid.get("bid").and_then(Json::as_array)) + .flatten() + { + if let Some(bid_id) = candidate.get("id").and_then(Json::as_str) + && !bid_id.is_empty() + && bid_id.len() <= MAX_BID_ID_BYTES + && !bid_id.bytes().any(|byte| byte <= 0x1f || byte == 0x7f) + { + *id_counts.entry(bid_id).or_default() += 1; + } + } + let duplicate_ids: HashSet = id_counts + .into_iter() + .filter(|(_, count)| *count > 1) + .map(|(bid_id, _)| bid_id.to_string()) + .collect(); + let publisher_origin = request .publisher .page_url @@ -1491,11 +1527,11 @@ impl ApsAuctionProvider { for seatbid in seatbids.into_iter().flatten() { let Some(bids) = seatbid.get("bid").and_then(Json::as_array) else { dropped += 1; - Self::increment_reason(&mut reasons, "empty_seatbid_bids"); + record_auction_drop(&mut reasons, AuctionDropReason::EmptySeatBidBids); continue; }; for value in bids { - match self.parse_bid(value, &slots, &publisher_origin) { + match self.parse_bid(value, &slots, &publisher_origin, &duplicate_ids) { Ok(candidate) => { let replace = selected.get(&candidate.slot_id).is_none_or(|current| { let candidate_price = candidate.price.unwrap_or_default(); @@ -1511,42 +1547,45 @@ impl ApsAuctionProvider { .is_some() { dropped += 1; - Self::increment_reason(&mut reasons, "lost_to_higher_bid"); + record_auction_drop( + &mut reasons, + AuctionDropReason::LostToHigherBid, + ); } } else { dropped += 1; - Self::increment_reason(&mut reasons, "lost_to_higher_bid"); + record_auction_drop(&mut reasons, AuctionDropReason::LostToHigherBid); } } Err(reason) => { dropped += 1; - Self::increment_reason(&mut reasons, reason); + record_auction_drop(&mut reasons, reason); } } } } if seatbid_count == 0 { - Self::increment_reason(&mut reasons, "empty_seatbid"); + record_auction_drop(&mut reasons, AuctionDropReason::EmptySeatBid); } let accepted = selected.len(); let metadata = [ ("seatbid_count".to_string(), json!(seatbid_count)), ("accepted_bid_count".to_string(), json!(accepted)), ("dropped_bid_count".to_string(), json!(dropped)), - ("drop_reasons".to_string(), json!(reasons)), ]; let mut response = if selected.is_empty() { AuctionResponse::no_bid(APS_INTEGRATION_ID, response_time_ms) } else { - AuctionResponse::success( - APS_INTEGRATION_ID, - selected.into_values().collect(), - response_time_ms, - ) + let bids = request + .slots + .iter() + .filter_map(|slot| selected.remove(&slot.id)) + .collect(); + AuctionResponse::success(APS_INTEGRATION_ID, bids, response_time_ms) }; response.metadata.extend(metadata); - response + response.with_drop_reasons(&reasons) } async fn parse_response_inner( @@ -1618,7 +1657,7 @@ impl ApsAuctionProvider { Err(error) => { log::warn!("Failed to parse APS response JSON: {error}"); let parsed = AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms) - .with_metadata("drop_reasons", json!({"unexpected_response_shape": 1})); + .with_drop_reason(AuctionDropReason::InvalidProviderResponse); return Ok(self.attach_debug_metadata( parsed, debug_enabled, @@ -1634,7 +1673,7 @@ impl ApsAuctionProvider { "APS cannot parse a successful bid response without the original auction request context" ); let response = AuctionResponse::error(APS_INTEGRATION_ID, response_time_ms) - .with_metadata("drop_reasons", json!({"missing_request_context": 1})); + .with_drop_reason(AuctionDropReason::MissingRequestContext); return Ok(self.attach_debug_metadata( response, debug_enabled, @@ -1969,7 +2008,8 @@ mod tests { use super::*; use crate::auction::test_support::canonical_parity_auction_request; use crate::auction::types::{ - AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, PublisherInfo, UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionDropReason, AuctionRequest, BidStatus, DeviceInfo, + PublisherInfo, UserInfo, }; use crate::integrations::IntegrationDocumentState; use crate::platform::test_support::{ @@ -2040,6 +2080,12 @@ mod tests { }) } + fn drop_count(response: &AuctionResponse, reason: AuctionDropReason) -> u64 { + response.metadata["drop_reasons"][reason.as_str()] + .as_u64() + .unwrap_or_default() + } + #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct RendererCorpus { @@ -2899,12 +2945,223 @@ mod tests { let response = provider.parse_aps_response(&value, 12, &request()); assert!(response.bids.is_empty()); assert_eq!( - response.metadata["drop_reasons"]["unexpected_response_shape"], + drop_count(&response, AuctionDropReason::InvalidProviderResponse), 1 ); } } + #[test] + fn upstream_bid_ids_are_required_bounded_control_free_and_response_unique() { + let provider = ApsAuctionProvider::new(config()); + let mut missing = bid("missing", 1.0, "iframe"); + missing + .as_object_mut() + .expect("should build an object bid") + .remove("id"); + let empty = bid("", 1.1, "iframe"); + let oversized = bid(&format!("{}x", "é".repeat(32)), 1.2, "iframe"); + let control = bid("control\u{0000}id", 1.3, "iframe"); + let duplicate_low = bid("duplicate", 1.4, "iframe"); + let duplicate_high = bid("duplicate", 9.0, "iframe"); + let valid_boundary = bid(&"é".repeat(32), 2.0, "iframe"); + + let response = provider.parse_aps_response( + &json!({"seatbid": [{"bid": [ + missing, + empty, + oversized, + control, + duplicate_low, + duplicate_high, + valid_boundary + ]}]}), + 12, + &request(), + ); + + assert_eq!(response.status, BidStatus::Success); + assert_eq!(response.bids.len(), 1); + assert_eq!( + response.bids[0].bid_id.as_deref(), + Some("é".repeat(32).as_str()) + ); + assert_eq!( + drop_count(&response, AuctionDropReason::MissingUpstreamBidId), + 2 + ); + assert_eq!( + drop_count(&response, AuctionDropReason::UpstreamBidIdTooLarge), + 1 + ); + assert_eq!( + drop_count(&response, AuctionDropReason::InvalidUpstreamBidId), + 1 + ); + assert_eq!( + drop_count(&response, AuctionDropReason::DuplicateUpstreamBidId), + 2 + ); + } + + #[test] + fn bid_validation_is_typed_and_isolated_from_a_valid_sibling() { + let provider = ApsAuctionProvider::new(config()); + let mut unknown_imp = bid("unknown-imp", 1.0, "iframe"); + unknown_imp["impid"] = json!("not-requested"); + let negative_price = bid("negative-price", -0.1, "iframe"); + let mut wrong_price = bid("wrong-price", 1.0, "iframe"); + wrong_price["price"] = json!("1.0"); + let mut zero_width = bid("zero-width", 1.0, "iframe"); + zero_width["w"] = json!(0); + let mut over_height = bid("over-height", 1.0, "iframe"); + over_height["h"] = json!(4097); + let mut unmatched_size = bid("unmatched-size", 1.0, "iframe"); + unmatched_size["w"] = json!(728); + unmatched_size["h"] = json!(90); + let mut missing_url = bid("missing-url", 1.0, "iframe"); + missing_url["ext"] + .as_object_mut() + .expect("should build a bid extension") + .remove("creativeurl"); + let mut invalid_url = bid("invalid-url", 1.0, "iframe"); + invalid_url["ext"]["creativeurl"] = json!("http://creative.example/render"); + let invalid_tag = bid("invalid-tag", 1.0, "video"); + let mut invalid_creative_id = bid("invalid-creative-id", 1.0, "iframe"); + invalid_creative_id["crid"] = json!(42); + let mut unsupported_media = bid("unsupported-media", 1.0, "iframe"); + unsupported_media["mtype"] = json!(2); + let valid = bid("valid-sibling", 2.0, "iframe"); + + let response = provider.parse_aps_response( + &json!({"seatbid": [{"bid": [ + "malformed", + unknown_imp, + negative_price, + wrong_price, + zero_width, + over_height, + unmatched_size, + missing_url, + invalid_url, + invalid_tag, + invalid_creative_id, + unsupported_media, + valid + ]}]}), + 12, + &request(), + ); + + assert_eq!(response.bids.len(), 1); + assert_eq!(response.bids[0].bid_id.as_deref(), Some("valid-sibling")); + for reason in [ + AuctionDropReason::MalformedBid, + AuctionDropReason::UnknownImpression, + AuctionDropReason::DimensionsOutOfRange, + AuctionDropReason::MissingCreativeUrl, + AuctionDropReason::InvalidCreativeUrl, + AuctionDropReason::InvalidTagType, + AuctionDropReason::InvalidCreativeId, + AuctionDropReason::UnsupportedMediaType, + ] { + assert_eq!(drop_count(&response, reason), 1, "should report {reason:?}"); + } + assert_eq!(drop_count(&response, AuctionDropReason::InvalidPrice), 2); + assert_eq!( + drop_count(&response, AuctionDropReason::InvalidDimensions), + 2 + ); + } + + #[test] + fn dimensions_accept_exact_requested_membership_at_contract_boundaries() { + let provider = ApsAuctionProvider::new(config()); + let mut auction_request = request(); + auction_request.slots = vec![ + AdSlot { + id: "minimum-slot".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 1, + height: 1, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }, + AdSlot { + id: "maximum-slot".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 4096, + height: 4096, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }, + ]; + let mut minimum = bid("minimum", 1.0, "iframe"); + minimum["impid"] = json!("minimum-slot"); + minimum["w"] = json!(1); + minimum["h"] = json!(1); + let mut maximum = bid("maximum", 1.0, "iframe"); + maximum["impid"] = json!("maximum-slot"); + maximum["w"] = json!(4096); + maximum["h"] = json!(4096); + + let response = provider.parse_aps_response( + &json!({"seatbid": [{"bid": [minimum, maximum]}]}), + 12, + &auction_request, + ); + + assert_eq!(response.status, BidStatus::Success); + assert_eq!(response.bids.len(), 2); + assert_eq!(response.bids[0].slot_id, "minimum-slot"); + assert_eq!(response.bids[1].slot_id, "maximum-slot"); + assert_eq!(response.metadata["dropped_bid_count"], 0); + } + + #[test] + fn contextual_currency_and_nonfinite_json_are_invalid_provider_responses() { + let provider = ApsAuctionProvider::new(config()); + for value in [ + json!({"contextual": {"slots": []}, "seatbid": [{"bid": [bid("valid", 1.0, "iframe")]}]}), + json!({"cur": "EUR", "seatbid": [{"bid": [bid("eur", 1.0, "iframe")]}]}), + ] { + let response = provider.parse_aps_response(&value, 12, &request()); + assert_eq!(response.status, BidStatus::Error); + assert_eq!( + drop_count(&response, AuctionDropReason::InvalidProviderResponse), + 1 + ); + } + + let body = br#"{"seatbid":[{"bid":[{"id":"overflow","impid":"fictional-slot","price":1e400,"w":300,"h":250,"ext":{"creativeurl":"https://creative.example/render","tagtype":"iframe"}}]}]}"#; + let response = futures::executor::block_on( + provider.parse_response_inner( + PlatformResponse::new( + edgezero_core::http::response_builder() + .status(StatusCode::OK) + .body(EdgeBody::from(body.to_vec())) + .expect("should build nonfinite APS response"), + ), + 12, + Some(&request()), + None, + false, + ), + ) + .expect("should reject nonfinite APS JSON safely"); + assert_eq!(response.status, BidStatus::Error); + assert_eq!( + drop_count(&response, AuctionDropReason::InvalidProviderResponse), + 1 + ); + } + #[test] fn debug_metadata_matches_pbs_httpcalls_shape() { let mut provider_config = config(); @@ -3093,7 +3350,7 @@ mod tests { let oversized = parse_with_context(&provider, oversized); assert_eq!( - malformed.metadata["drop_reasons"]["unexpected_response_shape"], + drop_count(&malformed, AuctionDropReason::InvalidProviderResponse), 1 ); assert_eq!( @@ -3173,7 +3430,7 @@ mod tests { assert!(response.bids.is_empty()); assert_eq!( - response.metadata["drop_reasons"]["unexpected_response_shape"], + drop_count(&response, AuctionDropReason::InvalidProviderResponse), 1 ); } @@ -3253,15 +3510,18 @@ mod tests { } #[test] - fn unsupported_currency_is_a_no_bid() { + fn unsupported_currency_is_an_invalid_provider_response() { let provider = ApsAuctionProvider::new(config()); let response = provider.parse_aps_response( &json!({"cur": "EUR", "seatbid": [{"bid": [bid("eur-bid", 1.0, "iframe")]}]}), 12, &request(), ); - assert_eq!(response.status, BidStatus::NoBid); - assert_eq!(response.metadata["drop_reasons"]["unsupported_currency"], 1); + assert_eq!(response.status, BidStatus::Error); + assert_eq!( + drop_count(&response, AuctionDropReason::InvalidProviderResponse), + 1 + ); } #[test] @@ -3274,10 +3534,7 @@ mod tests { ); assert_eq!(response.bids.len(), 1); assert_eq!(response.bids[0].bid_id.as_deref(), Some("valid")); - assert_eq!( - response.metadata["drop_reasons"]["missing_render_source"], - 1 - ); + assert_eq!(drop_count(&response, AuctionDropReason::MalformedBid), 1); } #[test] @@ -3353,7 +3610,7 @@ mod tests { ); assert!(response.bids.is_empty()); assert_eq!( - response.metadata["drop_reasons"]["missing_render_source"], + drop_count(&response, AuctionDropReason::MissingCreativeUrl), 1 ); assert_eq!(response.metadata["drop_reasons"]["invalid_dimensions"], 1); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 8be27585e..2474fa875 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3196,6 +3196,25 @@ pub(crate) fn write_bids_to_state( /// enabled cannot bloat every page render without bound. const MAX_AUCTION_DEBUG_DUMP_BYTES: usize = 256 * 1024; +/// Provider-metadata keys safe to surface in the on-page `ts-debug` dump. +/// +/// Fail-closed allowlist: any key not listed — notably `debug`, which carries +/// the resolved `OpenRTB` request (EC ID, `user.ext.eids`, the TC consent string, +/// `device.ip`, and `device.geo`) plus per-bidder `httpcalls` — is dropped so a +/// visitor's identity graph cannot reach the client-readable DOM even when +/// `[integration.prebid].debug` is also enabled. Full debug detail remains +/// available server-side via `log::trace!`. +const DEBUG_DUMP_METADATA_ALLOWLIST: &[&str] = &[ + "drop_reasons", + "error_type", + "status", + "message", + "responsetimemillis", + "errors", + "warnings", + "bidstatus", +]; + /// Per-bid creative preview length (in bytes) in the `ts-debug` dump. Mirrors /// the 512-byte upstream-body preview the prebid provider logs on an HTTP error /// (`integrations/prebid.rs`): enough to identify a creative without copying @@ -6698,8 +6717,8 @@ mod tests { use super::*; use crate::auction::orchestrator::OrchestrationResult; - use crate::auction::types::AuctionResponse; use crate::auction::types::{AdFormat, AdSlot, MediaType}; + use crate::auction::types::{AuctionDropReason, AuctionResponse}; use crate::integrations::IntegrationRegistry; use crate::platform::test_support::{ NoopSecretStore, StubHttpClient, build_services_with_http_client, @@ -6831,84 +6850,28 @@ mod tests { } #[test] - fn auction_debug_comment_reaches_the_shared_template_seam() { + fn auction_debug_comment_projects_typed_drop_reasons() { + let response = AuctionResponse::no_bid("aps", 12) + .with_drop_reason(AuctionDropReason::DuplicateUpstreamBidId); let result = OrchestrationResult { - provider_responses: vec![AuctionResponse::no_bid("prebid", 12)], + provider_responses: vec![response], mediator_response: None, winning_bids: std::collections::HashMap::new(), total_time_ms: 12, metadata: std::collections::HashMap::new(), }; - let state = AdBidsState::with_script("BIDS_SCRIPT"); - prepend_auction_debug_comment( - "stream", - &result, - &state, - &AuctionDebugCommentOptions::default(), - ); - - let seam = state.build_seam_script("[]"); + let state = Arc::new(Mutex::new(Some("BIDS_SCRIPT".to_string()))); - assert!( - seam.contains(""), - "the readable seam and its cache schema must move together" - ); - assert_eq!( - body_close_injection(AssemblyMode::Esi, false), - BodyCloseInjection::Marker(TEMPLATE_SEAM_PLACEHOLDER.to_string()), - "the seam's position must come from the parser, never from a byte search" - ); - assert_ne!( - TEMPLATE_SEAM_PLACEHOLDER, AD_ASSEMBLY_SEAM, - "a publisher document carrying the seam bytes must still receive \ - correctly positioned bids, which a shared marker would make impossible" - ); + 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, } + } - #[test] - fn shared_modes_render_byte_identical_documents_for_every_request_shape() { - let mode = AssemblyMode::Esi; - let shapes = every_shape(); - let baseline = render(mode, shapes[0]); + mod ssat_cache_policy_tests { + use super::*; + use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; + 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, PlatformError, PlatformHttpClient, PlatformPendingRequest, + PlatformResponse, PlatformSelectResult, + }; + use crate::test_support::tests::crate_test_settings_str; - for shape in &shapes[1..] { - let rendered = render(mode, *shape); - assert_eq!( - rendered, baseline, - "{mode:?}: rendered template differs for {shape:?}. A shared \ - template that varies by request freezes the first-filling \ - request's decision for every later reader." - ); - } - } + 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"; - #[test] - fn shared_mode_templates_contain_no_request_scoped_markers() { - // Byte-identity alone would be satisfied by rendering the same wrong - // thing every time, so also assert the specific things that must be - // absent. - let mode = AssemblyMode::Esi; - let rendered = render( - mode, - RequestShape { - ad_stack_ran: true, - diagnostics_active: true, - bids_available: true, - }, - ); - for forbidden in [ - ".adSlots", - ".bids=", - "__tsjs_gpt_diagnostics_active", - "history.replaceState", - ] { - assert!( - !rendered.contains(forbidden), - "{mode:?}: template contains request-scoped `{forbidden}`:\n{rendered}" - ); - } + struct DispatchingTestProvider; + + struct RangeAwareHttpClient { + stub: StubHttpClient, } - #[test] - fn inline_still_varies_by_request_as_it_must() { - // The shared-mode assertions would also pass if rendering were broken - // everywhere. Inline responses are per-navigation and never shared, so - // they *should* differ — this proves the test can tell the difference. - let with_ads = render( - AssemblyMode::Inline, - RequestShape { - ad_stack_ran: true, - diagnostics_active: false, - bids_available: true, - }, - ); - let without = render( - AssemblyMode::Inline, - RequestShape { - ad_stack_ran: false, - diagnostics_active: false, - bids_available: false, - }, - ); - assert_ne!( - with_ads, without, - "inline must still vary by request; if it does not, this harness is \ - not rendering what it claims to" - ); - assert!( - with_ads.contains(".adSlots"), - "inline with a matched slot should carry adSlots" - ); + impl RangeAwareHttpClient { + fn new() -> Self { + Self { + stub: StubHttpClient::new(), + } + } } - } - mod template_fingerprint_tests { - use super::*; + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for RangeAwareHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + if request.request.headers().contains_key(header::RANGE) { + self.stub.push_response_with_headers( + 206, + b"partial".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("content-range", "bytes 0-18/39"), + ], + ); + } else { + self.stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); + } + self.stub.send(request).await + } - /// Base settings with one integration's config replaced. - /// - /// Edits the parsed `[integrations]` map rather than appending TOML, so the two - /// fixtures differ in exactly the field under test — the base settings already - /// declare `[integrations.prebid]`, and a second table would not parse. - fn settings_with_prebid(enabled: bool, timeout_ms: u32) -> Settings { - let mut settings = create_test_settings(); - settings.integrations.insert( - "prebid".to_string(), - serde_json::json!({ - "enabled": enabled, - "external_bundle_url": "https://assets.example.com/prebid/bundle.js", - "timeout_ms": timeout_ms, - }), - ); - settings - } + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.stub.send_async(request).await + } - #[test] - fn disabling_an_integration_changes_the_fingerprint() { - // The fingerprint was `concatenated_hash(all_module_ids())` — every module - // compiled into the binary, so a constant for that binary. Turning an - // integration off changed the injected `") - .expect("should substitute the placeholder the transform emitted"); - - assert_eq!( - replaced, br#"

article

"#, - "should splice the payload exactly where the parser marked the body end" - ); - } - - #[test] - fn a_body_close_written_in_script_data_does_not_attract_the_seam() { - // The reverse byte search this replaced picked the string literal, spliced a - // `` terminated the - // publisher's script — in the served page and in the stored template alike. - let document = format!( - r#"

article

{TEMPLATE_SEAM_PLACEHOLDER}"# - ) - .into_bytes(); - - let replaced = replace_seam_placeholder(document, b"") - .expect("should substitute the placeholder the transform emitted"); - - assert_eq!( - replaced, - br#"

article

"#, - "should leave a body-close sequence inside script data untouched" - ); - } - - #[test] - fn a_publisher_copy_of_the_placeholder_refuses_substitution() { - let document = format!( - "

{TEMPLATE_SEAM_PLACEHOLDER}

article{TEMPLATE_SEAM_PLACEHOLDER}" - ) - .into_bytes(); - - let (returned, error) = - replace_seam_placeholder(document.clone(), b"") - .expect_err("should refuse a document that collides with the placeholder"); - - assert!( - matches!(error, SeamError::Repeated), - "should name the collision rather than guess an occurrence" - ); - assert_eq!( - returned, document, - "should hand back the publisher document byte for byte" - ); - } - - #[test] - fn a_document_without_the_placeholder_refuses_substitution() { - let document = b"

article

".to_vec(); - - let (returned, error) = - replace_seam_placeholder(document.clone(), b"") - .expect_err("should refuse a document the transform did not mark"); - - assert!( - matches!(error, SeamError::Missing), - "should name the absent placeholder rather than append blindly" - ); - assert_eq!( - returned, document, - "should hand back the document unchanged" - ); - } - - #[test] - fn parser_validation_does_not_change_the_cached_schema() { - assert_eq!(crate::platform::TEMPLATE_SCHEMA_VERSION, 4); - assert_eq!(AD_ASSEMBLY_SEAM, ""); - assert!(!contains_publisher_esi_directive( - AD_ASSEMBLY_SEAM.as_bytes() - )); - } - - /// Shareable HTML that already contains the seam marker. - /// - /// The marker is reserved, but publisher content can still contain it. The - /// transform adds its own terminal placeholder; repeated markers then make the - /// response bypass the template cache rather than requiring normalization. - fn queue_html_that_collides_with_the_marker(stub: &StubHttpClient) { - stub.push_response_with_headers( - 200, - format!( - "

origin

" - ) - .into_bytes(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("cache-control", "public, max-age=300"), - ], - ); - } - - #[tokio::test] - async fn origin_marker_collision_bypasses_template_cache_without_mutating_publisher_bytes() - { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_html_that_collides_with_the_marker(&stub); - queue_html_that_collides_with_the_marker(&stub); - - let cold = String::from_utf8( - body_of(run(&settings, &services, navigation_request()).await).await, - ) - .expect("cold document should be UTF-8"); - let warm = String::from_utf8( - body_of(run(&settings, &services, navigation_request()).await).await, - ) - .expect("warm document should be UTF-8"); - - assert!( - cold.contains("origin") && cold.contains("window.tsjs"), - "a reserved-comment collision must not turn a valid origin 200 into a 500" - ); - for document in [&cold, &warm] { - assert!( - document.contains(&format!("window.publisherMarker=\"{AD_ASSEMBLY_SEAM}\"")), - "should preserve a publisher marker inside script data: {document}" - ); - } - assert_eq!(stub.recorded_request_uris().len(), 2); - assert!( - cache - .entries - .lock() - .expect("should lock entries") - .is_empty(), - "should not store a template with a publisher marker collision" - ); - } - - #[tokio::test] - async fn html_without_an_explicit_body_gets_a_terminal_seam_instead_of_a_500() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - stub.push_response_with_headers( - 200, - b"
origin fragment
".to_vec(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("cache-control", "public, max-age=300"), - ], - ); - - let cold = String::from_utf8( - body_of(run(&settings, &services, navigation_request()).await).await, - ) - .expect("cold fragment should be UTF-8"); - let warm = String::from_utf8( - body_of(run(&settings, &services, navigation_request()).await).await, - ) - .expect("warm fragment should be UTF-8"); - - for document in [&cold, &warm] { - assert!(document.contains("origin fragment")); - assert!(!document.contains(AD_ASSEMBLY_SEAM)); - } - assert_eq!(stub.recorded_request_uris().len(), 1); - assert!( - !cache - .entries - .lock() - .expect("should lock entries") - .is_empty(), - "the recovered template should remain cacheable" - ); - } - - #[tokio::test] - async fn an_unsplittable_cached_template_is_a_miss_before_any_header_commits() { - // The hit path used to check only that a marker existed *somewhere* and - // leave the exactly-one check to the finalizer. By then the 200 and its - // headers were committed and, on the streaming adapter, the document head - // was already on the wire — so the only available failure was a truncated - // response. Falling back to the origin is a slower correct page. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - - // Fill the cache legitimately, then corrupt the stored body in place so the - // entry is found under exactly the key the next request derives. - let _ = body_of(run(&settings, &services, navigation_request()).await).await; - { - let stored_key = cache - .stored_keys - .lock() - .expect("should lock stored keys") - .first() - .cloned() - .expect("the cold request should have stored a template"); - let mut entries = cache.entries.lock().expect("should lock entries"); - let entry = entries - .get_mut(&stored_key.to_cache_key()) - .expect("the stored template should be readable"); - entry.body = format!( - "origin{AD_ASSEMBLY_SEAM}\ - {AD_ASSEMBLY_SEAM}" - ) - .into_bytes(); - } - - // The fall-back must be able to reach the origin. - queue_shareable_html(&stub); - let response = run(&settings, &services, navigation_request()).await; - let status = response.status(); - assert_eq!( - response - .headers() - .get(HEADER_X_TS_TEMPLATE_CACHE) - .and_then(|value| value.to_str().ok()), - Some("invalid"), - "an invalid-object recovery must be observable without exposing its key" - ); - let served = String::from_utf8(body_of(response).await) - .expect("served document should be UTF-8"); - - assert_eq!( - status, - StatusCode::OK, - "the reader should get a complete page, not a committed-then-broken one" - ); - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "an unusable entry must be treated as a miss and refetched" - ); - assert!( - !served.contains(AD_ASSEMBLY_SEAM), - "no marker may survive into the served page: {served}" - ); - assert!( - served.contains("window.tsjs"), - "and the fallback must still deliver the seam: {served}" - ); - assert!( - cache - .entries - .lock() - .expect("should lock entries") - .is_empty(), - "an unusable fresh object must be purged instead of forcing inline fallback \ - until its TTL expires" - ); - } - - /// The schema version whose templates carried an executable ESI tag at the seam. - /// - /// Pinned as a literal rather than derived from [`TEMPLATE_SCHEMA_VERSION`]: the - /// point is that the current version is *not* this one, and a derived value - /// would move with it and assert nothing. - const ESI_INCLUDE_SCHEMA_VERSION: u32 = 1; - - /// The exact schema-v1 marker retained only as a cache-compatibility fixture. - const LEGACY_ESI_INCLUDE: &str = ""; - - #[tokio::test] - async fn a_template_written_under_the_previous_schema_version_is_never_read() { - // v1 put an executable ESI include at the seam. v2 puts an inert comment - // there and hands slots to the scheduler, so a v1 entry has no marker this - // binary can find. `schema_version` is the only thing keeping the two apart - // — nothing purges template cache on deploy. - assert_ne!( - crate::platform::TEMPLATE_SCHEMA_VERSION, - ESI_INCLUDE_SCHEMA_VERSION, - "the seam marker changed shape, so the schema version must have moved \ - off the value under which the old marker was stored" - ); - - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - - // Fill the cache the ordinary way, then plant a predecessor's entry - // alongside it: same request, previous schema version, previous marker. - // Re-keyed from the key the store actually used, so the fixture cannot - // drift from what the request derives. - let _ = body_of(run(&settings, &services, navigation_request()).await).await; - { - let stored_key = cache - .stored_keys - .lock() - .expect("should lock stored keys") - .first() - .cloned() - .expect("the cold request should have stored a template"); - let mut entries = cache.entries.lock().expect("should lock entries"); - let mut predecessor = entries - .get(&stored_key.to_cache_key()) - .cloned() - .expect("the stored template should be readable"); - predecessor.body = - format!("origin{LEGACY_ESI_INCLUDE}") - .into_bytes(); - predecessor.metadata.schema_version = ESI_INCLUDE_SCHEMA_VERSION; - let mut old_key = stored_key; - old_key.schema_version = ESI_INCLUDE_SCHEMA_VERSION; - entries.insert(old_key.to_cache_key(), predecessor); - } - - let served = String::from_utf8( - body_of(run(&settings, &services, navigation_request()).await).await, - ) - .expect("served document should be UTF-8"); - - assert!( - cache - .lookups - .lock() - .expect("should lock lookups") - .iter() - .all(|key| key.schema_version != ESI_INCLUDE_SCHEMA_VERSION), - "no lookup may name a schema version whose templates this binary cannot \ - assemble" - ); - assert!( - !served.contains(", - ) -> Result, Report> { - Ok(Some(GeoInfo { - city: self.0.to_string(), - country: self.0.to_string(), - continent: self.0.to_string(), - latitude: 1.0, - longitude: 2.0, - metro_code: 3, - region: Some(self.0.to_string()), - asn: Some(4), - })) - } - } - - /// One synthetic user: an identity, a consent posture, and a location. - struct SyntheticUser { - ec_id: &'static str, - jurisdiction: crate::consent::jurisdiction::Jurisdiction, - geo_marker: &'static str, - } - - /// Runs one synthetic user against a fresh cache and returns the stored template. - /// - /// Fresh cache per user deliberately: the point is to compare what each *would* - /// store, so sharing a cache would let the first user's entry answer for the - /// second and the comparison would prove nothing. - async fn stored_template_for(user: &SyntheticUser) -> Vec { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = 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(Arc::clone(&stub) as Arc) - .geo(Arc::new(StubGeo(user.geo_marker))) - .client_info(ClientInfo::default()) - .template_cache( - Arc::clone(&cache) as Arc - ) - .build(); - queue_shareable_html(&stub); - - let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let consent = crate::consent::ConsentContext { - jurisdiction: user.jurisdiction.clone(), - ..Default::default() - }; - let mut ec_context = EcContext::new_for_test(Some(user.ec_id.to_string()), consent); - let publisher_response = handle_publisher_request( - &settings, - &services, - None, - &mut ec_context, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &[article_slot()], - registry: None, - }, - navigation_request(), - EdgeCacheHeader::SMaxageFallback, - ) - .await - .expect("should proxy publisher request"); - let _ = publisher_response_into_streaming_response( - publisher_response, - &Method::GET, - Arc::clone(&settings), - ®istry, - orchestrator, - services.clone(), - ) - .await - .expect("should finalize publisher response"); - - let entries = cache.entries.lock().expect("should lock entries"); - entries - .values() - .next() - .expect("a template should have been stored") - .body - .clone() - } - - #[tokio::test] - async fn two_users_differing_in_identity_consent_and_geo_store_the_same_template() { - // The gate the whole design rests on. The template is shared between - // visitors, so anything request-scoped that reaches it is one visitor's data - // served to the next. Byte-identity is the assertion because it does not - // depend on guessing which field might leak. - let alice = SyntheticUser { - ec_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.alice1", - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - geo_marker: "AliceCity", - }; - let bob = SyntheticUser { - ec_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.bobbb1", - jurisdiction: crate::consent::jurisdiction::Jurisdiction::Gdpr, - geo_marker: "BobCity", - }; - - let alice_template = stored_template_for(&alice).await; - let bob_template = stored_template_for(&bob).await; - - assert_eq!( - alice_template, bob_template, - "two users differing in identity, consent and geo must produce the same \ - shared template" - ); - - // Belt and braces: byte-identity would also hold if *both* templates leaked - // the same wrong thing, so name the values that must be absent. - let template = String::from_utf8(alice_template).expect("template should be utf-8"); - for forbidden in [ - alice.ec_id, - bob.ec_id, - alice.geo_marker, - bob.geo_marker, - "adSlots", - "window.tsjs", - ] { - assert!( - !template.contains(forbidden), - "`{forbidden}` must not appear in a shared template: {template}" - ); - } - } - - #[tokio::test] - async fn inline_mode_never_reads_or_writes_the_cache() { - // The shipped path. If this ever cached, per-user ad state would be shared - // between visitors — the exact failure the whole design exists to avoid. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("inline")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - queue_shareable_html(&stub); - - let _ = run(&settings, &services, navigation_request()).await; - let _ = run(&settings, &services, navigation_request()).await; - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "inline must fetch the origin every time" - ); - assert!( - cache - .entries - .lock() - .expect("should lock entries") - .is_empty(), - "inline must never write a shared template" - ); - } - - /// [`settings_with_mode`], with one integration configured a stated way. - /// - /// Edits the parsed `[integrations]` map rather than appending TOML, so two - /// fixtures differ in exactly the field under test. - fn settings_with_prebid_timeout(mode: &str, timeout_ms: u32) -> Settings { - let mut settings = settings_with_mode(mode); - settings.integrations.insert( - "prebid".to_string(), - serde_json::json!({ - "enabled": true, - "external_bundle_url": "https://assets.example.com/prebid/bundle.js", - "timeout_ms": timeout_ms, - }), - ); - settings - } - - /// Every key the cache was asked to store, rendered. - fn stored_cache_keys(cache: &MemoryTemplateCache) -> Vec { - cache - .stored_keys - .lock() - .expect("should lock stored keys") - .iter() - .map(crate::platform::TemplateCacheKey::to_cache_key) - .collect() - } - - /// Every key the cache was asked to read, rendered. - fn looked_up_cache_keys(cache: &MemoryTemplateCache) -> Vec { - cache - .lookups - .lock() - .expect("should lock lookups") - .iter() - .map(crate::platform::TemplateCacheKey::to_cache_key) - .collect() - } - - #[tokio::test] - async fn two_integration_configurations_never_share_a_template() { - // `template_fingerprint` folds the complete typed config, and its own - // tests call it directly — so reverting the *call site* back to the - // bundle-only hash, a constant for a given binary, left the entire suite - // green while every configuration silently shared one template. - // - // This drives the real request path and asserts on the keys the cache was - // actually handed, which is the only place that mutation is visible. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - let first = Arc::new(settings_with_prebid_timeout("esi", 1000)); - let second = Arc::new(settings_with_prebid_timeout("esi", 2500)); - queue_shareable_html(&stub); - queue_shareable_html(&stub); - - let _ = run(&first, &services, navigation_request()).await; - let _ = run(&second, &services, navigation_request()).await; - - let stored = stored_cache_keys(&cache); - assert_eq!( - stored.len(), - 2, - "each configuration must store its own template, got {stored:?}" - ); - assert_ne!( - stored[0], stored[1], - "two `[integrations]` configurations must key different templates; one key \ - serves the first configuration's injected markup to the second" - ); - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "the second configuration must have missed and fetched the origin rather \ - than reading the first's template" - ); - } - - #[tokio::test] - async fn one_integration_configuration_keys_one_template() { - // The converse, and the failure mode a fingerprint fix can introduce: - // over-invalidating is as total as under-invalidating. A fingerprint that - // moves between two equal configurations is a cache that never hits, which - // the spike would report as "no measurable benefit" rather than as a bug. - // - // The two `Settings` are parsed independently, so their `[integrations]` - // maps iterate in different orders — which is what exercises the sort. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - let first = Arc::new(settings_with_prebid_timeout("esi", 1000)); - let second = Arc::new(settings_with_prebid_timeout("esi", 1000)); - queue_shareable_html(&stub); - - let _ = run(&first, &services, navigation_request()).await; - let _ = run(&second, &services, navigation_request()).await; - - let looked_up = looked_up_cache_keys(&cache); - assert_eq!( - looked_up.len(), - 2, - "both requests must consult the cache, got {looked_up:?}" - ); - assert_eq!( - looked_up[0], looked_up[1], - "equal configurations must name one key, or the cache never hits" - ); - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "the second request must be served from the first's template" - ); - } - - fn cookie_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("sec-fetch-mode", "navigate") - .header(header::COOKIE, "ts-ec=abc123") - .body(EdgeBody::empty()) - .expect("should build cookie-bearing request") - } - - #[tokio::test] - async fn by_default_a_cookie_bearing_request_uses_no_shared_cache() { - // The shipped default, and the reason the cache is nearly inert on real - // traffic: TS sets its own identity cookie, so essentially every repeat - // visitor arrives carrying one and is excluded in both directions. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - queue_shareable_html(&stub); - - let _ = run(&settings, &services, cookie_navigation_request()).await; - let _ = run(&settings, &services, cookie_navigation_request()).await; - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "both requests must reach the origin" - ); - assert!( - cache - .entries - .lock() - .expect("should lock entries") - .is_empty(), - "and neither may store a template" - ); - } - - #[tokio::test] - async fn a_declared_cookie_independent_origin_lets_repeat_visitors_share() { - // The opt-in. Without it the spike can only ever measure first-ever page - // views, which is not the population the issue cares about. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let mut raw = settings_with_mode("esi"); - raw.creative_opportunities - .as_mut() - .expect("fixture configures creative opportunities") - .origin_is_cookie_independent = Some(true); - let settings = Arc::new(raw); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - queue_shareable_html(&stub); - - let _ = run(&settings, &services, cookie_navigation_request()).await; - let _ = run(&settings, &services, cookie_navigation_request()).await; - - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "the second cookie-bearing request should be served from the cache" - ); - } - - #[tokio::test] - async fn an_active_diagnostics_request_never_stores_a_template() { - // An independent review reintroduced a diagnostics leak scoped to - // A request-private diagnostics mutation could otherwise leak through a - // shared template. `requires_private_no_store()` is a - // strict superset of the condition under which diagnostics markup is - // emitted, and that stamp lands *before* the template cache gate reads response headers, - // so such a request never stores a template at all. - // - // That is a coincidence between two independent conditions, and the whole - // protection rests on it. This pins the consequence directly, so the - // relationship is checked rather than merely reasoned about. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - - let _ = run(&settings, &services, diagnostics_navigation_request()).await; - - assert!( - cache - .entries - .lock() - .expect("should lock entries") - .is_empty(), - "a reader running diagnostics must not contribute to a shared cache" - ); - } - - #[tokio::test] - async fn active_diagnostics_bypass_an_already_warm_template() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - queue_shareable_html(&stub); - - let _ = body_of(run(&settings, &services, navigation_request()).await).await; - let response = run(&settings, &services, diagnostics_navigation_request()).await; - let document = String::from_utf8(body_of(response).await) - .expect("diagnostics document should be UTF-8"); - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "request-private diagnostics must reach the origin even when an ordinary \ - shared template is warm" - ); - assert_eq!( - cache.lookups.lock().expect("should lock lookups").len(), - 1, - "the diagnostics request must not consult template cache at all" - ); - assert!( - document.contains("__tsjs_gpt_diagnostics_active"), - "origin fallback must retain the request-private diagnostics bootstrap" - ); - } - - #[tokio::test] - async fn datadome_suppressed_request_bypasses_a_warm_shared_template() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let mut raw = settings_with_mode("esi"); - raw.integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "client_side_key": "test-client-key", - }), - ) - .expect("should configure DataDome integration"); - let settings = Arc::new(raw); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - queue_shareable_html(&stub); - - let ordinary = run(&settings, &services, navigation_request()).await; - let ordinary_document = String::from_utf8(body_of(ordinary).await) - .expect("ordinary document should be UTF-8"); - assert!( - ordinary_document.contains("/integrations/datadome/tags.js"), - "the warm template fixture must contain the ordinary DataDome tag" - ); - - let mut suppressed_request = navigation_request(); - suppressed_request - .headers_mut() - .insert("sec-fetch-dest", HeaderValue::from_static("script")); - suppressed_request - .extensions_mut() - .insert(crate::integrations::datadome::DataDomeClientTagSuppressed); - let suppressed = run(&settings, &services, suppressed_request).await; - assert_eq!( - suppressed - .headers() - .get(HEADER_X_TS_TEMPLATE_CACHE) - .and_then(|value| value.to_str().ok()), - Some("bypass-request"), - "request-scoped tag suppression must not read a shared template" - ); - let suppressed_document = String::from_utf8(body_of(suppressed).await) - .expect("suppressed document should be UTF-8"); - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "the suppressed navigation must reach the origin even when template cache is warm" - ); - assert_eq!( - cache.lookups.lock().expect("should lock lookups").len(), - 1, - "the suppressed request must bypass template cache before lookup" - ); - assert!( - !suppressed_document.contains("/integrations/datadome/tags.js"), - "the request-scoped suppression decision must survive origin processing" - ); - } - - #[tokio::test] - async fn real_diagnostics_query_bypasses_template_cache_and_keeps_its_private_bootstrap() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let mut raw = settings_with_mode("esi"); - raw.integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should configure diagnostics"); - let settings = Arc::new(raw); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - - let mut request = navigation_request(); - *request.uri_mut() = "https://ts.example.com/article?ts_console=1" - .parse() - .expect("should parse diagnostics URI"); - let response = run(&settings, &services, request).await; - assert_eq!( - response - .headers() - .get(HEADER_X_TS_TEMPLATE_CACHE) - .and_then(|value| value.to_str().ok()), - Some("bypass-request") - ); - assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("private, no-store") - ); - assert!(response.headers().contains_key(header::SET_COOKIE)); - let document = String::from_utf8(body_of(response).await) - .expect("diagnostics document should be UTF-8"); - assert!(document.contains("__tsjs_gpt_diagnostics_active")); - assert!(document.contains("tsjs-gpt_diagnostics.min.js")); - assert!( - cache - .lookups - .lock() - .expect("should lock lookups") - .is_empty(), - "the real query activation must bypass lookup before origin work" - ); - } - - #[tokio::test] - async fn real_diagnostics_cookie_bypasses_a_warm_cookie_independent_template() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let mut raw = settings_with_mode("esi"); - raw.creative_opportunities - .as_mut() - .expect("fixture configures creative opportunities") - .origin_is_cookie_independent = Some(true); - raw.integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should configure diagnostics"); - let settings = Arc::new(raw); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - queue_shareable_html(&stub); - - let _ = body_of(run(&settings, &services, navigation_request()).await).await; - let mut diagnostics = navigation_request(); - diagnostics.headers_mut().insert( - header::COOKIE, - HeaderValue::from_static("__Host-ts-console=1"), - ); - let response = run(&settings, &services, diagnostics).await; - assert_eq!( - response - .headers() - .get(HEADER_X_TS_TEMPLATE_CACHE) - .and_then(|value| value.to_str().ok()), - Some("bypass-request") - ); - let document = String::from_utf8(body_of(response).await) - .expect("diagnostics document should be UTF-8"); - - assert_eq!(stub.recorded_request_uris().len(), 2); - assert_eq!( - cache.lookups.lock().expect("should lock lookups").len(), - 1, - "the diagnostics cookie must bypass the otherwise-eligible warm lookup" - ); - assert!(document.contains("__tsjs_gpt_diagnostics_active")); - } - - #[tokio::test] - async fn a_cache_backend_failure_falls_back_to_origin_and_is_observable() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - cache.fail_lookup.store(true, Ordering::Relaxed); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - - let response = run(&settings, &services, navigation_request()).await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response - .headers() - .get(HEADER_X_TS_TEMPLATE_CACHE) - .and_then(|value| value.to_str().ok()), - Some("backend-error") - ); - let document = String::from_utf8(body_of(response).await) - .expect("fallback document should be UTF-8"); - assert!(document.contains("origin")); - assert_eq!(stub.recorded_request_uris().len(), 1); - } - - #[tokio::test] - async fn a_cache_hit_streams_rather_than_buffering() { - // The property that makes the cache worth having, and the one that regressed - // silently. Buffered assembly held the first byte until the auction resolved - // — measured at ~100x worse TTFB than shipping nothing at all, because the - // inline path already streams and waits only at ``. - // - // Asserted on the body *shape* rather than on timing: a timing test would be - // flaky, and `EdgeBody::Stream` is the structural fact that produces the - // timing. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - - // Warm the cache, then take the hit. - let _ = run(&settings, &services, navigation_request()).await; - let warm = run(&settings, &services, navigation_request()).await; - - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "the second request must be a hit, or this asserts nothing" - ); - assert!( - warm.headers().get(header::CONTENT_LENGTH).is_none(), - "a streamed assembly has no length until bids resolve, and headers \ - commit before the first byte" - ); - - // The invariant that actually matters, and the one a body-shape assertion - // misses: a stream that awaited the auction before its first yield would - // still be an `EdgeBody::Stream` and would still hold the first byte. - // - // So pull exactly one chunk and prove the auction has not been collected - // yet — `ad_bids_state` is only written by the collector. No timing - // involved, so nothing to be flaky about. - let EdgeBody::Stream(mut stream) = warm.into_body() else { - panic!("a cache hit must stream, not buffer"); - }; - let first = stream - .next() - .await - .expect("the stream should yield a first chunk") - .expect("the first chunk should read"); - - assert!( - first.starts_with(b"") || first.starts_with(b"` seam emitted - // the marker because the mode was `Esi`, while assembly was skipped because - // the gate had refused a key — a fallback at one seam and not the other. - // - // Bypassing is the *normal* case against a real origin, so this path runs - // far more often than the shared one. It has to produce a working page. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - - // A `Set-Cookie` from the origin disqualifies the response, exactly as a - // real origin does. - stub.push_response_with_headers( - 200, - b"origin".to_vec(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("cache-control", "public, max-age=300"), - ("set-cookie", "sess=1"), - ], - ); - - let document = String::from_utf8( - body_of(run(&settings, &services, navigation_request()).await).await, - ) - .expect("response should be utf-8"); - - assert!( - cache - .entries - .lock() - .expect("should lock entries") - .is_empty(), - "the gate refused, so nothing may be stored" - ); - assert!( - !document.contains(AD_ASSEMBLY_SEAM), - "a marker must never be emitted when nothing will resolve it: {document}" - ); - assert!( - document.contains("window.tsjs"), - "and the reader must still get their bids: {document}" - ); - } - - #[tokio::test] - async fn the_seam_carries_slot_definitions_or_the_page_serves_no_ads() { - // Shared modes suppress the head slot script, so the seam is the only place - // `tsjs.adSlots` can come from. Sending only bids left it at its `[]` default, - // `adInit` defined nothing, and the page rendered perfectly with zero TS ads — - // green tests, healthy-looking page, no revenue. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - - let miss = body_of(run(&settings, &services, navigation_request()).await).await; - let hit = body_of(run(&settings, &services, navigation_request()).await).await; - - for (label, body) in [("miss", miss), ("hit", hit)] { - let text = String::from_utf8(body).expect("utf-8"); - assert!( - text.contains("var a=JSON.parse(") && text.contains("s(b,a)"), - "{label}: the seam must carry slot definitions and hand them to the \ - scheduler: {text}" - ); - assert!( - text.contains("test-slot"), - "{label}: the slot definitions must be populated, not `[]`" - ); - } - } - - #[tokio::test] - async fn an_assembled_response_is_private_even_when_the_ad_stack_is_off() { - // The private stamp was gated on `should_run_ad_stack`. A bot, prefetch, - // kill-switched or consent-denied request can still assemble an empty-bids - // document, and would have kept the origin's public caching directives — so a - // downstream cache could serve that to a later eligible reader. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - - let bot = HttpRequest::builder() - .method(Method::GET) - .uri("https://ts.example.com/article") - .header(header::HOST, "ts.example.com") - .header("sec-fetch-dest", "document") - .header("sec-fetch-mode", "navigate") - .header( - header::USER_AGENT, - "Googlebot/2.1 (+http://www.google.com/bot.html)", - ) - .body(EdgeBody::empty()) - .expect("should build bot request"); - let response = run(&settings, &services, bot).await; - - assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|v| v.to_str().ok()), - Some("private, no-store"), - "an assembled response must be private whatever the ad stack decided" - ); - } - - #[tokio::test] - async fn a_cache_hit_keeps_the_origin_security_headers() { - // Reconstructing headers keeps origin `Set-Cookie` and caching directives out - // of a shared cache, and also silently dropped Content-Security-Policy — - // a weaker page, served faster. Policy headers are per-URL, so they belong - // with the template. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - stub.push_response_with_headers( - 200, - b"origin".to_vec(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("cache-control", "public, max-age=300"), - ("content-security-policy", "default-src 'self'"), - ("x-frame-options", "SAMEORIGIN"), - ], - ); - - let _ = run(&settings, &services, navigation_request()).await; - let warm = run(&settings, &services, navigation_request()).await; - - assert_eq!( - warm.headers() - .get(header::CONTENT_SECURITY_POLICY) - .and_then(|v| v.to_str().ok()), - Some("default-src 'self'"), - "a hit must not drop the origin's CSP" - ); - assert_eq!( - warm.headers() - .get("x-frame-options") - .and_then(|v| v.to_str().ok()), - Some("SAMEORIGIN") - ); - } - - #[tokio::test] - async fn a_cache_hit_preserves_every_repeated_policy_header_in_order() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - stub.push_response_with_headers( - 200, - b"origin".to_vec(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("cache-control", "public, max-age=300"), - ("content-security-policy", "default-src 'self'"), - ("content-security-policy", "script-src 'self'"), - ("link", "; rel=preload; as=script"), - ("link", "; rel=preload; as=style"), - ("cross-origin-opener-policy", "same-origin"), - ("cross-origin-embedder-policy", "require-corp"), - ], - ); - - let _ = run(&settings, &services, navigation_request()).await; - let warm = run(&settings, &services, navigation_request()).await; - - let values = |name: &'static str| { - warm.headers() - .get_all(name) - .iter() - .map(|value| value.to_str().expect("policy header should be text")) - .collect::>() - }; - assert_eq!( - values("content-security-policy"), - ["default-src 'self'", "script-src 'self'"] - ); - assert_eq!( - values("link"), - [ - "; rel=preload; as=script", - "; rel=preload; as=style" - ] - ); - assert_eq!(values("cross-origin-opener-policy"), ["same-origin"]); - assert_eq!(values("cross-origin-embedder-policy"), ["require-corp"]); - } - - #[tokio::test] - async fn nonce_bearing_csp_bypasses_the_shared_template_cache() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - for _ in 0..2 { - stub.push_response_with_headers( - 200, - b"origin" - .to_vec(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("cache-control", "public, max-age=300"), - ( - "content-security-policy", - "default-src 'self'; script-src 'nonce-reader-nonce'", - ), - ], - ); - } - - let _ = body_of(run(&settings, &services, navigation_request()).await).await; - let _ = body_of(run(&settings, &services, navigation_request()).await).await; - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "a response-bound CSP nonce and its HTML must never be reused from template cache" - ); - assert!( - cache - .entries - .lock() - .expect("should lock entries") - .is_empty() - ); - } - - #[tokio::test] - async fn a_meta_delivered_nonce_policy_bypasses_the_shared_template_cache() { - // The response-header gate cannot see this policy at all. Storing the document - // would replay one response's nonce to every later reader — the exact thing the - // header check exists to prevent. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - for _ in 0..2 { - stub.push_response_with_headers( - 200, - br#"origin"# - .to_vec(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("cache-control", "public, max-age=300"), - ], - ); - } - - let cold = run(&settings, &services, navigation_request()).await; - assert_eq!( - cold.headers() - .get(HEADER_X_TS_TEMPLATE_CACHE) - .and_then(|value| value.to_str().ok()), - Some("bypass-response"), - "the cold response must refuse to store a nonce-bearing document" - ); - let _ = body_of(cold).await; - let _ = body_of(run(&settings, &services, navigation_request()).await).await; - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "the warm request must reach the origin, not a replayed nonce" - ); - assert!( - cache - .entries - .lock() - .expect("should lock entries") - .is_empty(), - "nothing nonce-bearing may reach the shared cache" - ); - } - - #[tokio::test] - async fn a_nonce_attribute_without_a_policy_bypasses_the_shared_template_cache() { - // Fail closed: the attributes say the document was written for a per-response - // policy, whether or not the policy itself survived to this scan. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - for _ in 0..2 { - stub.push_response_with_headers( - 200, - br#"origin"# - .to_vec(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("cache-control", "public, max-age=300"), - ], - ); - } - - let _ = body_of(run(&settings, &services, navigation_request()).await).await; - let _ = body_of(run(&settings, &services, navigation_request()).await).await; - - assert_eq!(stub.recorded_request_uris().len(), 2); - assert!( - cache - .entries - .lock() - .expect("should lock entries") - .is_empty() - ); - } - - #[tokio::test] - async fn a_body_close_in_script_data_corrupts_neither_the_cold_nor_the_warm_document() { - // No structural `` anywhere: a reverse byte search takes the string - // literal, and the payload's `` then terminates the publisher's script - // — in the response served cold and in the template every warm reader gets. - const PUBLISHER_SCRIPT: &str = r#""#; - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - stub.push_response_with_headers( - 200, - format!("{PUBLISHER_SCRIPT}

article

").into_bytes(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("cache-control", "public, max-age=300"), - ], - ); - - let cold = String::from_utf8( - body_of(run(&settings, &services, navigation_request()).await).await, - ) - .expect("the cold document should be UTF-8"); - let warm = String::from_utf8( - body_of(run(&settings, &services, navigation_request()).await).await, - ) - .expect("the warm document should be UTF-8"); - - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "the document is otherwise shareable and must still be stored and reused" - ); - for (label, document) in [("cold", &cold), ("warm", &warm)] { - assert!( - document.contains(PUBLISHER_SCRIPT), - "the {label} document must carry the publisher's script byte for byte: {document}" - ); - assert!( - !document.contains(AD_ASSEMBLY_SEAM), - "the {label} document must not ship an unresolved seam: {document}" - ); - } - } - - #[tokio::test] - async fn a_body_close_in_trailing_comment_data_does_not_attract_the_seam() { - // The reverse search took the *last* `` sequence, so the seam landed - // inside this comment and the assembled bids never executed. - const TRAILING_COMMENT: &str = ""; - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - stub.push_response_with_headers( - 200, - format!("

article

{TRAILING_COMMENT}") - .into_bytes(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("cache-control", "public, max-age=300"), - ], - ); - - let cold = String::from_utf8( - body_of(run(&settings, &services, navigation_request()).await).await, - ) - .expect("the cold document should be UTF-8"); - let warm = String::from_utf8( - body_of(run(&settings, &services, navigation_request()).await).await, - ) - .expect("the warm document should be UTF-8"); - - assert_eq!(stub.recorded_request_uris().len(), 1); - for (label, document) in [("cold", &cold), ("warm", &warm)] { - assert!( - document.contains(TRAILING_COMMENT), - "the {label} document must leave the publisher comment intact: {document}" - ); - assert!( - !document.contains(AD_ASSEMBLY_SEAM), - "the {label} document must not ship an unresolved seam: {document}" - ); - assert!( - !document.contains(TEMPLATE_SEAM_PLACEHOLDER), - "the transform-owned placeholder must never reach a reader: {document}" - ); - } - } - - #[tokio::test] - async fn a_post_is_never_answered_from_a_cached_get() { - // `handle_publisher_request` is the `*`-method fallback route, so a publisher - // path that renders a page on GET and accepts a form or webhook on POST reaches - // here for both. Serving the cached GET to the POST swallows the mutating - // request entirely: the origin never sees it, the caller gets 200 and a page, - // and nothing anywhere reports a problem. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - queue_shareable_html(&stub); - - // Warm the cache with a GET. - let _ = run(&settings, &services, navigation_request()).await; - assert_eq!(stub.recorded_request_uris().len(), 1); - - let post = HttpRequest::builder() - .method(Method::POST) - .uri("https://ts.example.com/article") - .header(header::HOST, "ts.example.com") - .body(EdgeBody::from("field=value")) - .expect("should build post request"); - let _ = run(&settings, &services, post).await; - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "the POST must reach the origin rather than being answered from the \ - cached GET" - ); - assert_eq!( - cache.entries.lock().expect("should lock entries").len(), - 1, - "and it must not store a template of its own" - ); - } - - #[tokio::test] - async fn an_authenticated_request_is_not_served_a_shared_template() { - // The stored template is perfectly cacheable; this request is not entitled - // to it. The store gate cannot express that, because it is a property of - // the reader rather than of the bytes — which is why the lookup re-checks. - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - queue_shareable_html(&stub); - - let _ = run(&settings, &services, navigation_request()).await; - assert_eq!( - cache.entries.lock().expect("should lock entries").len(), - 1, - "the cold request should have populated the cache" - ); - - 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); - let authenticated = HttpRequest::builder() - .method(Method::GET) - .uri("https://ts.example.com/article") - .header(header::HOST, "ts.example.com") - .header("sec-fetch-dest", "document") - .header(header::AUTHORIZATION, "Basic dXNlcjpwYXNz") - .body(EdgeBody::empty()) - .expect("should build authenticated request"); - let _ = handle_publisher_request( - &settings, - &services, - None, - &mut ec_context, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &[article_slot()], - registry: None, - }, - authenticated, - EdgeCacheHeader::SMaxageFallback, - ) - .await - .expect("should proxy publisher request"); - - assert_eq!( - stub.recorded_request_uris().len(), - 2, - "an authenticated request must reach the origin rather than read a \ - shared template" - ); - } - } - - mod template_cache_gate_tests { - //! `cache::core` stores whatever it is handed and rejects nothing, so every - //! one of these conditions is the caller's to enforce. Each is a leak vector - //! or an eligibility rule, not a preference. - - use super::*; - use crate::creative_opportunities::AssemblyMode; - use edgezero_core::http::HeaderName; - - fn headers(pairs: &[(HeaderName, &str)]) -> edgezero_core::http::HeaderMap { - let mut map = edgezero_core::http::HeaderMap::new(); - for (name, value) in pairs { - map.insert( - name.clone(), - HeaderValue::from_str(value).expect("should build header value"), - ); - } - map - } - - fn shareable() -> edgezero_core::http::HeaderMap { - headers(&[(header::CACHE_CONTROL, "max-age=60")]) - } - - /// The shipped default: no operator has stated what the origin varies on, so the - /// key covers nothing. Responses without a `Vary` are unaffected; any `Vary` at - /// all disqualifies. - fn nothing_covered() -> VarySpec { - VarySpec::new([]) - } - - #[test] - fn an_unconfigured_deployment_never_caches_a_varying_response() { - // The fail-closed default. An operator who has not stated the origin's Vary - // must not acquire a shared cache by omission — and a real origin varies on - // something, so this is the common path, not an edge case. - // Deliberately not `Accept-Encoding`: the shared path normalizes supported - // content codings to one identity template, so that header is covered - // whatever the operator configured. Using it here would test the - // structural-coverage carve-out rather than the drift guard. - let mut varying = shareable(); - varying.insert(header::VARY, HeaderValue::from_static("rsc")); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &varying, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::VaryNotCovered(VaryGap(vec![ - "rsc".to_string() - ]))), - "an unstated Vary must disqualify rather than silently under-key" - ); - } - - #[test] - fn an_origin_that_varies_on_cookie_is_refused_even_when_declared_independent() { - // The backstop that makes `origin_is_cookie_independent` safe to offer. The - // operator asserts their origin ignores cookies; if the origin then says - // otherwise, the assertion loses. Without this, a wrong assertion would - // silently cross-serve personalized HTML. - let mut varying = shareable(); - varying.insert(header::VARY, HeaderValue::from_static("Cookie")); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - // The operator's assertion has already been applied here: this is - // `false` precisely because they declared independence. - false, - StatusCode::OK, - "text/html", - &varying, - &VarySpec::new(["cookie".to_string()]), - ), - Some(TemplateCacheBypassReason::VaryCookie), - "the origin's declaration must override both cookie independence and an \ - accidentally configured per-cookie key" - ); - } - - #[test] - fn a_private_directive_on_a_second_cache_control_line_is_refused() { - // `HeaderMap::get` returns the first value only. An origin that sends - // `Cache-Control: public, max-age=300` and then `Cache-Control: private` on a - // separate line means exactly what one comma-joined line would mean, but the - // second line was invisible — so a response the origin marked private was - // written to a cache shared between readers. The `Vary` reads a few lines up - // already use `get_all` for the same reason. - let mut split = edgezero_core::http::HeaderMap::new(); - split.append( - header::CACHE_CONTROL, - HeaderValue::from_static("public, max-age=300"), - ); - split.append(header::CACHE_CONTROL, HeaderValue::from_static("private")); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &split, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::OriginNotShareable), - "a directive on any Cache-Control line must disqualify the response" - ); - } - - #[test] - fn a_no_store_directive_on_a_second_cache_control_line_is_refused() { - // Same defect, the other directive that matters — `no-store` is the one an - // origin uses for a response that must not be written down anywhere. - let mut split = edgezero_core::http::HeaderMap::new(); - split.append( - header::CACHE_CONTROL, - HeaderValue::from_static("max-age=60"), - ); - split.append(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &split, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::OriginNotShareable) - ); - } - - #[test] - fn cdn_specific_cache_policy_cannot_be_overridden_by_public_cache_control() { - for name in crate::response_privacy::CDN_CACHE_HEADERS { - let mut split = shareable(); - split.insert( - header::HeaderName::from_static(name), - HeaderValue::from_static("no-store"), - ); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &split, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::OriginNotShareable), - "template cache must fail closed on the CDN-specific policy header {name}" - ); - } - } - - #[test] - fn unsupported_vendor_freshness_does_not_authorize_template_cache() { - for name in crate::response_privacy::CDN_CACHE_HEADERS - .iter() - .filter(|name| **name != "surrogate-control") - { - let mut split = shareable(); - split.insert( - header::HeaderName::from_static(name), - HeaderValue::from_static("max-age=60"), - ); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &split, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::OriginNotShareable), - "the Fastly exception must not authorize the vendor policy {name}" - ); - } - } - - #[test] - fn observed_fastly_surrogate_policy_uses_edge_freshness_capped_by_configuration() { - let publisher_headers = headers(&[ - (header::CACHE_CONTROL, "public, max-age=60"), - ( - header::HeaderName::from_static("surrogate-control"), - "max-age=1200, stale-while-revalidate=21600, stale-if-error=604800", - ), - ]); - - assert_eq!( - template_cache_ttl( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &publisher_headers, - &TemplateCachePolicy::for_test(¬hing_covered(), Duration::from_secs(300),), - ), - Ok(Duration::from_secs(300)), - "Fastly's edge freshness should take precedence over the shorter browser \ - lifetime, while the configured safety ceiling remains authoritative" - ); - } - - #[test] - fn fastly_surrogate_freshness_takes_precedence_over_standard_freshness() { - for (cache_control, surrogate_control, expected) in [ - ("public, max-age=300", "max-age=30", 30), - ("public, max-age=30", "max-age=300", 300), - ] { - let publisher_headers = headers(&[ - (header::CACHE_CONTROL, cache_control), - ( - header::HeaderName::from_static("surrogate-control"), - surrogate_control, - ), - ]); - - assert_eq!( - template_cache_ttl( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &publisher_headers, - &TemplateCachePolicy::for_test( - ¬hing_covered(), - Duration::from_secs(600), - ), - ), - Ok(Duration::from_secs(expected)) - ); - } - } - - #[test] - fn surrogate_stale_windows_do_not_extend_fresh_reuse() { - let publisher_headers = headers(&[ - (header::CACHE_CONTROL, "public, max-age=300"), - ( - header::HeaderName::from_static("surrogate-control"), - "max-age=20, stale-while-revalidate=600, stale-if-error=1200", - ), - (header::AGE, "10"), - ]); - - assert_eq!( - template_cache_ttl( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &publisher_headers, - &TemplateCachePolicy::for_test(¬hing_covered(), Duration::from_secs(600),), - ), - Ok(Duration::from_secs(10)), - "stale windows are validated metadata, not fresh template cache lifetime" - ); - } - - #[test] - fn ambiguous_or_unsupported_surrogate_policy_fails_closed() { - for (policy, expected) in [ - ("max-age", TemplateCacheBypassReason::MalformedCachePolicy), - ( - "max-age=30, max-age=60", - TemplateCacheBypassReason::MalformedCachePolicy, - ), - ( - "max-age=tomorrow", - TemplateCacheBypassReason::MalformedCachePolicy, - ), - ( - "max-age=30, public", - TemplateCacheBypassReason::MalformedCachePolicy, - ), - ( - "stale-if-error=60", - TemplateCacheBypassReason::NoPositiveFreshness, - ), - ("max-age=0", TemplateCacheBypassReason::NoPositiveFreshness), - ( - "max-age=30,", - TemplateCacheBypassReason::MalformedCachePolicy, - ), - ] { - let mut publisher_headers = shareable(); - publisher_headers.insert( - header::HeaderName::from_static("surrogate-control"), - HeaderValue::from_str(policy).expect("should build Surrogate-Control"), - ); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &publisher_headers, - ¬hing_covered(), - ), - Some(expected), - "`{policy}` must fail closed" - ); - } - } - - #[test] - fn restrictive_surrogate_policy_is_never_overridden_by_standard_freshness() { - for directive in ["private", "no-store", "no-cache"] { - let mut publisher_headers = shareable(); - publisher_headers.insert( - header::HeaderName::from_static("surrogate-control"), - HeaderValue::from_str(directive).expect("should build Surrogate-Control"), - ); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &publisher_headers, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::OriginNotShareable), - "`{directive}` must remain authoritative" - ); - } - } - - #[test] - fn restrictive_standard_policy_is_never_overridden_by_surrogate_freshness() { - for directive in ["private", "no-store", "no-cache"] { - let publisher_headers = headers(&[ - ( - header::CACHE_CONTROL, - &format!("public, max-age=60, {directive}"), - ), - ( - header::HeaderName::from_static("surrogate-control"), - "max-age=1200", - ), - ]); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &publisher_headers, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::OriginNotShareable), - "standard `{directive}` must refuse template cache even with positive edge freshness" - ); - } - } - - #[test] - fn surrogate_control_can_authorize_fastly_edge_freshness_without_browser_freshness() { - let publisher_headers = headers(&[( - header::HeaderName::from_static("surrogate-control"), - "max-age=1200", - )]); - - assert_eq!( - template_cache_ttl( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &publisher_headers, - &TemplateCachePolicy::for_test(¬hing_covered(), Duration::from_secs(300),), - ), - Ok(Duration::from_secs(300)), - "Fastly edge freshness should not require browser freshness" - ); - } - - #[test] - fn repeated_cache_control_lines_without_a_disqualifier_still_cache() { - // The other direction: reading every value must not turn an ordinary - // multi-line `Cache-Control` into a bypass, or the fix would disable the - // cache instead of tightening it. - let mut split = edgezero_core::http::HeaderMap::new(); - split.append(header::CACHE_CONTROL, HeaderValue::from_static("public")); - split.append( - header::CACHE_CONTROL, - HeaderValue::from_static("max-age=60"), - ); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &split, - ¬hing_covered(), - ), - None - ); - } - - #[test] - fn origin_freshness_is_positive_age_adjusted_and_capped() { - let fresh_headers = headers(&[(header::CACHE_CONTROL, "public, max-age=300")]); - assert_eq!( - template_cache_ttl( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &fresh_headers, - &TemplateCachePolicy::for_test(¬hing_covered(), Duration::from_secs(60),), - ), - Ok(Duration::from_secs(60)) - ); - - let aged = headers(&[ - (header::CACHE_CONTROL, "s-maxage=50, max-age=300"), - (header::AGE, "35"), - ]); - assert_eq!( - template_cache_ttl( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &aged, - &TemplateCachePolicy::for_test(¬hing_covered(), Duration::from_secs(60),), - ), - Ok(Duration::from_secs(15)) - ); - - let old_date_without_age = headers(&[ - (header::CACHE_CONTROL, "public, max-age=60"), - (header::DATE, "Wed, 12 Aug 2026 08:00:00 GMT"), - ]); - let one_minute_later = httpdate::parse_http_date("Wed, 12 Aug 2026 08:01:00 GMT") - .expect("should parse fixture time"); - assert_eq!( - origin_shared_ttl_at( - &old_date_without_age, - one_minute_later, - Duration::from_secs(60), - ), - Err(TemplateCacheBypassReason::NoPositiveFreshness), - "an old Date is apparent age even when an upstream omitted Age" - ); - } - - #[test] - fn zero_exhausted_missing_and_malformed_freshness_are_refused() { - for (map, expected) in [ - ( - headers(&[(header::CACHE_CONTROL, "max-age=0")]), - TemplateCacheBypassReason::NoPositiveFreshness, - ), - ( - headers(&[(header::CACHE_CONTROL, "max-age=60"), (header::AGE, "60")]), - TemplateCacheBypassReason::NoPositiveFreshness, - ), - ( - headers(&[(header::CACHE_CONTROL, "public")]), - TemplateCacheBypassReason::NoPositiveFreshness, - ), - ( - headers(&[(header::CACHE_CONTROL, "max-age=tomorrow")]), - TemplateCacheBypassReason::MalformedCachePolicy, - ), - ( - headers(&[(header::CACHE_CONTROL, "max-age=\"60")]), - TemplateCacheBypassReason::MalformedCachePolicy, - ), - ( - headers(&[(header::CACHE_CONTROL, "max-age=+60")]), - TemplateCacheBypassReason::MalformedCachePolicy, - ), - ] { - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &map, - ¬hing_covered(), - ), - Some(expected) - ); - } - } - - #[test] - fn expires_can_authorize_but_never_extend_an_expired_response() { - let now = httpdate::parse_http_date("Wed, 12 Aug 2026 08:00:00 GMT") - .expect("should parse fixture time"); - let fresh = headers(&[ - (header::DATE, "Wed, 12 Aug 2026 08:00:00 GMT"), - (header::EXPIRES, "Wed, 12 Aug 2026 08:00:30 GMT"), - ]); - assert_eq!( - origin_shared_ttl_at(&fresh, now, Duration::from_secs(60)), - Ok(Duration::from_secs(30)) - ); - - let expired = headers(&[ - (header::DATE, "Wed, 12 Aug 2026 08:01:00 GMT"), - (header::EXPIRES, "Wed, 12 Aug 2026 08:00:30 GMT"), - ]); - assert_eq!( - origin_shared_ttl_at(&expired, now, Duration::from_secs(60)), - Err(TemplateCacheBypassReason::NoPositiveFreshness) - ); - } - - #[test] - fn request_semantics_bypass_template_cache_except_for_a_max_age_zero_reload() { - for (name, value) in [ - (header::CACHE_CONTROL, "no-cache"), - (header::CACHE_CONTROL, "max-age=30"), - (header::CACHE_CONTROL, "max-age=\"0"), - (header::CACHE_CONTROL, "min-fresh=10"), - (header::CACHE_CONTROL, "no-store"), - (header::PRAGMA, "no-cache"), - (header::PRAGMA, "legacy-extension, no-cache"), - (header::RANGE, "bytes=0-99"), - (header::IF_NONE_MATCH, "\"etag\""), - (header::IF_MODIFIED_SINCE, "Wed, 12 Aug 2026 08:00:00 GMT"), - ] { - let map = headers(&[(name.clone(), value)]); - assert!( - request_bypasses_template_cache(&map), - "{name}: {value} must bypass" - ); - } - assert!( - !request_bypasses_template_cache(&headers(&[(header::CACHE_CONTROL, "max-age=0")])), - "a browser reload may reuse template cache because the assembled response and auction \ - are still rebuilt for this reader" - ); - assert!(!request_bypasses_template_cache(&headers(&[( - header::CACHE_CONTROL, - "public" - )]))); - } - - #[test] - fn a_wildcard_vary_is_refused() { - // `VarySpec::uncovered_by` filters `*` out, with a comment saying the - // eligibility gate handles it. It did not — nothing rejected the wildcard, so - // a response the origin said no key can select was shareable. - let mut varying = shareable(); - varying.insert(header::VARY, HeaderValue::from_static("*")); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &varying, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::VaryWildcard) - ); - } - - #[test] - fn a_fully_covered_vary_is_cacheable() { - let mut varying = shareable(); - varying.insert( - header::VARY, - HeaderValue::from_static("rsc, Accept-Encoding"), - ); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &varying, - &VarySpec::new(["rsc".to_string(), "accept-encoding".to_string()]), - ), - None, - "a key covering everything the origin varies on is safe to store" - ); - } - - #[test] - fn config_drift_names_the_missing_header() { - // The failure this guards: the origin adds a header to its Vary, nobody - // updates config, and requests differing only in that header start sharing a - // template. The reason must name it, or diagnosing means a bisect. - let mut varying = shareable(); - varying.insert( - header::VARY, - HeaderValue::from_static("rsc, next-router-prefetch"), - ); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &varying, - &VarySpec::new(["rsc".to_string()]), - ), - Some(TemplateCacheBypassReason::VaryNotCovered(VaryGap(vec![ - "next-router-prefetch".to_string() - ]))), - "the uncovered header must be named" - ); - } - - #[test] - fn a_vary_split_across_repeated_headers_is_still_checked() { - // Vary is a list header, so an origin may send it once or many times. Reading - // only the first would let the rest through unkeyed. - let mut varying = shareable(); - varying.append(header::VARY, HeaderValue::from_static("rsc")); - varying.append(header::VARY, HeaderValue::from_static("cookie")); - - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &varying, - &VarySpec::new(["rsc".to_string()]), - ), - Some(TemplateCacheBypassReason::VaryCookie), - "a repeated Vary header must not hide names behind the first value" - ); - } - - #[test] - fn a_plain_shareable_html_200_is_cacheable() { - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &shareable(), - ¬hing_covered(), - ), - None, - "ESI shareable HTML 200 should be eligible" - ); - } - - #[test] - fn inline_mode_never_writes_a_template() { - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Inline, - false, - false, - StatusCode::OK, - "text/html", - &shareable(), - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::InlineMode), - "inline has no shared template to write" - ); - } - - #[test] - fn an_authorized_request_is_never_cached() { - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - true, - false, - StatusCode::OK, - "text/html", - &shareable(), - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::AuthorizedRequest), - "an authenticated response must not enter a shared cache" - ); - } - - #[test] - fn a_forwarded_request_cookie_disqualifies_even_without_set_cookie() { - // The dangerous case: session established on an earlier request, so this - // response carries no Set-Cookie, has no Cache-Control at all, is a 200, - // and is HTML — yet is personalized because TS forwarded the Cookie to - // origin unchanged. Every other condition reports it cacheable. - let no_cache_control = edgezero_core::http::HeaderMap::new(); - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - true, - StatusCode::OK, - "text/html", - &no_cache_control, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::CookieForwarded), - "cookie-personalized HTML must not become a shared template" - ); - } - - #[test] - fn an_origin_set_cookie_is_never_cached() { - let with_cookie = headers(&[ - (header::CACHE_CONTROL, "max-age=60"), - (header::SET_COOKIE, "sid=abc; Path=/"), - ]); - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &with_cookie, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::OriginSetCookie), - "caching this would replay one visitor's cookie to the next" - ); - } - - #[test] - fn non_shareable_cache_control_is_refused_case_insensitively() { - for directive in [ - "private", - "no-store", - "no-cache", - "Private, max-age=60", - "NO-STORE", - "public, No-Cache", - ] { - let map = headers(&[(header::CACHE_CONTROL, directive)]); - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &map, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::OriginNotShareable), - "`{directive}` should disqualify the response" - ); - } - } - - #[test] - fn a_datadome_block_is_refused_by_the_status_check() { - // DataDome replaces the document with a 403 - // (`integrations/datadome/protection.rs:778`). There is no separate - // marker to detect, and none is needed. - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::FORBIDDEN, - "text/html", - &shareable(), - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::NonOkStatus), - "a blocked document must not become the shared template" - ); - } - - #[test] - fn non_html_is_refused() { - for content_type in ["text/x-component", "application/json", ""] { - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - content_type, - &shareable(), - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::NotHtml), - "`{content_type}` has no HTML template to transform" - ); - } - } - - #[test] - fn unsupported_content_encoding_is_refused_before_representation_headers_change() { - let map = headers(&[ - (header::CACHE_CONTROL, "public, max-age=60"), - (header::CONTENT_ENCODING, "zstd"), - ]); - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &map, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::UnsupportedContentEncoding) - ); - - let mut repeated = headers(&[(header::CACHE_CONTROL, "public, max-age=60")]); - repeated.append(header::CONTENT_TYPE, HeaderValue::from_static("text/html")); - repeated.append( - header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), - ); - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - false, - false, - StatusCode::OK, - "text/html", - &repeated, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::MalformedRepresentationHeaders) - ); - } - - #[test] - fn leak_vectors_are_reported_before_mere_ineligibility() { - // A response that fails several conditions should name the most serious - // one, so an operator reading the log sees the security reason rather - // than a content-type quibble. - let map = headers(&[ - (header::CACHE_CONTROL, "private"), - (header::SET_COOKIE, "sid=abc"), - ]); - assert_eq!( - template_cache_bypass_reason( - AssemblyMode::Esi, - true, - false, - StatusCode::FORBIDDEN, - "application/json", - &map, - ¬hing_covered(), - ), - Some(TemplateCacheBypassReason::AuthorizedRequest), - "authorization is the most serious disqualifier and should win" - ); - } - } - - mod template_neutrality_tests { - //! The gate for #1009's shared-template design. - //! - //! An "absence of per-user values" scan is not sufficient here: the bug - //! that nearly shipped was a *conditionally present* element whose own - //! content was per-URL. These tests assert byte-identity across requests - //! that differ only in the gating decision. - - use super::*; - use crate::creative_opportunities::{ - AssemblyMode, CreativeOpportunityFormat, CreativeOpportunitySlot, - }; - - pub(super) fn slot() -> CreativeOpportunitySlot { - CreativeOpportunitySlot { - id: "atf".to_string(), - gam_unit_path: Some("/99999/example/home".to_string()), - div_id: Some("ad-atf".to_string()), - page_patterns: vec!["/**".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(), - compiled_unit: None, - } - } - - pub(super) fn settings_with_slots() -> Settings { - let mut settings = crate::test_support::tests::create_test_settings(); - // Construct the section rather than mutating it if present: the shared - // fixture does not carry `[creative_opportunities]`, and an `if let - // Some(..)` here would silently no-op and make the inline assertion - // below vacuous. - settings.creative_opportunities = Some(CreativeOpportunitiesConfig { - enabled: true, - gam_network_id: "99999".to_string(), - auction_timeout_ms: Some(500), - price_granularity: Default::default(), - section_root: None, - assembly_mode: None, - template_cache_vary: None, - template_cache_max_age_seconds: None, - origin_is_cookie_independent: None, - section_segment: None, - slot: vec![slot()], - }); - settings - } - - #[test] - fn shared_modes_emit_no_head_script_regardless_of_the_gating_decision() { - let settings = settings_with_slots(); - let slots = [slot()]; - let mode = AssemblyMode::Esi; - let ran = template_ad_slots_script(mode, true, &settings, &slots, "/"); - let did_not_run = template_ad_slots_script(mode, false, &settings, &slots, "/"); - - assert_eq!( - ran, did_not_run, - "{mode:?}: the template must be byte-identical whether or not the ad \ - stack ran; a cached object cannot carry one request's consent, bot, \ - prefetch or kill-switch decision" - ); - assert_eq!( - ran, None, - "{mode:?}: adSlots belongs in the per-request seam, not the template" - ); - } - - #[test] - fn inline_mode_keeps_its_request_dependent_behaviour() { - // Inline responses are per-navigation and never shared, so gating is - // correct there. This guards against "fixing" the shared-mode bug by - // breaking the shipped path. - let settings = settings_with_slots(); - let slots = [slot()]; - - assert!( - template_ad_slots_script(AssemblyMode::Inline, true, &settings, &slots, "/") - .is_some(), - "inline should emit adSlots when the ad stack runs" - ); - assert_eq!( - template_ad_slots_script(AssemblyMode::Inline, false, &settings, &slots, "/"), - None, - "inline should emit nothing when the ad stack does not run" - ); - } - - #[test] - fn shared_modes_are_neutral_across_differing_slot_matches() { - // Slot matching folds in the request path. Under a shared mode even - // that must not reach the template. - let settings = settings_with_slots(); - - let matched = template_ad_slots_script( - AssemblyMode::Esi, - true, - &settings, - &[slot()], - "/news/article", - ); - let unmatched = - template_ad_slots_script(AssemblyMode::Esi, true, &settings, &[], "/other"); - - assert_eq!( - matched, unmatched, - "the template must not vary with slot matching under a shared mode" - ); - } - } - - mod ssat_cache_policy_tests { - use super::*; - use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; - 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, PlatformError, PlatformHttpClient, PlatformPendingRequest, - PlatformResponse, PlatformSelectResult, - }; - 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; - - struct RangeAwareHttpClient { - stub: StubHttpClient, - } - - impl RangeAwareHttpClient { - fn new() -> Self { - Self { - stub: StubHttpClient::new(), - } - } - } - - #[async_trait::async_trait(?Send)] - impl PlatformHttpClient for RangeAwareHttpClient { - async fn send( - &self, - request: PlatformHttpRequest, - ) -> Result> { - if request.request.headers().contains_key(header::RANGE) { - self.stub.push_response_with_headers( - 206, - b"partial".to_vec(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("content-range", "bytes 0-18/39"), - ], - ); - } else { - self.stub.push_response_with_headers( - 200, - b"origin".to_vec(), - vec![("content-type", "text/html; charset=utf-8")], - ); - } - self.stub.send(request).await - } - - async fn send_async( - &self, - request: PlatformHttpRequest, - ) -> Result> { - self.stub.send_async(request).await - } - - async fn select( - &self, - pending_requests: Vec, - ) -> Result> { - self.stub.select(pending_requests).await - } - } - - #[async_trait::async_trait(?Send)] - impl AuctionProvider for DispatchingTestProvider { - fn provider_name(&self) -> &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(), - }) - .map(ProviderRequestOutcome::pending) - } - - 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!( - "{}\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 settings_with_disabled_ad_templates() -> Settings { - let toml = format!( - "{}\n[auction]\nenabled = true\n\n\ - [creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", - crate_test_settings_str() - ); - Settings::from_toml(&toml).expect("should parse settings with disabled ad templates") + Settings::from_toml(&toml).expect("should parse settings with disabled ad templates") } fn settings_with_disabled_auction() -> Settings { @@ -14626,6 +9929,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(); @@ -15859,6 +11252,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 fadf1b31d9fc05b933382dc282bd59f74839fa86 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 693/844] 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 6267b3c28ff5e9912847586e0f155a59d3430dcb 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 694/844] 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 4dd897ef04d4269319016e5885be8f389c222cfd 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 695/844] 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 13e7f21eac215bfaeb1906e82ef073b2eb9f278f 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 696/844] 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 a648669f87f244c0b644232f63464b570aa4a773 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 697/844] 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 e9ff5b279c5682ef7e0f30483d01a50fe24a1bb2 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 698/844] 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 c453b9c84..71c3fc3e6 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 @@ -885,15 +885,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 f8b6e66c8e2fbbb894157975951afe59ae0266d3 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 699/844] 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 8a3c41e5e8a557775e29f955573e29c9749e99d8 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 700/844] 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 d6451a9829a664c95a1139234ad576159cfc4eb0 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 701/844] 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 b438b0ddf012464d3dd849b7fe4c6f12b0df196d 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 702/844] 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 c9a36dbe051b415e1591f08165b64239c10c714c 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 703/844] Capture GPT subscriber membership at commit --- .../src/integrations/gpt_diagnostics/api.ts | 18 +- .../src/integrations/gpt_diagnostics/store.ts | 13 + .../integrations/gpt_diagnostics/api.test.ts | 74 + .../gpt_diagnostics/store.test.ts | 1433 +---------------- 4 files changed, 104 insertions(+), 1434 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 f7e951de7..e5a3c3d96 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,21 +6,7 @@ import type { GptDiagnosticsStoreSnapshot } from './store'; interface ApiStore { snapshot(): GptDiagnosticsStoreSnapshot; - subscribe(listener: () => void): () => void; - recordTrustedServerOpportunity( - slot: GptDiagnosticsSlotHandle, - auctionSlotId: string, - opportunity: GptDiagnosticsTrustedServerOpportunity, - trustedServerAuctionId?: string, - requestedSlotSizes?: ReadonlyArray - ): void; - recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void; - recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined; - recordTrustedServerCreativeResponse(attemptId: number): void; - recordTrustedServerCreativeFailure( - attemptId: number, - reason: GptDiagnosticsCreativeFailure - ): void; + subscribeCommits(listener: () => void): () => void; } interface ApiBindingManager { @@ -147,7 +133,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 71c3fc3e6..e7ed8891a 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 @@ -307,6 +307,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[] = []; @@ -576,6 +577,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); @@ -963,6 +969,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 7c9143291..bdc6e8854 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 @@ -444,6 +444,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 c2e2d2656..a6725a796 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 @@ -463,1428 +463,25 @@ describe('GptDiagnosticsStore', () => { expect(goodListener).toHaveBeenCalledTimes(1); }); - it('retains the Ad Manager identifiers GPT reported for the delivered ad', () => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('ad-slot-identity'); - - store.recordSlotRequested(slot); - store.recordSlotResponseReceived(slot); - store.recordSlotRenderEnded(slot, { - isEmpty: false, - adManager: { - lineItemId: 6543210987, - creativeId: 1234567890, - campaignId: 2345678901, - advertiserId: 3456789012, - }, - }); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.adManager, 'should keep every reported identifier').toEqual({ - lineItemId: 6543210987, - creativeId: 1234567890, - campaignId: 2345678901, - advertiserId: 3456789012, - }); - expect(cycle.responseClass).toBe('reservation'); - }); - - it('retains an observed outer slot box separately from GPT reported size', () => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('ad-slot-outer-box'); - - store.recordSlotRequested(slot); - store.recordSlotResponseReceived(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false, size: [1, 1] }); - store.recordObservedSlotSize(1, 1, [728, 90]); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.size).toEqual([1, 1]); - expect(cycle.observedSlotSize).toEqual([728, 90]); - }); - - it('rejects a stale prior-cycle outer-box measurement after a refresh', () => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('ad-slot-stale-outer-box'); - - store.recordSlotRequested(slot); - store.recordSlotResponseReceived(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false }); - store.recordSlotRequested(slot); - store.recordSlotResponseReceived(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false }); - store.recordObservedSlotSize(1, 1, [300, 250]); - store.recordObservedSlotSize(1, 2, [970, 250]); - - const requests = store.snapshot().slots[0].requests; - expect(requests[0].observedSlotSize).toBeUndefined(); - expect(requests[1].observedSlotSize).toEqual([970, 250]); - }); - - it('separates a fill without Ad Manager identifiers from a reservation', () => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('ad-slot-default'); - - store.recordSlotRequested(slot); - store.recordSlotRenderEnded(slot, { isEmpty: false }); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.responseClass).toBe('unclassified_non_empty'); - expect(cycle.adManager).toBeUndefined(); - }); - - it.each([ - { - name: 'a direct renderable candidate', - direct: 'renderable_candidate', - prebid: false, - publisher: false, - expectedPath: 'trusted_server_direct', - expectedOpportunity: 'renderable_candidate', - }, - { - name: 'a direct unrenderable candidate', - direct: 'unrenderable_candidate', - prebid: false, - publisher: false, - expectedPath: 'trusted_server_direct', - expectedOpportunity: 'unrenderable_candidate', - }, - { - name: 'a direct request without a candidate', - direct: 'no_candidate', - prebid: false, - publisher: false, - expectedPath: 'trusted_server_direct', - expectedOpportunity: 'no_candidate', - }, - { - name: 'a Prebid refresh', - direct: undefined, - prebid: true, - publisher: false, - expectedPath: 'prebid_refresh', - expectedOpportunity: undefined, - }, - { - name: 'competing direct and Prebid evidence', - direct: 'renderable_candidate', - prebid: true, - publisher: false, - expectedPath: 'competing', - expectedOpportunity: 'renderable_candidate', - }, - { - name: 'an unattributed request', - direct: undefined, - prebid: false, - publisher: false, - expectedPath: 'unattributed', - expectedOpportunity: undefined, - }, - { - name: 'a publisher refresh', - direct: undefined, - prebid: false, - publisher: true, - expectedPath: 'publisher_refresh', - expectedOpportunity: undefined, - }, - { - name: 'competing Prebid and publisher evidence', - direct: undefined, - prebid: true, - publisher: true, - expectedPath: 'competing', - expectedOpportunity: undefined, - }, - { - name: 'competing all source evidence', - direct: 'renderable_candidate', - prebid: true, - publisher: true, - expectedPath: 'competing', - expectedOpportunity: 'renderable_candidate', - }, - ] as const)( - 'attributes $name without inferring demand ownership', - ({ direct, prebid, publisher, expectedPath, expectedOpportunity }) => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('path-slot'); - - if (direct !== undefined) { - store.recordTrustedServerOpportunity(slot, 'auction-slot', direct); - } - if (prebid) store.recordPrebidRefresh([slot]); - if (publisher) store.recordPublisherRefresh([slot]); - store.recordSlotRequested(slot); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.requestPath).toBe(expectedPath); - expect(cycle.trustedServerOpportunity).toBe(expectedOpportunity); - } - ); - - it('consumes direct and Prebid markers exactly once', () => { - let now = 10; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('one-shot'); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - store.recordPrebidRefresh([slot]); - store.recordSlotRequested(slot); - now = 11; - store.recordSlotRequested(slot); - - const cycles = store.snapshot().slots[0].requests; - expect(cycles).toMatchObject([ - { - requestPath: 'competing', - trustedServerOpportunity: 'renderable_candidate', - }, - { requestPath: 'unattributed' }, - ]); - expect(cycles[1].trustedServerOpportunity).toBeUndefined(); - }); - - it('retains all configured requested slot sizes on only the correlated next request', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('requested-sizes'); - const formats: Array<[number, number]> = [ - [300, 250], - [728, 90], - [320, 50], - ]; - - store.recordTrustedServerOpportunity( - slot, - 'auction-slot', - 'renderable_candidate', - undefined, - formats - ); - formats[0]![0] = 1; - formats.push([970, 250]); - store.recordSlotRequested(slot); - store.recordSlotRequested(slot); - - const cycles = store.snapshot().slots[0]!.requests; - expect(cycles[0]?.requestedSlotSizes).toEqual([ - [300, 250], - [728, 90], - [320, 50], - ]); - expect(cycles[1]?.requestedSlotSizes).toBeUndefined(); - }); - - it('bounds and validates configured requested slot sizes before retaining them', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('validated-requested-sizes'); - const formats: Array<[number, number]> = Array.from( - { length: MAX_REQUESTED_SLOT_SIZES + 2 }, - (_, index) => [index + 1, 250] - ); - formats[0] = [0, 250]; - formats[1] = [300, Number.NaN]; - - store.recordTrustedServerOpportunity( - slot, - 'auction-slot', - 'renderable_candidate', - undefined, - formats - ); - store.recordSlotRequested(slot); - - const requested = store.snapshot().slots[0]!.requests[0]!.requestedSlotSizes; - expect(requested).toHaveLength(MAX_REQUESTED_SLOT_SIZES - 2); - expect(requested).not.toContainEqual([0, 250]); - expect(requested).not.toContainEqual([300, Number.NaN]); - expect(requested).not.toContainEqual([MAX_REQUESTED_SLOT_SIZES + 1, 250]); - }); - - it('consumes a combined request intent with independent source facts', () => { - let now = 10; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('intent'); - - store.recordTrustedServerOpportunity( - slot, - 'auction-slot', - 'renderable_candidate', - ' auction-123 ' - ); - now = 20; - store.recordPrebidRefresh([slot]); - now = 30; - store.recordPublisherRefresh([slot]); - now = 34; - store.recordSlotRequested(slot); - now = 35; - store.recordSlotRequested(slot); - - const cycles = store.snapshot().slots[0].requests; - expect(cycles).toMatchObject([ - { - requestPath: 'competing', - requestIntentId: 1, - trustedServerOpportunity: 'renderable_candidate', - trustedServerAuctionId: 'auction-123', - opportunityToRequestMs: 24, - }, - { requestPath: 'unattributed' }, - ]); - expect(cycles[1].requestIntentId).toBeUndefined(); - expect(deferred, 'source evidence must not schedule deferred work').toHaveLength(0); - }); - - it('keeps repeated source evidence single-source and increments consumed intent IDs', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const first = fakeSlot('repeat-intent-first'); - const second = fakeSlot('repeat-intent-second'); - store.recordPublisherRefresh([first]); - now = 2; - store.recordPublisherRefresh([first]); - store.recordSlotRequested(first); - now = 3; - store.recordTrustedServerOpportunity(second, 'second-auction', 'no_candidate'); - store.recordPublisherRefresh([second]); - store.recordSlotRequested(second); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'publisher_refresh', - requestIntentId: 1, - }); - expect(store.snapshot().slots[1].requests[0]).toMatchObject({ - requestPath: 'competing', - requestIntentId: 2, - }); - }); - - it('expires repeated source evidence lazily without scheduling timer work', () => { - let now = 0; - const deferred: Array<{ callback: () => void; delayMs: number }> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback, delayMs) => deferred.push({ callback, delayMs }), - }); - const consumed = fakeSlot('lazy-expiry-consumed'); - const expired = fakeSlot('lazy-expiry-expired'); - - for (let observation = 0; observation < 1_000; observation += 1) { - now = observation; - store.recordPublisherRefresh([consumed, expired]); - } - - expect(deferred, 'a refresh burst must not queue deferred work').toHaveLength(0); - - // The window runs from the newest observation, at t = 999. - now = 999 + REQUEST_PATH_ATTRIBUTION_WINDOW_MS - 1; - store.recordSlotRequested(consumed); - now += 1; - store.recordSlotRequested(expired); - - expect(store.snapshot().slots[0].requests[0].requestPath).toBe('publisher_refresh'); - expect(store.snapshot().slots[1].requests[0].requestPath).toBe('unattributed'); - expect(deferred, 'expiry must stay free of deferred work').toHaveLength(0); - }); - - it('replaces a fully expired intent instead of reviving its intent ID', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('expired-intent-replacement'); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate', 'stale'); - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS; - store.recordPublisherRefresh([slot]); - store.recordSlotRequested(slot); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle).toMatchObject({ requestPath: 'publisher_refresh', requestIntentId: 2 }); - expect( - cycle.trustedServerOpportunity, - 'expired direct evidence must not survive' - ).toBeUndefined(); - expect(cycle.trustedServerAuctionId).toBeUndefined(); - expect(deferred).toHaveLength(0); - }); - - it('derives a replacement from the most recent earlier filled render', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement'); - - store.recordSlotRequested(slot); - now = 2; - store.recordSlotResponseReceived(slot); - now = 3; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 20; - store.recordSlotRequested(slot); - now = 21; - store.recordSlotResponseReceived(slot); - now = 22; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - replacedRequestNumber: 1, - previousRenderToRequestMs: 17, - previousCreativeId: 101, - creativeChanged: true, - }); - }); - - it('compares primary and source-agnostic GPT creative identities for replacements', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement-fallback-creative'); - store.recordSlotRequested(slot); - now = 2; - store.recordSlotResponseReceived(slot); - now = 3; - store.recordSlotRenderEnded(slot, { - isEmpty: false, - adManager: { sourceAgnosticCreativeId: 101 }, - }); - now = 4; - store.recordSlotRequested(slot); - now = 5; - store.recordSlotResponseReceived(slot); - now = 6; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - previousCreativeId: 101, - creativeChanged: false, - }); - }); - - it('uses the latest earlier filled render while ignoring empty renders', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement-most-recent-filled'); - - store.recordSlotRequested(slot); - now = 2; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 3; - store.recordSlotRequested(slot); - now = 4; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - now = 5; - store.recordSlotRequested(slot); - now = 6; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - now = 7; - store.recordSlotRequested(slot); - now = 8; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 303 } }); - - const requests = store.snapshot().slots[0].requests; - expect(requests[2].replacedRequestNumber).toBeUndefined(); - expect(requests[3]).toMatchObject({ - replacedRequestNumber: 2, - previousRenderToRequestMs: 3, - previousCreativeId: 202, - creativeChanged: true, - }); - }); - - it('reports one-sided creative IDs without claiming a creative change', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const previousOnly = fakeSlot('replacement-previous-id-only'); - const currentOnly = fakeSlot('replacement-current-id-only'); - - store.recordSlotRequested(previousOnly); - now = 2; - store.recordSlotRenderEnded(previousOnly, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 3; - store.recordSlotRequested(previousOnly); - now = 4; - store.recordSlotRenderEnded(previousOnly, { isEmpty: false }); - - store.recordSlotRequested(currentOnly); - now = 5; - store.recordSlotRenderEnded(currentOnly, { isEmpty: false }); - now = 6; - store.recordSlotRequested(currentOnly); - now = 7; - store.recordSlotRenderEnded(currentOnly, { isEmpty: false, adManager: { creativeId: 202 } }); - - const [previousOnlyCycle] = store.snapshot().slots[0].requests.slice(-1); - const [currentOnlyCycle] = store.snapshot().slots[1].requests.slice(-1); - expect(previousOnlyCycle).toMatchObject({ replacedRequestNumber: 1, previousCreativeId: 101 }); - expect(previousOnlyCycle.creativeChanged).toBeUndefined(); - expect(currentOnlyCycle).toMatchObject({ replacedRequestNumber: 1 }); - expect(currentOnlyCycle.previousCreativeId).toBeUndefined(); - expect(currentOnlyCycle.creativeChanged).toBeUndefined(); - }); - - it('does not infer replacements once the earlier filled cycle has been evicted', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now }); - const slot = fakeSlot('replacement-evicted'); - - store.recordSlotRequested(slot); - now += 1; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - // Complete every filler cycle so the eviction pushes the only filled render - // out of retention and the final render still matches exactly one cycle. - for (let index = 0; index < MAX_REQUEST_CYCLES_PER_SLOT; index += 1) { - now += 1; - store.recordSlotRequested(slot); - now += 1; - store.recordSlotResponseReceived(slot); - now += 1; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - } - now += 1; - store.recordSlotRequested(slot); - now += 1; - store.recordSlotResponseReceived(slot); - now += 1; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - - const requests = store.snapshot().slots[0].requests; - expect( - requests.some((cycle) => cycle.adManager?.creativeId === 101), - 'the earlier filled cycle should have been evicted' - ).toBe(false); - const latestCycle = last(requests)!; - expect(latestCycle.renderAtMs, 'the final render must have been matched').toBeDefined(); - expect(latestCycle.adManager?.creativeId).toBe(202); - expect(latestCycle.replacedRequestNumber).toBeUndefined(); - expect(latestCycle.previousRenderToRequestMs).toBeUndefined(); - expect(latestCycle.previousCreativeId).toBeUndefined(); - }); - - it('keeps Trusted Server and publisher source evidence separate from replacement facts', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('replacement-source-evidence'); - - store.recordSlotRequested(slot); - now = 2; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 101 } }); - now = 3; - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - store.recordPublisherRefresh([slot]); - store.recordSlotRequested(slot); - now = 4; - store.recordSlotRenderEnded(slot, { isEmpty: false, adManager: { creativeId: 202 } }); - - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - requestPath: 'competing', - replacedRequestNumber: 1, - previousCreativeId: 101, - creativeChanged: true, - }); - }); - - it('expires request-path markers at the five-second boundary without waiting for timers', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const beforeBoundary = fakeSlot('before-boundary'); - const atBoundary = fakeSlot('at-boundary'); - - for (const slot of [beforeBoundary, atBoundary]) { - store.recordTrustedServerOpportunity( - slot, - `auction-${slot.getSlotElementId?.()}`, - 'no_candidate' - ); - store.recordPrebidRefresh([slot]); - } - - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS - 1; - store.recordSlotRequested(beforeBoundary); - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS; - store.recordSlotRequested(atBoundary); - - const [before, expired] = store.snapshot().slots.map((slot) => slot.requests[0]); - expect(before).toMatchObject({ - requestPath: 'competing', - trustedServerOpportunity: 'no_candidate', - }); - expect(expired).toMatchObject({ requestPath: 'unattributed' }); - expect(expired.trustedServerOpportunity).toBeUndefined(); - expect(deferred, 'the boundary must be enforced without marker timers').toHaveLength(0); - }); - - it('keeps the newest evidence when a source is re-observed inside the window', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('re-observed-source'); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - store.recordPrebidRefresh([slot]); - now = 100; - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'unrenderable_candidate'); - store.recordPrebidRefresh([slot]); - store.recordSlotRequested(slot); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'competing', - requestIntentId: 1, - trustedServerOpportunity: 'unrenderable_candidate', - }); - expect(deferred).toHaveLength(0); - }); - - it('expires sources independently and replaces Trusted Server auction metadata', () => { - let now = 0; - const deferred: Array<() => void> = []; + it('announces correctness commits synchronously while coalescing presentation work', () => { + const scheduled: Array<() => void> = []; const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('independent-expiry'); - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate', 'old'); - now = 1; - store.recordPrebidRefresh([slot]); - now = 2; - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'no_candidate'); - now = REQUEST_PATH_ATTRIBUTION_WINDOW_MS + 1; - store.recordSlotRequested(slot); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'trusted_server_direct', - trustedServerOpportunity: 'no_candidate', + now: () => 1, + schedule: (callback) => scheduled.push(callback), }); - expect(store.snapshot().slots[0].requests[0].trustedServerAuctionId).toBeUndefined(); - }); + const commitListener = vi.fn(); + const presentationListener = vi.fn(); + store.subscribeCommits(commitListener); + store.subscribe(presentationListener); - it('uses replacement Trusted Server evidence for latency and removes an unconsumed final source', () => { - let now = 0; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const repeated = fakeSlot('repeated-trusted-server-evidence'); - const unconsumed = fakeSlot('unconsumed-trusted-server-evidence'); + store.markGptObserved(); + store.recordSlotRequested(fakeSlot('commit-membership')); - store.recordTrustedServerOpportunity(repeated, 'auction-slot', 'renderable_candidate'); - now = 40; - store.recordTrustedServerOpportunity(repeated, 'auction-slot', 'no_candidate'); - now = 50; - store.recordSlotRequested(repeated); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - requestPath: 'trusted_server_direct', - trustedServerOpportunity: 'no_candidate', - opportunityToRequestMs: 10, - }); - - now = 60; - store.recordTrustedServerOpportunity(unconsumed, 'other-auction-slot', 'no_candidate'); - now += REQUEST_PATH_ATTRIBUTION_WINDOW_MS; - store.recordSlotRequested(unconsumed); - - const unconsumedCycle = store.snapshot().slots[1].requests[0]; - expect(unconsumedCycle.requestPath).toBe('unattributed'); - expect(unconsumedCycle.requestIntentId).toBeUndefined(); - }); - - it('retains only valid bounded auction IDs without dropping Trusted Server intent', () => { - const valid = 'a'.repeat(256); - const cases: Array<[unknown, string | undefined]> = [ - [valid, valid], - ['', undefined], - [' ', undefined], - [123, undefined], - ['é'.repeat(129), undefined], - ]; - for (const [auctionId, expected] of cases) { - const store = new GptDiagnosticsStore({ now: () => 1, defer: () => undefined }); - const slot = fakeSlot(`auction-id-${String(auctionId).length}`); - store.recordTrustedServerOpportunity( - slot, - 'auction-slot', - 'renderable_candidate', - auctionId as string - ); - store.recordSlotRequested(slot); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.requestPath).toBe('trusted_server_direct'); - expect(cycle.trustedServerAuctionId).toBe(expected); - } - }); - - it('does not mutate an open request cycle when a later direct marker arrives', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('open-cycle'); - - store.recordSlotRequested(slot); - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'renderable_candidate'); - - const openCycle = store.snapshot().slots[0].requests[0]; - expect(openCycle).toMatchObject({ requestPath: 'unattributed' }); - expect(openCycle.trustedServerOpportunity).toBeUndefined(); - - store.recordSlotRequested(slot); - expect(store.snapshot().slots[0].requests[1]).toMatchObject({ - requestPath: 'trusted_server_direct', - trustedServerOpportunity: 'renderable_candidate', - }); - }); - - it('ignores malformed diagnostic marker inputs without throwing', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const slot = fakeSlot('valid-marker'); - - expect(() => - store.recordTrustedServerOpportunity(null as never, 'auction-slot', 'renderable_candidate') - ).not.toThrow(); - expect(() => - store.recordTrustedServerOpportunity(slot, 'auction-slot', 'invalid' as never) - ).not.toThrow(); - expect(() => store.recordPrebidRefresh(null as never)).not.toThrow(); - expect(() => store.recordPrebidRefresh([null, 1, slot] as never)).not.toThrow(); - - store.recordSlotRequested(slot); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle).toMatchObject({ requestPath: 'prebid_refresh' }); - expect(cycle.trustedServerOpportunity).toBeUndefined(); - }); - - it.each(['renderable_candidate', 'unrenderable_candidate'] as const)( - 'moves an explicit non-empty %s to unconfirmed after one deferred notification', - (opportunity) => { - let now = 10; - const deferred: Array<{ callback: () => void; delayMs: number }> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - schedule: (callback) => callback(), - defer: (callback, delayMs) => deferred.push({ callback, delayMs }), - }); - const listener = vi.fn(); - const slot = fakeSlot(`delivery-${opportunity}`); - store.subscribe(listener); - - store.recordTrustedServerOpportunity(slot, 'auction-slot', opportunity); - store.recordSlotRequested(slot); - expect(deferred, 'recording intent must not defer work').toHaveLength(0); - listener.mockClear(); - - now = 30; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('pending'); - expect(deferred).toHaveLength(1); - expect(deferred[0].delayMs).toBe(TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS); - expect(listener).toHaveBeenCalledTimes(1); - - now = 30 + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; - deferred[0].callback(); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('candidate_unconfirmed'); - expect(listener).toHaveBeenCalledTimes(2); - expect(deferred).toHaveLength(1); - } - ); - - it.each([ - { - name: 'an explicit no-candidate fill', - opportunity: 'no_candidate', - renderFacts: { isEmpty: false }, - expected: 'no_candidate', - }, - { - name: 'a fill without a direct opportunity', - opportunity: undefined, - renderFacts: { isEmpty: false }, - expected: 'unknown', - }, - { - name: 'a render with omitted fill state', - opportunity: 'renderable_candidate', - renderFacts: {}, - expected: 'unknown', - }, - { - name: 'an empty render', - opportunity: 'renderable_candidate', - renderFacts: { isEmpty: true }, - expected: 'not_applicable', - }, - { - name: 'a pre-render request', - opportunity: 'renderable_candidate', - renderFacts: undefined, - expected: 'not_applicable', - }, - ] as const)( - 'derives $name from observed evidence only', - ({ opportunity, renderFacts, expected }) => { - let now = 10; - const deferred: Array<() => void> = []; - const store = new GptDiagnosticsStore({ - now: () => now, - defer: (callback) => deferred.push(callback), - }); - const slot = fakeSlot('delivery-state'); - - if (opportunity === undefined) { - store.recordPrebidRefresh([slot]); - } else { - store.recordTrustedServerOpportunity(slot, 'auction-slot', opportunity); - } - store.recordSlotRequested(slot); - deferred.shift()?.(); - now = 30; - if (renderFacts !== undefined) store.recordSlotRenderEnded(slot, renderFacts); - - expect(store.snapshot().slots[0].requests[0].delivery).toBe(expected); - expect(deferred, 'should not schedule an attribution-boundary notification').toHaveLength(0); - now = 30 + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; - expect(store.snapshot().slots[0].requests[0].delivery).toBe(expected); - } - ); - - it.each([ - { name: 'omitted fill state', facts: {}, expected: undefined }, - { name: 'an empty render', facts: { isEmpty: true }, expected: 'empty' }, - { - name: 'an explicit backfill', - facts: { isEmpty: false, isBackfill: true }, - expected: 'backfill', - }, - { - name: 'an explicit reservation', - facts: { isEmpty: false, adManager: { lineItemId: 123 } }, - expected: 'reservation', - }, - { - name: 'a source-agnostic identity confirmed as non-backfill', - facts: { - isEmpty: false, - isBackfill: false, - adManager: { sourceAgnosticLineItemId: 123 }, - }, - expected: 'reservation', - }, - { - name: 'a source-agnostic identity without a backfill fact', - facts: { isEmpty: false, adManager: { sourceAgnosticLineItemId: 123 } }, - expected: 'unclassified_non_empty', - }, - { - name: 'an otherwise unclassified non-empty render', - facts: { isEmpty: false }, - expected: 'unclassified_non_empty', - }, - ] as const)('classifies $name only from explicit render facts', ({ facts, expected }) => { - const store = new GptDiagnosticsStore({ now: () => 10 }); - const slot = fakeSlot('response-class'); - - store.recordSlotRequested(slot); - store.recordSlotRenderEnded(slot, facts); - - expect(store.snapshot().slots[0].requests[0].responseClass).toBe(expected); - }); - - it('correlates a creative request and response to the selected request cycle', () => { - let now = 10; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('creative-selected'); - associateSlot(store, slot, 'auction-selected'); - store.recordSlotRequested(slot); - - now = 20; - const attemptId = store.recordTrustedServerCreativeRequest('auction-selected'); - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 20, - delivery: 'trusted_server_selected', - }); - - now = 25; - store.recordTrustedServerCreativeResponse(attemptId!); - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 20, - trustedServerCreativeResponseAtMs: 25, - delivery: 'trusted_server_response_sent', - }); - }); - - it('accepts late positive creative evidence after the candidate observation timeout', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('late-positive'); - associateSlot(store, slot, 'auction-late'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - - now = 1 + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; - expect(store.snapshot().slots[0].requests[0].delivery).toBe('candidate_unconfirmed'); - - const attemptId = store.recordTrustedServerCreativeRequest('auction-late'); - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_selected'); - now += 1; - store.recordTrustedServerCreativeResponse(attemptId!); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_response_sent'); - }); - - it('keeps the first request timestamp and live ID across duplicate creative requests', () => { - let now = 1; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('creative-retry'); - associateSlot(store, slot, 'auction-retry'); - store.recordSlotRequested(slot); - - now = 5; - const firstId = store.recordTrustedServerCreativeRequest('auction-retry'); - now = 9; - const duplicateId = store.recordTrustedServerCreativeRequest('auction-retry'); - - expect(duplicateId).toBe(firstId); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(5); - }); - - it('records each safe creative failure once in first-observed order and can later succeed', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('creative-failures'); - associateSlot(store, slot, 'auction-failures'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-failures')!; - - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - store.recordTrustedServerCreativeFailure(attemptId, 'missing_render_source'); - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - store.recordTrustedServerCreativeFailure(attemptId, 'invalid_cache_payload'); - store.recordTrustedServerCreativeFailure(attemptId, 'response_post_failed'); - store.recordTrustedServerCreativeFailure(attemptId, 'unsafe_runtime_value' as never); - - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toEqual([ - 'cache_fetch_failed', - 'missing_render_source', - 'invalid_cache_payload', - 'response_post_failed', - ]); - expect(store.snapshot().attributionIssues).toEqual([]); - - now = 1; - store.recordTrustedServerCreativeResponse(attemptId); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_response_sent'); - }); - - it('keeps an asynchronous response on its originating cycle after a newer refresh', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('async-origin'); - associateSlot(store, slot, 'auction-async'); - store.recordSlotRequested(slot); - const firstId = store.recordTrustedServerCreativeRequest('auction-async')!; - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - - now = 2; - store.recordSlotRequested(slot); - now = 3; - store.recordTrustedServerCreativeResponse(firstId); - - const [first, second] = store.snapshot().slots[0].requests; - expect(first).toMatchObject({ - trustedServerCreativeResponseAtMs: 3, - delivery: 'trusted_server_response_sent', - }); - expect(second.trustedServerCreativeResponseAtMs).toBeUndefined(); - }); - - it('provisionally attaches an initial pre-render creative request', () => { - let now = 10; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('provisional'); - associateSlot(store, slot, 'auction-provisional'); - store.recordSlotRequested(slot); - - now = 11; - const attemptId = store.recordTrustedServerCreativeRequest('auction-provisional'); - expect(attemptId).toEqual(expect.any(Number)); - now = 12; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 11, - isEmpty: false, - delivery: 'trusted_server_selected', - }); - expect(store.snapshot().attributionIssues).toEqual([]); - }); - - it('rejects an ambiguous pre-render request when an earlier non-empty cycle is retained', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('ambiguous-creative'); - associateSlot(store, slot, 'auction-ambiguous'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - now = 2; - store.recordSlotRequested(slot); - - now = 3; - expect(store.recordTrustedServerCreativeRequest('auction-ambiguous')).toBeUndefined(); - expect(store.snapshot().slots[0].requests[1].trustedServerCreativeRequestAtMs).toBeUndefined(); - expect(store.snapshot().attributionIssues).toEqual([ - expect.objectContaining({ - reason: 'creative_request_ambiguous_cycle', - runtimeSlotNumber: 1, - slotElementId: 'ambiguous-creative', - }), - ]); - }); - - it('accepts positive creative evidence when GPT omitted isEmpty', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('unknown-fill-positive'); - associateSlot(store, slot, 'auction-unknown-fill'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, {}); - now = 2; - - expect(store.recordTrustedServerCreativeRequest('auction-unknown-fill')).toEqual( - expect.any(Number) - ); - expect(store.snapshot().slots[0].requests[0].delivery).toBe('trusted_server_selected'); - }); - - it('rejects explicit empty cycles and never falls back to an older compatible cycle', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('empty-current'); - associateSlot(store, slot, 'auction-empty-current'); - store.recordSlotRequested(slot); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: false }); - now = 2; - store.recordSlotRequested(slot); - now = 3; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - - expect(store.recordTrustedServerCreativeRequest('auction-empty-current')).toBeUndefined(); - const [older, current] = store.snapshot().slots[0].requests; - expect(older.trustedServerCreativeRequestAtMs).toBeUndefined(); - expect(current.trustedServerCreativeRequestAtMs).toBeUndefined(); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_request_without_cycle'); - }); - - it('preserves provisional evidence and reports when the cycle later renders empty', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('provisional-empty'); - associateSlot(store, slot, 'auction-provisional-empty'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-provisional-empty'); - now = 1; - store.recordSlotRenderEnded(slot, { isEmpty: true }); - - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(0); - expect(store.snapshot().attributionIssues).toEqual([ - expect.objectContaining({ - reason: 'creative_request_on_empty_cycle', - runtimeSlotNumber: 1, - slotElementId: 'provisional-empty', - }), - ]); - - // The attempt is dead once its cycle rendered empty, so a late response - // cannot claim a Trusted Server delivery against that empty render. - store.recordTrustedServerCreativeResponse(attemptId!); - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle.trustedServerCreativeResponseAtMs).toBeUndefined(); - expect(cycle.delivery, 'an empty cycle must not report a markup response').toBe( - 'trusted_server_selected' - ); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_evicted'); - }); - - it('preserves provisional evidence when the render omits isEmpty', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('provisional-unknown-fill'); - associateSlot(store, slot, 'auction-provisional-unknown-fill'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-provisional-unknown-fill'); - - now = 1; - store.recordSlotRenderEnded(slot, {}); - - expect(attemptId).toEqual(expect.any(Number)); - expect(store.snapshot().slots[0].requests[0]).toMatchObject({ - trustedServerCreativeRequestAtMs: 0, - delivery: 'trusted_server_selected', - }); - expect(store.snapshot().attributionIssues).toEqual([]); - }); - - it('admits a request at the cycle-age boundary and rejects only after it', () => { - let boundaryNow = 0; - const boundaryStore = new GptDiagnosticsStore({ - now: () => boundaryNow, - defer: () => undefined, - }); - const boundarySlot = fakeSlot('cycle-boundary'); - associateSlot(boundaryStore, boundarySlot, 'auction-boundary'); - boundaryStore.recordSlotRequested(boundarySlot); - boundaryNow = CREATIVE_ATTEMPT_WINDOW_MS; - expect(boundaryStore.recordTrustedServerCreativeRequest('auction-boundary')).toEqual( - expect.any(Number) - ); - - let lateNow = 0; - const lateStore = new GptDiagnosticsStore({ now: () => lateNow, defer: () => undefined }); - const lateSlot = fakeSlot('cycle-too-old'); - associateSlot(lateStore, lateSlot, 'auction-too-old'); - lateStore.recordSlotRequested(lateSlot); - lateNow = CREATIVE_ATTEMPT_WINDOW_MS + 1; - expect(lateStore.recordTrustedServerCreativeRequest('auction-too-old')).toBeUndefined(); - expect(last(lateStore.snapshot().attributionIssues)?.reason).toBe( - 'creative_request_without_cycle' - ); - }); - - it('distinguishes missing slot associations from known slots without a request cycle', () => { - const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); - const associated = fakeSlot('associated-no-cycle'); - associateSlot(store, associated, 'auction-no-cycle'); - - expect(store.recordTrustedServerCreativeRequest('auction-unknown')).toBeUndefined(); - expect(store.recordTrustedServerCreativeRequest('')).toBeUndefined(); - expect(store.recordTrustedServerCreativeRequest('auction-no-cycle')).toBeUndefined(); - - const issues = store.snapshot().attributionIssues; - expect(issues.map((issue) => issue.reason)).toEqual([ - 'creative_request_without_slot', - 'creative_request_without_slot', - 'creative_request_without_cycle', - ]); - expect(issues[0].runtimeSlotNumber).toBeUndefined(); - expect(issues[0].slotElementId).toBeUndefined(); - expect(issues[2].slotElementId).toBe('associated-no-cycle'); - }); - - it('expires attempts at 30 seconds without replacement or late mutation', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('attempt-expiry'); - associateSlot(store, slot, 'auction-expiry'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-expiry')!; - - now = CREATIVE_ATTEMPT_WINDOW_MS - 1; - expect(store.recordTrustedServerCreativeRequest('auction-expiry')).toBe(attemptId); - now = CREATIVE_ATTEMPT_WINDOW_MS; - expect(store.recordTrustedServerCreativeRequest('auction-expiry')).toBeUndefined(); - store.recordTrustedServerCreativeResponse(attemptId); - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - - const cycle = store.snapshot().slots[0].requests[0]; - expect(cycle).toMatchObject({ trustedServerCreativeRequestAtMs: 0 }); - expect(cycle.trustedServerCreativeResponseAtMs).toBeUndefined(); - expect(cycle.trustedServerCreativeFailures).toBeUndefined(); - expect(store.snapshot().attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_expired', - 'creative_attempt_expired', - 'creative_attempt_expired', - ]); - }); - - it('reuses a live attempt after the cycle ages out and expires from creative-request time', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('delayed-attempt-expiry'); - associateSlot(store, slot, 'auction-delayed-attempt-expiry'); - store.recordSlotRequested(slot); - - now = 20_000; - const attemptId = store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry'); - expect(attemptId).toEqual(expect.any(Number)); - - now = CREATIVE_ATTEMPT_WINDOW_MS + 1; - expect(store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry')).toBe( - attemptId - ); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(20_000); - - now = 20_000 + CREATIVE_ATTEMPT_WINDOW_MS - 1; - expect(store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry')).toBe( - attemptId - ); - now = 20_000 + CREATIVE_ATTEMPT_WINDOW_MS; - expect( - store.recordTrustedServerCreativeRequest('auction-delayed-attempt-expiry') - ).toBeUndefined(); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_expired'); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeRequestAtMs).toBe(20_000); - }); - - it('reports unknown IDs and invalidates live attempts on cycle and slot eviction', () => { - const now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - store.recordTrustedServerCreativeResponse(999_999); - - const shiftedSlot = fakeSlot('shifted-attempt'); - associateSlot(store, shiftedSlot, 'auction-shifted'); - store.recordSlotRequested(shiftedSlot); - const shiftedId = store.recordTrustedServerCreativeRequest('auction-shifted')!; - for (let index = 0; index < MAX_REQUEST_CYCLES_PER_SLOT; index += 1) { - store.recordSlotRequested(shiftedSlot); - } - store.recordTrustedServerCreativeResponse(shiftedId); - - const evictedSlot = fakeSlot('lru-attempt'); - associateSlot(store, evictedSlot, 'auction-lru-attempt'); - store.recordSlotRequested(evictedSlot); - const evictedId = store.recordTrustedServerCreativeRequest('auction-lru-attempt')!; - for (let index = 0; index < MAX_DIAGNOSTIC_SLOTS; index += 1) { - store.recordSlotRequested(fakeSlot(`attempt-lru-filler-${index}`)); - } - store.recordTrustedServerCreativeFailure(evictedId, 'response_post_failed'); - - expect(store.snapshot().attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_unknown', - 'creative_attempt_evicted', - 'creative_attempt_evicted', - ]); - }); - - it('treats duplicate writers against a completed attempt as idempotent', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('completed-attempt'); - associateSlot(store, slot, 'auction-completed'); - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest('auction-completed')!; - now = 1; - store.recordTrustedServerCreativeResponse(attemptId); - const completed = store.snapshot(); - - now = 2; - expect(store.recordTrustedServerCreativeRequest('auction-completed')).toBeUndefined(); - store.recordTrustedServerCreativeResponse(attemptId); - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - - expect(store.snapshot().slots[0].requests[0]).toEqual(completed.slots[0].requests[0]); - expect(store.snapshot().attributionIssues).toEqual([]); - }); - - it('does not replace a completed current-cycle attempt after its tombstone is reclaimed', () => { - const now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const sentinelSlot = fakeSlot('completed-current-cycle'); - associateSlot(store, sentinelSlot, 'auction-completed-current-cycle'); - store.recordSlotRequested(sentinelSlot); - const sentinelId = store.recordTrustedServerCreativeRequest('auction-completed-current-cycle')!; - store.recordTrustedServerCreativeResponse(sentinelId); - - recordCompletedAttempts(store, MAX_CREATIVE_ATTEMPTS - 1, 'completed-current-cycle-fill'); - const replacementSlot = fakeSlot('completed-current-cycle-replacement'); - associateSlot(store, replacementSlot, 'auction-completed-current-cycle-replacement'); - store.recordSlotRequested(replacementSlot); - expect( - store.recordTrustedServerCreativeRequest('auction-completed-current-cycle-replacement') - ).toEqual(expect.any(Number)); - - expect( - store.recordTrustedServerCreativeRequest('auction-completed-current-cycle') - ).toBeUndefined(); - expect( - store.recordTrustedServerCreativeRequest('auction-completed-current-cycle') - ).toBeUndefined(); - expect(store.snapshot().attributionIssues).toEqual([]); - expect( - store.snapshot().slots.find((slot) => slot.slotElementId === 'completed-current-cycle') - ?.requests[0] - ).toMatchObject({ - trustedServerCreativeRequestAtMs: 0, - trustedServerCreativeResponseAtMs: 0, - }); - }); - - it('does not replace an expired current-cycle attempt after its tombstone is reclaimed', () => { - let now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const sentinelSlot = fakeSlot('expired-current-cycle'); - associateSlot(store, sentinelSlot, 'auction-expired-current-cycle'); - store.recordSlotRequested(sentinelSlot); - expect(store.recordTrustedServerCreativeRequest('auction-expired-current-cycle')).toEqual( - expect.any(Number) - ); - - now = CREATIVE_ATTEMPT_WINDOW_MS; - recordCompletedAttempts(store, MAX_CREATIVE_ATTEMPTS - 1, 'expired-current-cycle-fill'); - const replacementSlot = fakeSlot('expired-current-cycle-replacement'); - associateSlot(store, replacementSlot, 'auction-expired-current-cycle-replacement'); - store.recordSlotRequested(replacementSlot); - expect( - store.recordTrustedServerCreativeRequest('auction-expired-current-cycle-replacement') - ).toEqual(expect.any(Number)); - - expect( - store.recordTrustedServerCreativeRequest('auction-expired-current-cycle') - ).toBeUndefined(); - expect( - store.recordTrustedServerCreativeRequest('auction-expired-current-cycle') - ).toBeUndefined(); - expect(store.snapshot().attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_unknown', - 'creative_attempt_unknown', - ]); - expect( - store.snapshot().slots.find((slot) => slot.slotElementId === 'expired-current-cycle') - ?.requests[0] - ).toMatchObject({ trustedServerCreativeRequestAtMs: 0 }); - }); - - it('never evicts a live attempt at capacity and lets an unassigned duplicate retry', () => { - let now = 100; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const liveIds: number[] = []; - let created = 0; - - for (let slotIndex = 0; created < MAX_CREATIVE_ATTEMPTS; slotIndex += 1) { - const slot = fakeSlot(`live-capacity-${slotIndex}`); - const auctionSlotId = `auction-live-capacity-${slotIndex}`; - associateSlot(store, slot, auctionSlotId); - const cycles = Math.min(MAX_REQUEST_CYCLES_PER_SLOT, MAX_CREATIVE_ATTEMPTS - created); - for (let cycleIndex = 0; cycleIndex < cycles; cycleIndex += 1) { - store.recordSlotRequested(slot); - liveIds.push(store.recordTrustedServerCreativeRequest(auctionSlotId)!); - created += 1; - } - } - - const rejectedSlot = fakeSlot('live-capacity-rejected'); - associateSlot(store, rejectedSlot, 'auction-live-capacity-rejected'); - store.recordSlotRequested(rejectedSlot); - expect( - store.recordTrustedServerCreativeRequest('auction-live-capacity-rejected') - ).toBeUndefined(); - expect(last(store.snapshot().slots)?.requests[0].trustedServerCreativeRequestAtMs).toBe(100); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_capacity'); - - now = 250; - store.recordTrustedServerCreativeResponse(liveIds[0]); - const retriedId = store.recordTrustedServerCreativeRequest('auction-live-capacity-rejected'); - expect(retriedId).toEqual(expect.any(Number)); - expect(retriedId).not.toBe(liveIds[0]); - expect(last(store.snapshot().slots)?.requests[0].trustedServerCreativeRequestAtMs).toBe(100); - store.recordTrustedServerCreativeResponse(liveIds[1]); - expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_capacity'); - }); - - it('does not create an already-expired attempt when a capacity retry reaches its boundary', () => { - let now = 100; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - let created = 0; - - for (let slotIndex = 0; created < MAX_CREATIVE_ATTEMPTS; slotIndex += 1) { - const slot = fakeSlot(`boundary-capacity-${slotIndex}`); - const auctionSlotId = `auction-boundary-capacity-${slotIndex}`; - associateSlot(store, slot, auctionSlotId); - const cycles = Math.min(MAX_REQUEST_CYCLES_PER_SLOT, MAX_CREATIVE_ATTEMPTS - created); - for (let cycleIndex = 0; cycleIndex < cycles; cycleIndex += 1) { - store.recordSlotRequested(slot); - expect(store.recordTrustedServerCreativeRequest(auctionSlotId)).toEqual(expect.any(Number)); - created += 1; - } - } - - const rejectedSlot = fakeSlot('boundary-capacity-rejected'); - const rejectedAuctionSlotId = 'auction-boundary-capacity-rejected'; - associateSlot(store, rejectedSlot, rejectedAuctionSlotId); - store.recordSlotRequested(rejectedSlot); - expect(store.recordTrustedServerCreativeRequest(rejectedAuctionSlotId)).toBeUndefined(); - - now += CREATIVE_ATTEMPT_WINDOW_MS; - expect(store.recordTrustedServerCreativeRequest(rejectedAuctionSlotId)).toBeUndefined(); - const snapshot = store.snapshot(); - const rejectedCycle = snapshot.slots.find( - (slot) => slot.slotElementId === 'boundary-capacity-rejected' - )?.requests[0]; - expect(rejectedCycle?.trustedServerCreativeRequestAtMs).toBe(100); - expect(snapshot.attributionIssues.map((issue) => issue.reason)).toEqual([ - 'creative_attempt_capacity', - 'creative_attempt_expired', - ]); - }); - - it('bounds attribution issues separately without changing callback coverage', () => { - const store = new GptDiagnosticsStore({ now: () => 1, defer: () => undefined }); - const beforeCoverage = store.snapshot().coverage; - - for (let index = 0; index < MAX_ATTRIBUTION_ISSUES + 1; index += 1) { - store.recordTrustedServerCreativeResponse(10_000 + index); - } - - const snapshot = store.snapshot(); - expect(snapshot.attributionIssues).toHaveLength(MAX_ATTRIBUTION_ISSUES); - expect(snapshot.metadata.droppedAttributionIssues).toBe(1); - expect(snapshot.callbackIssues).toEqual([]); - expect(snapshot.metadata.droppedCallbacks).toBe(0); - expect(snapshot.coverage).toEqual(beforeCoverage); - assertCoverageEquation(store); - }); - - it('returns detached creative evidence and never exports attempt bookkeeping', () => { - const now = 0; - const store = new GptDiagnosticsStore({ now: () => now, defer: () => undefined }); - const slot = fakeSlot('detached-creative'); - associateSlot(store, slot, 'auction-detached-creative'); - store.recordSlotRequested(slot); - store.recordSlotRenderEnded(slot, { - isEmpty: false, - adManager: { creativeId: 123, yieldGroupIds: [11], companyIds: [22] }, - }); - const attemptId = store.recordTrustedServerCreativeRequest('auction-detached-creative')!; - store.recordTrustedServerCreativeFailure(attemptId, 'cache_fetch_failed'); - store.recordTrustedServerCreativeResponse(999_999); - - const first = store.snapshot(); - first.slots[0].requests[0].trustedServerCreativeFailures!.push('response_post_failed'); - first.slots[0].requests[0].adManager!.yieldGroupIds!.push(33); - first.slots[0].requests[0].adManager!.companyIds!.push(44); - first.attributionIssues[0].reason = 'creative_attempt_capacity'; - - const second = store.snapshot(); - expect(second.slots[0].requests[0].trustedServerCreativeFailures).toEqual([ - 'cache_fetch_failed', - ]); - expect(second.slots[0].requests[0].adManager).toMatchObject({ - creativeId: 123, - yieldGroupIds: [11], - companyIds: [22], - }); - expect(second.attributionIssues[0].reason).toBe('creative_attempt_unknown'); - const serializedCycle = JSON.stringify(second.slots[0].requests[0]); - expect(serializedCycle).not.toMatch( - /"(?:id|status|expiresAtMs|provisionalBeforeRender|auctionSlotId|attemptId|attemptStatus)"\s*:/ - ); + expect(commitListener).toHaveBeenCalledTimes(2); + expect(presentationListener).not.toHaveBeenCalled(); + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(presentationListener).toHaveBeenCalledOnce(); }); it('returns detached snapshot data', () => { From 9ee56cb9e0783506a4a22244ff1d83ead8160be3 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 704/844] 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 0ca31fa728601d1e03866430e970ac81fcb0aa70 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 705/844] 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 | 111 +- .../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 | 285 +- .../src/integrations/aps.rs | 16 +- .../src/integrations/didomi.rs | 12 +- .../src/integrations/gpt.rs | 96 +- .../src/integrations/mod.rs | 18 +- .../src/integrations/prebid.rs | 23 +- .../src/integrations/registry.rs | 65 +- .../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 | 1749 ++---- 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 | 111 +- .../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 | 108 +- .../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 | 5320 ----------------- .../integrations/gpt/gpt_bootstrap.test.ts | 10 + .../lib/test/integrations/gpt/index.test.ts | 674 --- .../lib/test/integrations/gpt/module.test.ts | 15 + .../gpt/schedule_initial_ad_init.test.ts | 550 -- .../test/integrations/gpt/spa_hook.test.ts | 770 --- .../test/integrations/prebid/index.test.ts | 140 - .../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 | 168 - .../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, 3319 insertions(+), 10393 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 ea0ac65a2..83b5a3657 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -26,8 +26,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, @@ -82,9 +82,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 +91,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,25 +102,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 { @@ -136,7 +130,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 { @@ -150,12 +144,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, @@ -165,12 +158,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, @@ -394,7 +386,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 16] { +fn named_routes() -> [NamedRoute; 13] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -467,14 +459,6 @@ fn named_routes() -> [NamedRoute; 16] { 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 fd9cdbcff..02f0a30c6 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 @@ -141,16 +140,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"), @@ -162,6 +154,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 @@ -272,7 +269,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 e4e583824..cb38886b2 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -31,9 +31,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, @@ -143,9 +142,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 { @@ -155,7 +151,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; @@ -167,12 +162,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 @@ -186,7 +180,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 @@ -653,15 +646,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 { @@ -675,10 +661,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 9be8b9f18..4f7eb687f 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() } @@ -37,11 +34,9 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { } app::set_cloudflare_env(env.clone()); - #[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. @@ -57,7 +52,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) @@ -73,7 +68,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 3699bcd73..b6a049686 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() @@ -343,16 +341,9 @@ fn all_explicit_routes_are_registered() { ("GET", "/_ts/admin/ec/{id}"), ("GET", "/_ts/admin/eids"), ("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"), @@ -364,6 +355,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 e38506e84..58649b833 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -132,9 +132,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, @@ -195,7 +194,6 @@ pub(crate) fn build_state( build_state_from_settings(load_settings_from_config_store(stores)?) } -#[cfg(feature = "aps-runner-proxy-integration-test")] pub(crate) async fn dispatch_reserved_for_state( state: &Arc, req: Request, @@ -210,7 +208,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)), ) } @@ -227,9 +225,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); @@ -1213,16 +1208,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], @@ -1373,8 +1358,7 @@ 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, - RuntimeStoreConfig, TrustedServerApp, build_per_request_services, + AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_PATH, TrustedServerApp, build_state_from_settings, startup_error_router, }; use base64::Engine as _; @@ -1971,83 +1955,26 @@ mod tests { } #[test] - fn admin_ec_lookup_routes_are_registered() { - // Both lookup shapes must be explicitly routed to the admin EC - // handler: the bare cookie-based route and the parameterized route. - // Leaving either unrouted would fall through to the publisher - // fallback, forwarding the caller's `Authorization` header to the - // origin. - for path in ["/_ts/admin/ec", "/_ts/admin/ec/{id}"] { - let route = NAMED_ROUTES - .iter() - .find(|route| route.path == path) - .unwrap_or_else(|| panic!("{path} must be a named route")); - assert!( - matches!(route.handler, NamedRouteHandler::AdminEcLookup), - "{path} must map to the admin EC lookup handler" - ); - assert_eq!( - route.primary_methods, - &[Method::GET], - "{path} must have GET as its only primary method" - ); - } - - let eids_route = NAMED_ROUTES - .iter() - .find(|route| route.path == "/_ts/admin/eids") - .expect("should register /_ts/admin/eids as a named route"); - assert!( - matches!(eids_route.handler, NamedRouteHandler::AdminEidsLookup), - "/_ts/admin/eids must map to the admin EIDs lookup handler" - ); - assert_eq!( - eids_route.primary_methods, - &[Method::GET], - "/_ts/admin/eids must have GET as its only primary method" - ); - } - - #[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 daea99f6f..9138dc3f4 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -178,7 +178,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())) @@ -192,8 +191,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), @@ -213,14 +210,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 519553af5..53742ca60 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -33,9 +33,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, @@ -102,9 +101,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 { @@ -114,7 +110,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; @@ -126,17 +121,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, @@ -146,12 +140,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, @@ -229,7 +222,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 16] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 13] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -242,7 +235,6 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 16] { ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), ("/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]), @@ -887,18 +879,8 @@ fn build_router(state: &Arc) -> RouterService { .get("/_ts/admin/ec/{id}", admin_ec_not_supported_handler) .get("/_ts/admin/eids", admin_eids_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 4a8ee55a4..7b14c8698 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() @@ -603,53 +601,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 7ade9487f..ab2b6341c 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}; @@ -20,20 +20,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; @@ -45,6 +46,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`]. @@ -168,6 +229,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. @@ -254,12 +331,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 @@ -357,10 +436,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, @@ -388,7 +468,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), }, ) }) @@ -397,8 +477,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 ); @@ -877,6 +957,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 85238bff1..d94c71913 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 = @@ -975,27 +1040,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 }) } @@ -2627,6 +2672,7 @@ mod convert_tests { auction_id: "auction-1".to_string(), results, }, + slots: Vec::new(), bids, } } @@ -2821,6 +2867,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 3b5829106..17c202e77 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -510,6 +510,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 { @@ -517,6 +533,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 68e590cd7..cb8ae05b9 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -500,9 +500,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 deb2db8b0..a6c14d25c 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, end, - 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 @@ -199,9 +196,8 @@ pub struct HtmlProcessorConfig { pub max_buffered_body_bytes: usize, /// Request-scoped conditional diagnostics delivery decision. pub gpt_diagnostics: Option, - /// What the `` seam injects. Decided by the caller rather than inferred - /// from [`Self::ad_slots_script`]. - pub body_close: BodyCloseInjection, + /// 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, /// Set when the document delivers a response-bound CSP nonce in its own markup. @@ -230,7 +226,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, - body_close: BodyCloseInjection::None, + render_trace_overlay: false, suppress_datadome_client_side_tag: false, csp_nonce_observed: None, } @@ -272,13 +268,10 @@ impl HtmlProcessorConfig { self } - /// Watch the document for a response-bound CSP nonce delivered in its own markup. - /// - /// Pass `Some` only when the completed transform may be stored as a shared template; - /// nothing else reads the observation. + /// Attach the server-owned request-scoped render-trace overlay decision. #[must_use] - pub fn with_csp_nonce_observer(mut self, observed: Option>) -> Self { - self.csp_nonce_observed = observed; + pub fn with_render_trace_overlay(mut self, active: bool) -> Self { + self.render_trace_overlay = active; self } @@ -368,13 +361,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 body_close = config.body_close.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; // No source-comment neutralization here: rewriting a publisher comment that happens // to match the reserved marker would change publisher content bytes. Collisions are @@ -403,7 +394,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() { @@ -417,28 +408,75 @@ 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(""; - -/// Transform-owned stand-in for the seam, emitted at the document's structural body end. -/// -/// Deliberately *not* [`AD_ASSEMBLY_SEAM`]. The payload that ends up in the seam is not -/// known until the completed transform has been checked for publisher collisions, and the -/// position is not knowable from the output bytes: a reverse search for `` selects -/// a string literal in `` when the document has -/// no real close, and prefers a `` sequence in trailing comment data over the real -/// closing tag. Only the parser knows which one is structural, so the parser marks the -/// spot and the substitution below fills it in. -/// -/// Keeping it distinct from [`AD_ASSEMBLY_SEAM`] is what lets a publisher document that -/// contains the seam bytes still receive correctly positioned bids: that collision -/// revokes the shared reservation without disturbing this placeholder. -pub(crate) const TEMPLATE_SEAM_PLACEHOLDER: &str = ""; - -/// The mode the operator asked for, before availability is taken into account. -/// -/// Spelled once, because the mode has to mean the same thing at the cache key, at the -/// seam, and at both hit finalizers. Every one of those re-derived it from the same -/// `Option` chain, and the finalizers had no way to ask at all — which is why they -/// demanded a seam marker of a mode that emits none. -fn configured_assembly_mode(settings: &Settings) -> AssemblyMode { - settings - .creative_opportunities - .as_ref() - .map(CreativeOpportunitiesConfig::assembly_mode) - .unwrap_or_default() -} - -/// Whether this mode's completed template receives [`AD_ASSEMBLY_SEAM`]. -/// -/// The property that decides whether a template is *expected* to have a hole in it, and -/// therefore whether the absence of one is a defect or the design. Only `Esi` splices per -/// reader. -/// -/// Matched exhaustively rather than compared against `Esi`, so a new mode has to state -/// its answer here instead of silently inheriting one. -fn mode_emits_seam_marker(mode: AssemblyMode) -> bool { - match mode { - AssemblyMode::Inline => false, - AssemblyMode::Esi => true, - } -} - -/// The assembly mode this response will actually be delivered under. -/// -/// The configured mode says what the operator wants; the cache key says whether it is -/// available. A shared mode with no key means the gate refused this response — the -/// origin set a cookie, declared a `Vary` the key does not cover, returned a non-200, -/// and so on — so there is no shared template to build and nothing downstream will -/// assemble one. -/// -/// When that happens the request falls back to [`AssemblyMode::Inline`] **entirely**, -/// at every seam. Falling back at one seam and not another is what produced the failure -/// this function exists to prevent: the `` seam emitted a legacy ESI tag because -/// the mode was `Esi`, while assembly was skipped because there was no key, so the reader -/// received a document with unresolved executable ESI markup in it and no bids at all. -/// -/// Bypassing is the *normal* case against a real origin, not an edge case, so this path -/// runs far more often than the shared one. -fn effective_assembly_mode(settings: &Settings, shared_template_authorized: bool) -> AssemblyMode { - let configured = configured_assembly_mode(settings); - if matches!(configured, AssemblyMode::Inline) || shared_template_authorized { - return configured; - } - log::debug!( - "assembly mode {configured:?} is unavailable for this response (no shared template \ - was authorized); falling back to inline" - ); - AssemblyMode::Inline -} - -/// What the streaming HTML processor should inject at ``. -/// -/// Explicit rather than inferred. The previous shape read -/// `ad_slots_script.is_some()` inside the element handler, which silently coupled -/// two independent decisions: once [`template_ad_slots_script`] stopped emitting a -/// head script under a shared mode, body-close injection stopped with it. -/// -/// `Esi` emits [`TEMPLATE_SEAM_PLACEHOLDER`], not the seam itself. What goes into the -/// seam is still decided after the completed transform has been checked for publisher -/// collisions and ESI directives — but *where* it goes has to be decided here, by the -/// parser, because the output bytes cannot distinguish a structural `` from one -/// written inside a script string or a trailing comment. -pub(crate) fn body_close_injection( - mode: AssemblyMode, - head_script_present: bool, -) -> BodyCloseInjection { - match mode { - // Per-navigation and never shared, so gating on slot presence is correct. - AssemblyMode::Inline => { - if head_script_present { - BodyCloseInjection::InlineBids - } else { - BodyCloseInjection::None - } - } - AssemblyMode::Esi => BodyCloseInjection::Marker(TEMPLATE_SEAM_PLACEHOLDER.to_string()), - } + render_trace_overlay: bool, } fn create_html_stream_processor( @@ -1414,26 +1263,11 @@ fn create_html_stream_processor( params.origin_host, params.request_host, params.request_scheme, - ); - - let assembly_mode = effective_assembly_mode(params.settings, params.shared_template_authorized); - let body_close = body_close_injection(assembly_mode, params.ad_slots_script.is_some()); - - let gpt_diagnostics = template_gpt_diagnostics(assembly_mode, params.gpt_diagnostics); - - // Only a response that can be stored has a consumer for the observation, so the - // handlers are not registered for ordinary inline traffic. - let csp_nonce_observed = params - .shared_template_authorized - .then_some(params.csp_nonce_observed) - .flatten(); - - let config = config - .with_ad_state(params.ad_slots_script, params.ad_bids_state) - .with_gpt_diagnostics(gpt_diagnostics) - .with_body_close(body_close) - .with_csp_nonce_observer(csp_nonce_observed) - .with_datadome_client_tag_suppression(params.suppress_datadome_client_side_tag); + ) + .with_ad_state(params.ad_slots_script, params.ad_bids_state) + .with_gpt_diagnostics(params.gpt_diagnostics) + .with_render_trace_overlay(params.render_trace_overlay) + .with_datadome_client_tag_suppression(params.suppress_datadome_client_side_tag); Ok(create_html_processor(config)) } @@ -1629,26 +1463,8 @@ pub struct OwnedProcessResponseParams { /// Request-scoped conditional diagnostics delivery decision. pub(crate) gpt_diagnostics: Option, - /// Set by the transform when the document carries a response-bound CSP nonce. - /// - /// `None` wherever no transform runs. Recorded by the HTML parser rather than - /// rescanned from the output, which cannot tell a `nonce` attribute from the same - /// word inside a script. - pub(crate) csp_nonce_observed: Option>, -} - -/// Response-authorized template cache insert inputs. The key is built before origin lookup; the -/// lifetime is only known after the origin proves this representation is shareable. -pub(crate) struct AuthorizedTemplateStore { - reservation: crate::platform::TemplateCacheReservation, - expires_at: Instant, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum TemplateStoreOutcome { - Stored, - Expired, - Error, + /// Server-owned request-scoped render-trace overlay decision. + pub(crate) render_trace_overlay: bool, } /// Buffers a [`PublisherResponse`] into a single [`Response`], collecting the @@ -2467,72 +2283,20 @@ pub async fn publisher_response_into_streaming_response( response.headers_mut().remove(header::CONTENT_LENGTH); let mut params = *params; - let mut processor = - 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| { + if let Some(dispatched) = params.dispatched_auction.take() { 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 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(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((mut guard, telemetry)) = dispatched_auction { - if is_html_content_type(¶ms.content_type) { - hold_auction = Some((guard, telemetry)); - } else if let Some(dispatched) = guard.take() { - collect_non_html_auction( - dispatched, - telemetry, - ¶ms, - &orchestrator, - &services, - &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(); - } - } - - if let Some((guard, telemetry)) = hold_auction { - let mut state = AuctionHoldState::new(guard, telemetry); + let mut guard = DispatchedAuctionGuard::new(dispatched); + let dispatched = guard + .take() + .expect("should have dispatched auction to collect before HTML"); + if is_html_content_type(¶ms.content_type) { let collect_refs = AuctionCollectDeps { price_granularity: params.price_granularity, ad_bids_state: ¶ms.ad_bids_state, + browser_slots_json: params.ad_slots_script.as_deref(), orchestrator: &orchestrator, services: &services, settings: &settings, @@ -2541,87 +2305,49 @@ pub async fn publisher_response_into_streaming_response( ¶ms.request_host, ), }; - - while let Some(step) = hold_step_next_chunk( - &mut source, - &mut decoder, - &mut encoder, - &mut processor, - &mut state, - &collect_refs, - ) - .await - .map_err(publisher_stream_error)? - { - // Emit the ready prefix before collecting the auction so - // the client receives the document up to `` while - // the auction rides alongside transfer. - for encoded in step.ready { - yield encoded; - } - if step.close_found { - for encoded in hold_collect_close_tail( - &mut processor, - &mut encoder, - &mut state, - &collect_refs, - ) - .await - .map_err(publisher_stream_error)? - { - yield encoded; - } - } - } - - // Whatever the decoder released at finalization is emitted - // before collection, for the same reason as the mid-stream - // prefix above: a small compressed page can surface its - // whole document here. - for encoded in hold_finish_ready_segments( - &mut processor, - &mut decoder, - &mut encoder, - &mut state, - &collect_refs, - ) - .await - .map_err(publisher_stream_error)? - { - yield encoded; - } - for encoded in hold_finish_tail_segments( - &mut processor, - &mut encoder, - &mut state, - &collect_refs, - ) - .await - .map_err(publisher_stream_error)? - { - yield encoded; - } + collect_stream_auction(dispatched, telemetry, &collect_refs).await; } else { - while let Some(segments) = passthrough_step( - &mut source, - &mut decoder, - &mut encoder, - &mut processor, + collect_non_html_auction( + dispatched, + telemetry, + ¶ms, + &orchestrator, + &services, + &settings, ) - .await - .map_err(publisher_stream_error)? - { - for encoded in segments { - yield encoded; - } - } - for encoded in - passthrough_finish_segments(&mut processor, &mut decoder, &mut encoder) - .map_err(publisher_stream_error)? - { + .await; + } + guard.disarm(); + } + 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 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(max_body_bytes); + + while let Some(segments) = passthrough_step( + &mut source, + &mut decoder, + &mut encoder, + &mut processor, + ) + .await + .map_err(publisher_stream_error)? + { + for encoded in segments { 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) @@ -2801,8 +2527,7 @@ pub fn stream_publisher_body( ad_bids_state: params.ad_bids_state.script_cell(), suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, gpt_diagnostics: params.gpt_diagnostics.as_ref(), - shared_template_authorized: params.template_cache_key.is_some(), - csp_nonce_observed: params.csp_nonce_observed.as_ref(), + render_trace_overlay: params.render_trace_overlay, }; let input_compression = Compression::from_content_encoding(¶ms.content_encoding); let output_compression = if params.template_cache_key.is_some() { @@ -2813,22 +2538,13 @@ pub fn stream_publisher_body( process_response_streaming(body, output, &borrowed, output_compression) } -/// Stream publisher body with a `` handler with bids now in state. -/// -/// For non-HTML content types the auction is collected before any body bytes -/// are written (no `` to inject). If `params.dispatched_auction` is -/// `None` the function falls back to the synchronous -/// [`stream_publisher_body`] path. +/// HTML requires the exact initial auction projection before the first body +/// byte reaches the processor because the immutable boot value is emitted at +/// ``. Non-HTML responses use the same collect-before-body ordering. If +/// `params.dispatched_auction` is `None`, the function uses the ordinary +/// streaming pipeline directly. /// /// # Errors /// @@ -2863,11 +2579,18 @@ pub async fn stream_publisher_body_async( auction_request: params.auction_request.take(), }; - let is_html = is_html_content_type(¶ms.content_type); - - if !is_html { - // Non-HTML: collect auction first, then stream. There is no - // to hold, so delaying the entire body until collection is acceptable. + if is_html_content_type(¶ms.content_type) { + let collect_refs = AuctionCollectDeps { + price_granularity: params.price_granularity, + ad_bids_state: ¶ms.ad_bids_state, + browser_slots_json: params.ad_slots_script.as_deref(), + orchestrator, + services, + settings, + request_origin: request_origin(¶ms.request_scheme, ¶ms.request_host), + }; + collect_stream_auction(dispatched, telemetry, &collect_refs).await; + } else { collect_non_html_auction( dispatched, telemetry, @@ -2877,74 +2600,19 @@ pub async fn stream_publisher_body_async( settings, ) .await; - if body.is_stream() { - return process_response_streaming_async( - body, - output, - params, - settings, - integration_registry, - ) - .await; - } - return stream_publisher_body(body, output, params, settings, integration_registry); } - // HTML: build the processor once and drive it chunk by chunk. - // One-behind buffer: stream chunk N-1 immediately; hold chunk N until origin - // EOF, then await auction and process chunk N (which contains ). - let mut processor = match create_html_stream_processor(HtmlStreamProcessorParams { - origin_host: ¶ms.origin_host, - request_host: ¶ms.request_host, - request_scheme: ¶ms.request_scheme, - settings, - integration_registry, - ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), - ad_bids_state: Arc::clone(params.ad_bids_state.script_cell()), - suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, - gpt_diagnostics: params.gpt_diagnostics.clone(), - shared_template_authorized: params.template_cache_key.is_some(), - csp_nonce_observed: params.csp_nonce_observed.clone(), - }) { - Ok(processor) => processor, - Err(err) => { - emit_abandoned_auction( - services, - telemetry.observation, - dispatched, - "processor_init_error", - ) - .await; - return Err(err); - } - }; - - let input_compression = Compression::from_content_encoding(¶ms.content_encoding); - let output_compression = if params.template_cache_key.is_some() { - Compression::None - } else { - input_compression - }; - stream_html_with_auction_hold( - body, - output, - &mut processor, - input_compression, - output_compression, - AuctionCollectCtx { - dispatched, - telemetry, - deps: AuctionCollectDeps { - price_granularity: params.price_granularity, - ad_bids_state: ¶ms.ad_bids_state, - orchestrator, - services, - settings, - request_origin: request_origin(¶ms.request_scheme, ¶ms.request_host), - }, - }, - ) - .await + if body.is_stream() { + return process_response_streaming_async( + body, + output, + params, + settings, + integration_registry, + ) + .await; + } + stream_publisher_body(body, output, params, settings, integration_registry) } /// Builds the canonical mediator placeholder [`Request`] passed to the collect @@ -3076,135 +2744,95 @@ fn request_origin(scheme: &str, host: &str) -> String { } } -/// This request's auction result, in both forms the seams need. -/// -/// The inline `` seam injects a rendered `", + 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() @@ -221,6 +307,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 4226e19f0..13c3dc506 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 8c7f12e8d..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 server-side ad templates run for the request. When template -// delivery is disabled or gated off (auction/consent, 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 0519f5546..640a46637 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)]); } - - installTrustedServerPageTargeting(); - 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 0f3f81514..9ebdd808c 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'; @@ -8,6 +9,7 @@ import { GptDiagnosticsObserver } from './observer'; import { GptDiagnosticsOverlay } from './overlay'; import { GptDiagnosticsSlotSizeObserver } from './slot_size_observer'; import { GptDiagnosticsStore } from './store'; +import { createGptDiagnosticsIntegrationRegistration } from './module'; type GptDiagnosticsWindow = Window & typeof globalThis; @@ -106,3 +108,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 63fc49703..734c07da6 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -21,6 +21,7 @@ import { releasePublisherFirstImpressionAuction, } from '../../core/first_impression'; import { log } from '../../core/log'; +import { EMBEDDED_RELEASE_ID } from '../../core/release'; import { isEffectivelyVisible, recordRender, stampCreativeTrace } from '../../core/trace'; import { buildAdRequest, @@ -32,6 +33,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). @@ -1815,29 +1817,91 @@ function syncPrebidEidsCookie(): void { } } -// Self-initialize when loaded in a browser (same pattern as other integrations). +// --------------------------------------------------------------------------- +// Render trace (client-side /auction path) +// --------------------------------------------------------------------------- + +interface PrebidRenderedBid { + adUnitCode?: string; + bidderCode?: string; + bidder?: string; + creativeId?: string; + meta?: { + tsAuctionId?: unknown; + tsBidId?: unknown; + tsAdmHash?: unknown; + [key: string]: unknown; + }; +} + +interface PrebidRenderEvent { + bid?: PrebidRenderedBid; + adId?: string; + reason?: string; + message?: string; +} + +function findAuctionSlotElement(adUnitCode: string): HTMLElement | null { + if (typeof document === 'undefined') return null; + return (document.getElementById(adUnitCode) ?? + document.getElementById(`${adUnitCode}-container`)) as HTMLElement | null; +} + +export function recordPrebidAdRender( + bid: PrebidRenderedBid | undefined, + outcome: 'succeeded' | 'failed' +): RenderRecord | undefined { + if (!bid || typeof bid.adUnitCode !== 'string' || bid.adUnitCode === '') return undefined; + const meta = bid.meta ?? {}; + if (typeof meta.tsAuctionId !== 'string') return undefined; + const succeeded = outcome === 'succeeded'; + const el = findAuctionSlotElement(bid.adUnitCode); + const record = recordRender({ + slotId: bid.adUnitCode, + path: 'auction', + rendered: succeeded, + injected: succeeded, + visible: succeeded ? isEffectivelyVisible(el) : false, + elementId: el?.id, + auctionId: meta.tsAuctionId, + bidId: typeof meta.tsBidId === 'string' ? meta.tsBidId : undefined, + admHash: typeof meta.tsAdmHash === 'string' ? meta.tsAdmHash : undefined, + bidder: bid.bidderCode ?? bid.bidder, + creativeId: bid.creativeId, + servedFrom: 'prebid', + }); + if (el) stampCreativeTrace(el, record); + return record; +} + +export function installPrebidRenderTrace(): void { + if (typeof window === 'undefined') return; + const p = pbjs as unknown as { + onEvent?: (event: string, handler: (event: PrebidRenderEvent) => void) => void; + __tsRenderTraceInstalled?: boolean; + }; + if (typeof p.onEvent !== 'function' || p.__tsRenderTraceInstalled) return; + p.__tsRenderTraceInstalled = true; + const listen = (name: string, outcome: 'succeeded' | 'failed'): void => { + p.onEvent!(name, (event) => { + try { + recordPrebidAdRender(event?.bid, outcome); + } catch (err) { + log.warn(`[tsjs-prebid] render-trace ${name} failed`, err); + } + }); + }; + listen('adRenderSucceeded', 'succeeded'); + listen('adRenderFailed', 'failed'); +} + 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(); - // 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 b9e46c7de..3000fa96b 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); @@ -180,7 +180,7 @@ describe('creative/click.ts', () => { anchor.setAttribute('href', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); @@ -220,7 +220,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(); @@ -269,7 +269,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); @@ -361,7 +361,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(); @@ -389,7 +389,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(); @@ -416,7 +416,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 1d203f297..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts +++ /dev/null @@ -1,5320 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -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; -}; - -async function runGptBootstrapWithGoogleTag(googletag: object): Promise { - const bootstrapUrl = new URL( - '../../../../../trusted-server-core/src/integrations/gpt_bootstrap.js', - import.meta.url - ); - const urlPath = decodeURIComponent(bootstrapUrl.pathname); - let bootstrapPath: string; - if (urlPath.startsWith('/@fs/')) { - bootstrapPath = urlPath.slice('/@fs'.length); - } else if (bootstrapUrl.protocol === 'file:') { - bootstrapPath = urlPath; - } else { - bootstrapPath = path.resolve(process.cwd(), `.${urlPath}`); - } - const bootstrap = await readFile(bootstrapPath, 'utf8'); - const runBootstrap = new Function('window', 'googletag', bootstrap) as ( - window: Window, - googletag: object - ) => void; - runBootstrap(window, googletag); -} - -type HandoffImplementation = 'bootstrap' | 'bundle'; - -async function installHandoff(implementation: HandoffImplementation): Promise { - if (implementation === 'bootstrap') { - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); - return; - } - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); -} - -interface ResponsiveSlotElementOptions { - containerVisible?: boolean; - containerWidth?: number; - containerHeight?: number; - elementHidden?: boolean; - elementWidth?: number; - elementHeight?: number; - checkVisibility?: boolean; -} - -function appendResponsiveSlotElement( - id: string, - { - containerVisible = false, - containerWidth = 0, - containerHeight = 0, - elementHidden = false, - elementWidth = 0, - elementHeight = 0, - checkVisibility, - }: ResponsiveSlotElementOptions = {} -): HTMLDivElement { - const container = document.createElement('div'); - container.id = `${id}-container`; - container.dataset.responsiveSlotTest = 'true'; - container.style.display = containerVisible ? 'block' : 'none'; - container.getBoundingClientRect = () => - ({ width: containerWidth, height: containerHeight }) as DOMRect; - - const element = document.createElement('div'); - element.id = id; - element.style.display = elementHidden ? 'none' : 'block'; - element.getBoundingClientRect = () => ({ width: elementWidth, height: elementHeight }) as DOMRect; - if (checkVisibility !== undefined) { - (element as HTMLElement & { checkVisibility?: () => boolean }).checkVisibility = vi - .fn() - .mockReturnValue(checkVisibility); - } - 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); -} - -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-new-slot')?.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()); - }); - - function configureOpportunityDiagnostics( - bid: AuctionBidData | undefined, - recordTrustedServerOpportunity: ReturnType, - formats: Array<[number, number]> = [[300, 250]] - ) { - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - clearTargeting: 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, - targeting: {}, - }, - ], - bids: bid ? { atf_sidebar_ad: bid } : {}, - gptDiagnosticsRecorder: { - recordTrustedServerOpportunity, - } as unknown as TsjsApi['gptDiagnosticsRecorder'], - }; - - return { mockPubads, mockSlot }; - } - - it('leaves a publisher-auctioned slot untouched when delayed adInit receives no candidate', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - undefined, - recordTrustedServerOpportunity - ); - const ts = (window as TestWindow).tsjs as TsjsApi; - registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - ts.adInit!(); - - expect(mockSlot.setTargeting).not.toHaveBeenCalled(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); - expect(recordTrustedServerOpportunity).not.toHaveBeenCalled(); - expect(ts.divToSlotId).toEqual({}); - expect(ts.prevSlotTargetingKeys).toEqual({}); - }); - - it('falls back once when a publisher auction abandons its first-impression claim', async () => { - vi.useFakeTimers(); - try { - const recordTrustedServerOpportunity = vi.fn(); - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - { hb_pb: '1.10', hb_adid: 'example-fallback-ad', adm: '
Fallback
' }, - recordTrustedServerOpportunity - ); - const ts = (window as TestWindow).tsjs as TsjsApi; - registerPublisherFirstImpressionAuctions(ts, ['div-atf-sidebar']); - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - ts.adInit!(); - - expect(mockPubads.refresh).not.toHaveBeenCalled(); - vi.advanceTimersByTime(5001); - - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledOnce(); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); - expect(recordTrustedServerOpportunity).toHaveBeenCalledOnce(); - - vi.advanceTimersByTime(10_000); - expect(mockPubads.refresh).toHaveBeenCalledOnce(); - } finally { - vi.useRealTimers(); - } - }); - - it('does not clear targeting or request again after TS claims an existing slot', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - undefined, - recordTrustedServerOpportunity - ); - const ts = (window as TestWindow).tsjs as TsjsApi; - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - ts.adInit!(); - const clearCalls = mockSlot.clearTargeting.mock.calls.length; - const targetingCalls = mockSlot.setTargeting.mock.calls.length; - ts.adInit!(); - - expect(mockSlot.clearTargeting).toHaveBeenCalledTimes(clearCalls); - expect(mockSlot.setTargeting).toHaveBeenCalledTimes(targetingCalls); - expect(mockPubads.refresh).toHaveBeenCalledOnce(); - expect(recordTrustedServerOpportunity).toHaveBeenCalledOnce(); - }); - - it.each(['slotRequested', 'slotRenderEnded'] as const)( - 'leaves a publisher slot untouched after an earlier %s event', - async (eventName) => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - { hb_pb: '2.00', hb_adid: 'late-page-bid', adm: '
Late
' }, - recordTrustedServerOpportunity - ); - const ts = (window as TestWindow).tsjs as TsjsApi; - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - const lifecycleListener = mockPubads.addEventListener.mock.calls.find( - ([registeredEvent]) => registeredEvent === eventName - )?.[1] as ((event: SlotRenderEvent) => void) | undefined; - expect(lifecycleListener).toBeDefined(); - lifecycleListener!({ isEmpty: false, slot: mockSlot }); - - ts.adInit!(); - - expect(mockSlot.setTargeting).not.toHaveBeenCalled(); - expect(mockPubads.refresh).not.toHaveBeenCalled(); - expect(recordTrustedServerOpportunity).not.toHaveBeenCalled(); - expect(ts.firstImpression?.slots['div-atf-sidebar']?.owner).toBe('publisher'); - expect(ts.firstImpression?.slots['div-atf-sidebar']?.phase).toBe( - eventName === 'slotRequested' ? 'requested' : 'rendered' - ); - } - ); - - it.each([ - [ - 'inline markup', - { hb_pb: '1.00', hb_adid: 'abc-uuid', adm: '
Creative
' }, - 'renderable_candidate', - ], - [ - 'complete cache coordinates', - { - hb_bidder: 'example-bidder', - hb_adid: 'abc-uuid', - hb_cache_host: 'cache.example.com', - hb_cache_path: '/cache', - }, - 'renderable_candidate', - ], - [ - 'an ad ID without a render source', - { hb_pb: '1.00', hb_adid: 'abc-uuid' }, - 'unrenderable_candidate', - ], - [ - 'a render source without an ad ID', - { hb_pb: '1.00', adm: '
Creative
' }, - 'unrenderable_candidate', - ], - [ - 'no non-empty Trusted Server bid targeting', - { hb_pb: '', hb_bidder: '', hb_adid: '', adm: '
Creative
' }, - 'no_candidate', - ], - ] as const)( - 'records exactly one %s opportunity for every resolved GPT slot', - async (_description, bid, expectedOpportunity) => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockSlot } = configureOpportunityDiagnostics( - bid as AuctionBidData, - recordTrustedServerOpportunity - ); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledTimes(1); - expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( - mockSlot, - 'atf_sidebar_ad', - expectedOpportunity, - undefined, - undefined - ); - } - ); - - it('forwards winning bid auction metadata to diagnostics only when present', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockSlot } = configureOpportunityDiagnostics( - { - hb_pb: '1.00', - hb_bidder: 'example', - hb_adid: 'creative-1', - hb_auction_id: 'auction-123', - }, - recordTrustedServerOpportunity - ); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( - mockSlot, - 'atf_sidebar_ad', - 'unrenderable_candidate', - 'auction-123', - undefined - ); - }); - - it('retains handoff formats when reusing a Trusted Server-defined GPT slot', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const formats: Array<[number, number]> = [ - [300, 250], - [728, 90], - [320, 50], - ]; - const { mockSlot } = configureOpportunityDiagnostics( - undefined, - recordTrustedServerOpportunity, - formats - ); - (window as TestWindow).tsjs!.gptSlotHandoffs = { - 'div-atf-sidebar': { - gamUnitPath: '/123/atf', - formats, - divIdPrefix: 'div-atf-sidebar', - slotElementId: 'div-atf-sidebar', - publisherClaimed: true, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }, - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( - mockSlot, - 'atf_sidebar_ad', - 'no_candidate', - undefined, - formats - ); - }); - - it('forwards configured formats when defining a Trusted Server GPT slot', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const formats: Array<[number, number]> = [ - [300, 250], - [728, 90], - ]; - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - undefined, - recordTrustedServerOpportunity, - formats - ); - mockPubads.getSlots.mockReturnValue([]); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( - mockSlot, - 'atf_sidebar_ad', - 'no_candidate', - undefined, - formats - ); - }); - - it('keeps slot delivery running when requested-size diagnostics access throws', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockPubads, mockSlot } = configureOpportunityDiagnostics( - undefined, - recordTrustedServerOpportunity - ); - Object.defineProperty((window as TestWindow).tsjs!, 'gptSlotHandoffs', { - configurable: true, - get: () => { - throw new Error('diagnostics handoff unavailable'); - }, - }); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - - expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - expect(recordTrustedServerOpportunity).not.toHaveBeenCalled(); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); - }); - - it('records no_candidate when the resolved slot has no bid', async () => { - const recordTrustedServerOpportunity = vi.fn(); - const { mockSlot } = configureOpportunityDiagnostics(undefined, recordTrustedServerOpportunity); - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - (window as TestWindow).tsjs!.adInit!(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledTimes(1); - expect(recordTrustedServerOpportunity).toHaveBeenCalledWith( - mockSlot, - 'atf_sidebar_ad', - 'no_candidate', - undefined, - undefined - ); - }); - - it('keeps targeting, display, and refresh running when opportunity diagnostics throws', async () => { - const existingSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const definedSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue('div-new-slot'), - getTargeting: vi.fn().mockReturnValue([]), - }; - const refresh = vi.fn(); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([existingSlot]), - addEventListener: vi.fn(), - refresh, - }; - const display = vi.fn(); - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(definedSlot), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - display, - }; - const newSlotDiv = document.createElement('div'); - newSlotDiv.id = 'div-new-slot'; - document.body.appendChild(newSlotDiv); - const recordTrustedServerOpportunity = vi.fn(() => { - throw new Error('diagnostics unavailable'); - }); - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - { - id: 'new_slot_ad', - gam_unit_path: '/123/new', - div_id: 'div-new-slot', - formats: [[728, 90]], - targeting: {}, - }, - ], - bids: { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_adid: 'existing-id', - adm: '
Existing
', - }, - new_slot_ad: { - hb_pb: '2.00', - hb_adid: 'new-id', - adm: '
New
', - }, - }, - gptDiagnosticsRecorder: { - recordTrustedServerOpportunity, - } as unknown as TsjsApi['gptDiagnosticsRecorder'], - }; - - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - expect(() => (window as TestWindow).tsjs!.adInit!()).not.toThrow(); - - expect(recordTrustedServerOpportunity).toHaveBeenCalledTimes(2); - expect(existingSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(definedSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '2.00'); - expect(display).toHaveBeenCalledWith('div-new-slot'); - expect(refresh).toHaveBeenCalledWith([existingSlot]); - }); - - 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.enableSingleRequest).toHaveBeenCalledOnce(); - 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: {}, - }; - - runGptBootstrap(); - (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: {}, - }; - - await runGptBootstrapWithGoogleTag(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(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: {}, - }; - - await runGptBootstrapWithGoogleTag(googletag); - - // 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!(); - - // The slot already spent its first impression above. Changing GPT's - // initial-load mode must not make a repeated adInit request it again. - expect(nativeRefresh).not.toHaveBeenCalled(); - - 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).not.toHaveBeenCalled(); - - // 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: {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - - 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((key: string) => (key === 'ts' ? ['publisher-value'] : [])), - }; - 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(clearTargeting).not.toHaveBeenCalledWith('ts'); - 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', - candidateIndexes: [2], - activeIndexes: [], - selectedIndex: null, - }, - { - implementation: 'runtime', - activeIndexes: [], - elementLayoutIndexes: [1], - visibleContainerIndexes: [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: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [1, 2], - containerWidthIndexes: [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', - candidateIndexes: [2], - activeIndexes: [], - selectedIndex: null, - }, - { - implementation: 'bootstrap', - activeIndexes: [], - elementLayoutIndexes: [1], - visibleContainerIndexes: [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: [], - hiddenElementIndexes: [0, 1, 3], - visibleContainerIndexes: [1, 2], - containerWidthIndexes: [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 containerWidthIndexes = - 'containerWidthIndexes' in testCase ? testCase.containerWidthIndexes : activeIndexes; - const containerHeightIndexes = - 'containerHeightIndexes' in testCase ? testCase.containerHeightIndexes : activeIndexes; - const elementWidthIndexes = - 'elementWidthIndexes' in testCase ? testCase.elementWidthIndexes : elementLayoutIndexes; - const elementHeightIndexes = - 'elementHeightIndexes' in testCase ? testCase.elementHeightIndexes : elementLayoutIndexes; - const candidateIndexes = - 'candidateIndexes' in testCase ? testCase.candidateIndexes : [0, 1, 2, 3]; - 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( - (candidateIndexes as readonly number[]).includes(index) - ? `ad-responsive-${suffix}` - : `unrelated-responsive-${suffix}`, - { - containerVisible: (visibleContainerIndexes as readonly number[]).includes(index), - containerWidth: (containerWidthIndexes as readonly number[]).includes(index) ? 320 : 0, - containerHeight: (containerHeightIndexes as readonly number[]).includes(index) - ? 100 - : 0, - elementHidden: (hiddenElementIndexes as readonly number[]).includes(index), - elementWidth: (elementWidthIndexes as readonly number[]).includes(index) ? 300 : 0, - elementHeight: (elementHeightIndexes as readonly number[]).includes(index) ? 250 : 0, - } - ) - ); - 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.each(['runtime', 'bootstrap'] as const)( - '$implementation reports an ambiguous prefix once during adInit', - async (implementation) => { - const elements = ['a', 'b', 'c', 'd'].map((suffix) => - appendResponsiveSlotElement(`ad-warning-${suffix}`, { containerVisible: true }) - ); - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(elements[0]!.id), - getTargeting: vi.fn().mockReturnValue([]), - }; - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), - display: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - const bootstrapWarn = vi.fn(); - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'warning_slot', - gam_unit_path: '/123/warning', - div_id: 'ad-warning-', - formats: [[300, 250]], - targeting: {}, - }, - { - id: 'warning_slot_duplicate', - gam_unit_path: '/123/warning', - div_id: 'ad-warning-', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - ...(implementation === 'bootstrap' ? { log: { warn: bootstrapWarn } } : {}), - }; - - const runtimeWarn = vi.spyOn(console, 'warn'); - if (implementation === 'runtime') { - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - } else { - runGptBootstrap(); - } - (window as TestWindow).tsjs!.adInit!(); - - if (implementation === 'runtime') { - const warningCall = runtimeWarn.mock.calls.find((call) => - call.includes('GPT slot prefix did not resolve to one active element') - ); - expect(runtimeWarn).toHaveBeenCalledTimes(1); - expect(warningCall).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - divId: 'ad-warning-', - prefixMatchCount: 4, - activeMatchCount: 0, - }), - ]) - ); - } else { - expect(bootstrapWarn).toHaveBeenCalledTimes(1); - expect(bootstrapWarn).toHaveBeenCalledWith( - 'GPT slot prefix did not resolve to one active element', - { - divId: 'ad-warning-', - prefixMatchCount: 4, - activeMatchCount: 0, - } - ); - } - runtimeWarn.mockRestore(); - } - ); - - it.each(['runtime', 'bootstrap'] as const)( - '$implementation trusts checkVisibility when resolving a visible slot', - async (implementation) => { - const element = appendResponsiveSlotElement('ad-native-slot', { - checkVisibility: true, - }); - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - getSlotElementId: vi.fn().mockReturnValue(element.id), - getTargeting: vi.fn().mockReturnValue([]), - }; - const defineSlot = vi.fn().mockReturnValue(mockSlot); - const mockPubads = { - enableSingleRequest: vi.fn(), - getSlots: vi.fn().mockReturnValue([]), - addEventListener: vi.fn(), - refresh: vi.fn(), - }; - (window as TestWindow).googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot, - display: vi.fn(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - (window as TestWindow).tsjs = { - adSlots: [ - { - id: 'native_visibility_slot', - gam_unit_path: '/123/native-visibility', - div_id: 'ad-native-', - formats: [[300, 250]], - targeting: {}, - }, - ], - bids: {}, - }; - - if (implementation === 'runtime') { - const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); - installTsAdInit(); - } else { - runGptBootstrap(); - } - (window as TestWindow).tsjs!.adInit!(); - - expect(defineSlot).toHaveBeenCalledWith('/123/native-visibility', [[300, 250]], element.id); - } - ); - - 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(); - publisherNativeScript = undefined; - // 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: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header' }, - }; - }); - - 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!; - } - - function createCollapsedTrustedSlotIframe(divId = 'div-header') { - const slot = document.createElement('div'); - slot.id = divId; - const wrapper = document.createElement('div'); - wrapper.style.width = '1px'; - wrapper.style.height = '1px'; - const iframe = document.createElement('iframe'); - iframe.width = '1'; - iframe.height = '1'; - iframe.style.width = '1px'; - iframe.style.height = '1px'; - wrapper.appendChild(iframe); - slot.appendChild(wrapper); - document.body.appendChild(slot); - return { iframe, slot, source: iframe.contentWindow!, wrapper }; - } - - async function captureBridgeListener(): Promise<(e: MessageEvent) => unknown> { - let bridgeListener: ((e: MessageEvent) => unknown) | undefined; - const origAdd = window.addEventListener.bind(window); - const currentScriptSpy = publisherNativeScript - ? vi.spyOn(document, 'currentScript', 'get').mockReturnValue(publisherNativeScript) - : undefined; - 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(); - currentScriptSpy?.mockRestore(); - - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); - return bridgeListener!; - } - - it('records an inline creative request and response with the same opaque attempt ID', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(41); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - tsjs.bids.homepage_header.adm = '
Creative
'; - delete tsjs.bids.homepage_header.nurl; - delete tsjs.bids.homepage_header.burl; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const postMessage = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(postMessage).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(41); - expect(postMessage.mock.invocationCallOrder[0]).toBeLessThan( - recordTrustedServerCreativeResponse.mock.invocationCallOrder[0] - ); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - }); - - it('expands an authenticated collapsed inline creative shell after response delivery', async () => { - const tsjs = (window as TestWindow).tsjs!; - tsjs.bids.homepage_header.adm = '
Fictional creative
'; - tsjs.bids.homepage_header.w = 728; - tsjs.bids.homepage_header.h = 90; - delete tsjs.bids.homepage_header.nurl; - delete tsjs.bids.homepage_header.burl; - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - const postMessage = vi.fn(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source: collapsed.source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(postMessage).toHaveBeenCalledOnce(); - expect(collapsed.iframe.width).toBe('728'); - expect(collapsed.iframe.height).toBe('90'); - expect(collapsed.wrapper.style.width).toBe('728px'); - expect(collapsed.wrapper.style.height).toBe('90px'); - }); - - it.each(['fixed', 'anchor', 'expanded', 'oversized'] as const)( - 'does not resize a %s Universal Creative shell', - async (guard) => { - const tsjs = (window as TestWindow).tsjs!; - tsjs.bids.homepage_header.adm = '
Fictional creative
'; - tsjs.bids.homepage_header.w = guard === 'oversized' ? 10_001 : 300; - tsjs.bids.homepage_header.h = 250; - delete tsjs.bids.homepage_header.nurl; - delete tsjs.bids.homepage_header.burl; - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - if (guard === 'fixed') collapsed.iframe.style.position = 'fixed'; - if (guard === 'expanded') collapsed.iframe.style.width = '300px'; - if (guard === 'anchor') { - const anchor = document.createElement('ins'); - anchor.dataset.anchorStatus = 'displayed'; - collapsed.slot.insertBefore(anchor, collapsed.wrapper); - anchor.appendChild(collapsed.wrapper); - } - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source: collapsed.source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(collapsed.iframe.width).toBe('1'); - expect(collapsed.iframe.height).toBe('1'); - expect(collapsed.wrapper.style.width).toBe('1px'); - expect(collapsed.wrapper.style.height).toBe('1px'); - } - ); - - it('records no creative evidence for an ad ID the requesting slot does not own', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(42); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'someone-elses-ad-id' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(recordTrustedServerCreativeRequest).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - }); - - it.each([ - ['missing cache coordinates', {}], - ['incomplete cache coordinates', { hb_cache_host: 'cache.example.com' }], - ] as const)( - 'records missing_render_source for an exact-owned request with %s', - async (_description, cacheFields) => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(45); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - delete tsjs.bids.homepage_header.hb_cache_host; - delete tsjs.bids.homepage_header.hb_cache_path; - Object.assign(tsjs.bids.homepage_header, cacheFields); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(45, 'missing_render_source'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(stopImmediatePropagation).not.toHaveBeenCalled(); - expect(fetchStub).not.toHaveBeenCalled(); - } - ); - - it('records no failure when diagnostics declined to open a creative attempt', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(undefined); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - delete tsjs.bids.homepage_header.hb_cache_host; - delete tsjs.bids.homepage_header.hb_cache_path; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ); - - // Without an attempt ID there is nothing to attribute the failure to, and - // the missing-source fallback must still run untouched. - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(stopImmediatePropagation).not.toHaveBeenCalled(); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('records response_post_failed without resizing when posting inline markup throws', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(46); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - tsjs.bids.homepage_header.adm = '
Creative
'; - - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [ - { - postMessage: vi.fn(() => { - throw new Error('port closed'); - }), - }, - ], - source: collapsed.source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(stopImmediatePropagation).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(46, 'response_post_failed'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(collapsed.iframe.width).toBe('1'); - expect(collapsed.iframe.height).toBe('1'); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('serves a server APS renderer once and rejects a repeated request', 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 collapsed = createCollapsedTrustedSlotIframe(); - const source = collapsed.source; - 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); - expect(collapsed.iframe.width).toBe('300'); - expect(collapsed.iframe.height).toBe('250'); - expect(collapsed.wrapper.style.width).toBe('300px'); - expect(collapsed.wrapper.style.height).toBe('250px'); - - // 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, - markUsed, - }, - }; - - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - const source = collapsed.source; - 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('contract test: fails a registered APS runner without a Universal Creative response or markUsed', 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, - markUsed, - }, - }; - const marker = enablePublisherNativeMode(); - - try { - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - const request = 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; - - bridgeListener(request); - nativeRunnerIn('div-header').runner.dispatchEvent(new Event('error')); - await Promise.resolve(); - await Promise.resolve(); - bridgeListener(request); - - 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('contract test: consumes a registered APS capability and marks it used only after runner load', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'native-prebid-ad-id'; - const markUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed, - }, - }; - const marker = enablePublisherNativeMode(); - - try { - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const portMessages: string[] = []; - const request = 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; - - bridgeListener(request); - expect(markUsed).not.toHaveBeenCalled(); - const native = nativeRunnerIn('div-header'); - native.runner.dispatchEvent(new Event('load')); - await Promise.resolve(); - await Promise.resolve(); - bridgeListener(request); - - expect(native.frame.style.display).toBe(''); - expect(markUsed).toHaveBeenCalledOnce(); - expect(portMessages).toEqual([]); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeUndefined(); - } finally { - marker.remove(); - } - }); - - it('does not use the requesting frame to disambiguate a registered APS slot prefix', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'native-dynamic-prebid-ad-id'; - const markUsed = vi.fn(); - (window as TestWindow).tsjs.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-native-', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed, - }, - }; - const marker = enablePublisherNativeMode(); - createTrustedSlotIframe('div-native-first'); - const source = createTrustedSlotIframe('div-native-second'); - - try { - const bridgeListener = await captureBridgeListener(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: prebidAdId }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - expect(document.querySelector('iframe[title="Ad content"]')).toBeNull(); - expect(markUsed).not.toHaveBeenCalled(); - expect((window as TestWindow).tsjs.apsPrebidRenderers[prebidAdId]).toBeDefined(); - } finally { - marker.remove(); - document.getElementById('div-native-first')?.remove(); - document.getElementById('div-native-second')?.remove(); - } - }); - - it('still serves the APS renderer when markUsed throws', async () => { - const renderer = apsRenderer(); - const prebidAdId = 'throwing-mark-used-ad-id'; - const markUsed = vi.fn(() => { - throw new Error('fictional markUsed failure'); - }); - (window as TestWindow).tsjs!.apsPrebidRenderers = { - [prebidAdId]: { - adUnitCode: 'div-header', - renderer, - registeredAt: Date.now(), - expiresAt: Date.now() + 60_000, - markUsed, - }, - }; - - 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(markUsed).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, - markUsed: firstMarkUsed, - }, - }; - - 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, - markUsed: secondMarkUsed, - }; - sendRequest(); - - expect(portMessages).toHaveLength(2); - expect(stopImmediatePropagation).toHaveBeenCalledTimes(2); - expect(firstMarkUsed).toHaveBeenCalledTimes(1); - expect(secondMarkUsed).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 }, () => ({ - markUsed: 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, - markUsed: 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, - markUsed: 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('claims a TS-owned request before rejecting invalid APS data', 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).toHaveBeenCalledOnce(); - 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 recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(43); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - 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 postMessage = vi.fn((message: string) => portMessages.push(message)); - const fakePort = { postMessage }; - 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(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(43); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - expect(postMessage.mock.invocationCallOrder[0]).toBeLessThan( - recordTrustedServerCreativeResponse.mock.invocationCallOrder[0] - ); - 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('does not classify a downstream cache-processing throw as cache_fetch_failed', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(54); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
Creative
' })), - } as Response); - const { log } = await import('../../../src/core/log'); - const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => { - throw new Error('success logging unavailable'); - }); - - try { - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const postMessage = vi.fn(); - const dispatch = () => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - - dispatch(); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(postMessage).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(54); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - - // A second request must run after the first downstream failure, proving - // the in-flight key was still cleared by the promise's finally handler. - dispatch(); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(fetchStub).toHaveBeenCalledTimes(2); - expect(postMessage).toHaveBeenCalledTimes(2); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - } finally { - debugSpy.mockRestore(); - beaconSpy.mockRestore(); - } - }); - - it.each([ - [ - 'an HTTP non-ok response', - (stub: ReturnType) => - stub.mockResolvedValue({ ok: false, status: 503 } as Response), - ], - [ - 'a response body read rejection', - (stub: ReturnType) => - stub.mockResolvedValue({ - ok: true, - text: () => Promise.reject(new Error('body unavailable')), - } as Response), - ], - [ - 'a network rejection', - (stub: ReturnType) => stub.mockRejectedValue(new Error('network unavailable')), - ], - ] as const)('records cache_fetch_failed once for %s', async (_description, arrangeFetch) => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(47); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - arrangeFetch(fetchStub); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(47, 'cache_fetch_failed'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it('records only response_post_failed when posting cached markup throws', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(48); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
Creative
' })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [ - { - postMessage: vi.fn(() => { - throw new Error('port closed'); - }), - }, - ], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(48, 'response_post_failed'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(beaconSpy).not.toHaveBeenCalled(); - beaconSpy.mockRestore(); - }); - - it.each(['request', 'response'] as const)( - 'keeps inline delivery and beacons unchanged when the diagnostics %s writer throws', - async (throwingWriter) => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn(() => { - if (throwingWriter === 'request') throw new Error('diagnostics request failed'); - return 49; - }); - const recordTrustedServerCreativeResponse = vi.fn(() => { - if (throwingWriter === 'response') throw new Error('diagnostics response failed'); - }); - const recordTrustedServerCreativeFailure = vi.fn(); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - tsjs.bids.homepage_header.adm = '
Creative
'; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - const postMessage = vi.fn(); - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(stopImmediatePropagation).toHaveBeenCalledTimes(1); - expect(postMessage).toHaveBeenCalledTimes(1); - expect(beaconSpy).toHaveBeenCalledTimes(2); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - if (throwingWriter === 'response') { - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(49); - } else { - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - } - beaconSpy.mockRestore(); - } - ); - - it('does not turn a throwing cache response diagnostic into a cache failure', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(50); - const recordTrustedServerCreativeResponse = vi.fn(() => { - throw new Error('diagnostics response failed'); - }); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ adm: '
Creative
' })), - } as Response); - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const postMessage = vi.fn(); - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(postMessage).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeResponse).toHaveBeenCalledWith(50); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - expect(beaconSpy).toHaveBeenCalledTimes(2); - beaconSpy.mockRestore(); - }); - - it('preserves missing-source fallback when the failure diagnostic throws', async () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(51); - const recordTrustedServerCreativeFailure = vi.fn(() => { - throw new Error('diagnostics failure writer failed'); - }); - const tsjs = (window as TestWindow).tsjs!; - tsjs.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse: vi.fn(), - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - delete tsjs.bids.homepage_header.hb_cache_host; - delete tsjs.bids.homepage_header.hb_cache_path; - - const bridgeListener = await captureBridgeListener(); - const source = createTrustedSlotIframe(); - const stopImmediatePropagation = vi.fn(); - expect(() => - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage: vi.fn() }], - source, - stopImmediatePropagation, - }) as unknown as MessageEvent - ) - ).not.toThrow(); - - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(51, 'missing_render_source'); - expect(stopImmediatePropagation).not.toHaveBeenCalled(); - expect(fetchStub).not.toHaveBeenCalled(); - }); - - it('uses the adInit-resolved div when a responsive prefix becomes ambiguous', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve('
Responsive Creative
'), - } as Response); - - const resolvedSlot = document.createElement('div'); - resolvedSlot.id = 'div-responsive-a'; - const iframe = document.createElement('iframe'); - resolvedSlot.appendChild(iframe); - document.body.appendChild(resolvedSlot); - const laterSibling = document.createElement('div'); - laterSibling.id = 'div-responsive-b'; - document.body.appendChild(laterSibling); - - (window as TestWindow).tsjs!.adSlots = [ - { - id: 'homepage_header', - formats: [[728, 90]] as [number, number][], - gam_unit_path: '/a/b/c', - div_id: 'div-responsive-', - targeting: {}, - }, - ]; - (window as TestWindow).tsjs!.divToSlotId = { - 'div-responsive-a': 'homepage_header', - }; - - const bridgeListener = await captureBridgeListener(); - const portMessages: string[] = []; - const fakePort = { postMessage: (s: string) => portMessages.push(s) }; - const stopSpy = vi.fn(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source: iframe.contentWindow, - stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent - ); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchStub).toHaveBeenCalledWith( - 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' } - ); - expect(portMessages).toHaveLength(1); - expect(stopSpy).toHaveBeenCalled(); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/win'); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp.example/bill'); - beaconSpy.mockRestore(); - }); - - it('declines to render when the PBS Cache response carries no adm', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(44); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - // 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(recordTrustedServerCreativeRequest).toHaveBeenCalledWith('homepage_header'); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledTimes(1); - expect(recordTrustedServerCreativeFailure).toHaveBeenCalledWith(44, 'invalid_cache_payload'); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - 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 and its collapsed shell from 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 collapsed = createCollapsedTrustedSlotIframe(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort], - source: collapsed.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); - expect(collapsed.iframe.width).toBe('300'); - expect(collapsed.iframe.height).toBe('250'); - expect(collapsed.wrapper.style.width).toBe('300px'); - expect(collapsed.wrapper.style.height).toBe('250px'); - beaconSpy.mockRestore(); - }); - - it('does not resize a stale cache response after navigation', async () => { - let resolveText: ((body: string) => void) | undefined; - fetchStub.mockResolvedValue({ - ok: true, - text: () => - new Promise((resolve) => { - resolveText = resolve; - }), - } as Response); - const bridgeListener = await captureBridgeListener(); - const collapsed = createCollapsedTrustedSlotIframe(); - const postMessage = vi.fn(); - - bridgeListener( - Object.assign(new Event('message'), { - data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [{ postMessage }], - source: collapsed.source, - stopImmediatePropagation: vi.fn(), - }) as unknown as MessageEvent - ); - await Promise.resolve(); - expect(resolveText).toBeDefined(); - (window as TestWindow).tsjs!.navGeneration = 1; - resolveText?.(JSON.stringify({ adm: '
cached
', w: 300, h: 250 })); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(postMessage).toHaveBeenCalledOnce(); - expect(collapsed.iframe.width).toBe('1'); - expect(collapsed.iframe.height).toBe('1'); - }); - - 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: {}, - }, - ], - divToSlotId: { 'div-a': 'slot_a', 'div-b': 'slot_b' }, - }; - - 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: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header' }, - }; - - 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: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header' }, - }; - - 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: {}, - }, - ], - divToSlotId: { 'div-header': 'homepage_header', 'div-in-content': 'homepage_in_content' }, - }; - - 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 () => { - const recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(52); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - 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); - expect(recordTrustedServerCreativeRequest).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeFailure).not.toHaveBeenCalled(); - 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 recordTrustedServerCreativeRequest = vi.fn().mockReturnValue(53); - const recordTrustedServerCreativeResponse = vi.fn(); - const recordTrustedServerCreativeFailure = vi.fn(); - (window as TestWindow).tsjs!.gptDiagnosticsRecorder = { - recordTrustedServerCreativeRequest, - recordTrustedServerCreativeResponse, - recordTrustedServerCreativeFailure, - } as unknown as TsjsApi['gptDiagnosticsRecorder']; - - 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(); - expect(recordTrustedServerCreativeRequest).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeResponse).not.toHaveBeenCalled(); - expect(recordTrustedServerCreativeFailure).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 5be1c679e..117483891 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 @@ -575,6 +575,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: [], }, }, @@ -631,6 +640,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 2365321fc..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts +++ /dev/null @@ -1,674 +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 GAM attribution bundle fallback', () => { - interface AttributionPubAds { - getSlots: () => unknown[]; - refresh: (...args: unknown[]) => void; - } - - interface AttributionGoogleTag { - cmd: Array<() => void>; - pubads: () => AttributionPubAds; - defineSlot: (...args: unknown[]) => unknown; - display: (...args: unknown[]) => void; - getConfig?: (keys: string | string[]) => Record | undefined; - setConfig?: (config: Record) => void; - } - - type AttributionWindow = Omit & { - tsjs?: Partial; - googletag?: AttributionGoogleTag; - __tsjs_gpt_enabled?: boolean; - __tsjs_installGptShim?: unknown; - __tsjs_slim_prebid_url?: string; - }; - - let win: AttributionWindow; - let executingScript: HTMLScriptElement | null; - let originalCurrentScriptDescriptor: PropertyDescriptor | undefined; - let originalPushState: History['pushState']; - let originalReplaceState: History['replaceState']; - let addEventListenerSpy: ReturnType; - - function makeGoogleTag(overrides: Partial = {}): AttributionGoogleTag { - const pubads: AttributionPubAds = { - getSlots: vi.fn(() => []), - refresh: vi.fn(), - }; - - return { - cmd: [], - pubads: vi.fn(() => pubads), - defineSlot: vi.fn(), - display: vi.fn(), - ...overrides, - }; - } - - function attributedScript(ownerDocument: Document = document): HTMLScriptElement { - const script = ownerDocument.createElement('script'); - script.id = 'trustedserver-js'; - script.setAttribute('data-ts-gam-attribution', 'true'); - return script; - } - - async function importFreshGptBundle(): Promise { - await import('../../../src/integrations/gpt/index'); - } - - beforeEach(async () => { - const guard = await importGuardModule(); - guard.resetGuardState(); - vi.resetModules(); - - win = window as AttributionWindow; - delete win.tsjs; - delete win.googletag; - delete win.__tsjs_gpt_enabled; - delete win.__tsjs_installGptShim; - delete win.__tsjs_slim_prebid_url; - - executingScript = null; - originalCurrentScriptDescriptor = Object.getOwnPropertyDescriptor(document, 'currentScript'); - Object.defineProperty(document, 'currentScript', { - configurable: true, - get: () => executingScript, - }); - - originalPushState = history.pushState; - originalReplaceState = history.replaceState; - addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(() => {}); - }); - - afterEach(async () => { - const guard = await importGuardModule(); - guard.resetGuardState(); - history.pushState = originalPushState; - history.replaceState = originalReplaceState; - if (originalCurrentScriptDescriptor) { - Object.defineProperty(document, 'currentScript', originalCurrentScriptDescriptor); - } else { - delete (document as unknown as Record).currentScript; - } - delete win.tsjs; - delete win.googletag; - delete win.__tsjs_gpt_enabled; - delete win.__tsjs_installGptShim; - delete win.__tsjs_slim_prebid_url; - vi.restoreAllMocks(); - }); - - it('reuses the existing queue and pushes exact targeting before adInit exists', async () => { - const queue: Array<() => void> = []; - const adInitAtPush: Array = []; - const arrayPush = queue.push.bind(queue); - queue.push = (...callbacks: Array<() => void>) => { - callbacks.forEach(() => adInitAtPush.push(win.tsjs?.adInit)); - return arrayPush(...callbacks); - }; - const setConfig = vi.fn(); - const tag = makeGoogleTag({ cmd: queue, setConfig }); - win.googletag = tag; - executingScript = attributedScript(); - const initialScriptCount = document.scripts.length; - const fetchSpy = vi.spyOn(globalThis, 'fetch'); - - await importFreshGptBundle(); - - expect(win.googletag!.cmd).toBe(queue); - expect(adInitAtPush[0]).toBeUndefined(); - expect(queue.length).toBeGreaterThan(0); - queue[0](); - expect(setConfig).toHaveBeenCalledWith({ targeting: { ts: 'true' } }); - expect(document.scripts).toHaveLength(initialScriptCount); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it.each([ - ['no current script', null], - ['missing attribute', undefined], - ['empty attribute', ''], - ['false attribute', 'false'], - ['non-exact attribute', 'TRUE'], - ])('fails closed for %s', async (_label, attributeValue) => { - if (attributeValue === null) { - executingScript = null; - } else { - executingScript = document.createElement('script'); - if (attributeValue !== undefined) { - executingScript.setAttribute('data-ts-gam-attribution', attributeValue); - } - } - - await importFreshGptBundle(); - - expect(win.googletag).toBeUndefined(); - }); - - it('ignores a marked duplicate-ID decoy when the executing tag is unmarked', async () => { - const decoy = attributedScript(); - document.head.appendChild(decoy); - executingScript = document.createElement('script'); - executingScript.id = 'trustedserver-js'; - - await importFreshGptBundle(); - - expect(win.googletag).toBeUndefined(); - decoy.remove(); - }); - - it('characterizes a marked document.write clone as able to activate', async () => { - const clonedDocument = document.implementation.createHTMLDocument('nested clone'); - clonedDocument.write(''); - clonedDocument.close(); - executingScript = clonedDocument.querySelector('script'); - const queue: Array<() => void> = []; - const setConfig = vi.fn(); - win.googletag = makeGoogleTag({ cmd: queue, setConfig }); - - await importFreshGptBundle(); - queue[0](); - - expect(setConfig).toHaveBeenCalledWith({ targeting: { ts: 'true' } }); - }); - - it.each(['missing', 'throwing'])( - 'keeps module installation working with %s setConfig', - async (setConfigMode) => { - const queue: Array<() => void> = []; - const setConfig = - setConfigMode === 'throwing' - ? vi.fn(() => { - throw new Error('publisher setConfig failed'); - }) - : undefined; - win.googletag = makeGoogleTag({ cmd: queue, setConfig }); - win.__tsjs_slim_prebid_url = 'https://cdn.example.com/slim-prebid.js'; - executingScript = attributedScript(); - - await importFreshGptBundle(); - - expect(() => [...queue].forEach((command) => command())).not.toThrow(); - expect(typeof win.tsjs?.adInit).toBe('function'); - expect(typeof win.tsjs?.scheduleInitialAdInit).toBe('function'); - expect(win.tsjs?.spaHookInstalled).toBe(true); - expect(addEventListenerSpy).toHaveBeenCalledWith('popstate', expect.any(Function)); - expect(addEventListenerSpy).toHaveBeenCalledWith('load', expect.any(Function)); - expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); - if (setConfig) { - expect(setConfig).toHaveBeenCalledWith({ targeting: { ts: 'true' } }); - } - } - ); - - it('preserves GPT-enabled shim behavior without queuing attribution when unmarked', async () => { - const queue: Array<() => void> = []; - const setConfig = vi.fn(); - const tag = makeGoogleTag({ cmd: queue, setConfig }); - win.googletag = tag; - win.__tsjs_gpt_enabled = true; - executingScript = document.createElement('script'); - - await importFreshGptBundle(); - [...queue].forEach((command) => command()); - const guard = await importGuardModule(); - - expect(guard.isGuardInstalled()).toBe(true); - expect(win.googletag).toBe(tag); - expect(win.googletag!.cmd).toBe(queue); - expect(setConfig).not.toHaveBeenCalled(); - expect(typeof win.tsjs?.adInit).toBe('function'); - }); -}); - -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 73b8ac9b6..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; - -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); -const BOOTSTRAP_SOURCE = readFileSync( - path.resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' -); - -function runBootstrap(): void { - new Function(BOOTSTRAP_SOURCE)(); -} - -/** - * 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('accepts only the first schedule call', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - ts.scheduleInitialAdInit!({ first: { hb_pb: '1.00' } }); - ts.scheduleInitialAdInit!({ second: { hb_pb: '2.00' } }); - expect(ts.bids).toEqual({ first: { hb_pb: '1.00' } }); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('keeps the first schedule claim across bootstrap-to-bundle handoff', async () => { - runBootstrap(); - const ts = (window as TestWindow).tsjs!; - const firstSlot = { - id: 'first_slot', - gam_unit_path: '/123/first', - div_id: 'div-first', - formats: [[300, 250]] as Array<[number, number]>, - }; - const secondSlot = { - id: 'second_slot', - gam_unit_path: '/123/second', - div_id: 'div-second', - formats: [[728, 90]] as Array<[number, number]>, - }; - - ts.scheduleInitialAdInit!({ first_slot: { hb_pb: '1.00' } }, [firstSlot]); - await importGptModule(); - const adInit = vi.fn(); - ts.adInit = adInit; - ts.scheduleInitialAdInit!({ second_slot: { hb_pb: '2.00' } }, [secondSlot]); - - expect(ts.bids).toEqual({ first_slot: { hb_pb: '1.00' } }); - expect(ts.adSlots).toEqual([firstSlot]); - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('still runs after a query-only history change before load', async () => { - // The SPA auction hook identifies routes by pathname only, so a query-only - // replaceState is not a navigation: it must neither trigger an auction nor - // cancel the pending initial adInit. (A URL-equality guard would abort - // here and leave the initial ads uninitialized.) - 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).not.toHaveBeenCalled(); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - 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('applies server targeting to a publisher-displayed slot before its refresh', async () => { - // A publisher that defined and displayed its GPT slot before window load - // still gets the server-side targeting applied before the refresh that - // delivers it — the deferred run must order setTargeting ahead of the ad - // request it triggers. - const div = document.createElement('div'); - div.id = 'div-atf-sidebar'; - document.body.appendChild(div); - const mockSlot = { - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - clearTargeting: 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(), - pubads: vi.fn().mockReturnValue(mockPubads), - enableServices: vi.fn(), - }; - 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]], - targeting: { pos: 'atf' }, - }, - ]; - ts.bids = { atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo' } }; - - ts.scheduleInitialAdInit!(); - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); - const targetingOrder = mockSlot.setTargeting.mock.invocationCallOrder[0]!; - const refreshOrder = mockPubads.refresh.mock.invocationCallOrder[0]!; - expect(targetingOrder).toBeLessThan(refreshOrder); - }); - - 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('applies the SSR slot definitions on the initial document', async () => { - // Under a shared-template mode the head script emits no `tsjs.adSlots`, so the - // `` seam is the only source of slot definitions. They must arrive, or - // `adInit()` iterates an empty list and the page defines no TS slots at all. - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adInit = vi.fn(); - const ssrSlot = { - id: 'ssr_slot', - gam_unit_path: '/123/ssr', - div_id: 'div-ssr', - formats: [[728, 90]] as Array<[number, number]>, - }; - - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, [ssrSlot]); - - expect(ts.adSlots).toEqual([ssrSlot]); - expect(ts.bids).toEqual({ ssr_slot: { hb_pb: '1.00' } }); - }); - - it('preserves head-injected slots when initialSlots is omitted', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adInit = vi.fn(); - const headSlot = { - id: 'head_slot', - gam_unit_path: '/123/head', - div_id: 'div-head', - formats: [[300, 250]] as Array<[number, number]>, - }; - ts.adSlots = [headSlot]; - - ts.scheduleInitialAdInit!({ head_slot: { hb_pb: '1.00' } }); - - expect(ts.adSlots).toEqual([headSlot]); - }); - - it('replaces existing slots when initialSlots is explicitly empty', async () => { - await importGptModule(); - const ts = (window as TestWindow).tsjs!; - ts.adInit = vi.fn(); - ts.adSlots = [ - { - id: 'stale_slot', - gam_unit_path: '/123/stale', - div_id: 'div-stale', - formats: [[300, 250]], - }, - ]; - - ts.scheduleInitialAdInit!({}, []); - - expect(ts.adSlots).toEqual([]); - }); - - it('drops the SSR slot definitions when a navigation has already committed', async () => { - // The guard covered the bids and the adInit call, but the shared-template seam - // assigned `tsjs.adSlots` on the line *before* calling the scheduler — outside the - // guard entirely. A navigation that committed while the SSR document was still - // streaming therefore kept its own bids and silently lost its slots to the stale - // SSR payload, and the next `adInit()` for that route defined the wrong slots. - 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); - const liveSlot = { - id: 'live_slot', - gam_unit_path: '/123/live', - div_id: 'div-live', - formats: [[300, 250]] as Array<[number, number]>, - }; - ts.adSlots = [liveSlot]; - - ts.scheduleInitialAdInit!({ ssr_slot: { hb_pb: '1.00' } }, [ - { - id: 'ssr_slot', - gam_unit_path: '/123/ssr', - div_id: 'div-ssr', - formats: [[728, 90]], - }, - ]); - - expect(ts.adSlots).toEqual([liveSlot]); - - window.dispatchEvent(new Event('load')); - flushFrame(); - flushFrame(); - expect(adInit).not.toHaveBeenCalled(); - }); - - 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 4648a02d9..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ /dev/null @@ -1,770 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -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 = ''; - // 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 only when a pathname navigation is accepted', async () => { - // The deferred initial-adInit bootstrap keys off this counter, so it must - // move in lockstep with the hook's own navigation identity: bumped - // synchronously for each accepted pathname change, untouched by the - // query-only and same-path history calls the hook ignores. - 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(1); - - history.pushState({}, '', '/next-page'); - expect(ts.navGeneration).toBe(1); - await flushAsync(); - }); - - it.each(['bootstrap', 'bundle'] as const)( - 'invalidates unclaimed GPT handoffs before an SPA fetch (%s)', - async (implementation) => { - let resolveFetch: ((response: unknown) => void) | undefined; - fetchStub.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFetch = resolve; - }) - ); - - const routeADiv = document.createElement('div'); - routeADiv.id = 'div-atf-sidebar'; - document.body.appendChild(routeADiv); - const routeASlot = { - getSlotElementId: vi.fn().mockReturnValue(routeADiv.id), - }; - const routeBSlot = { - getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar-2'), - }; - const nativeDefineSlot = vi.fn().mockReturnValue(routeBSlot); - const nativeDisplay = vi.fn(); - const pubads = { - getSlots: vi.fn().mockReturnValue([routeASlot]), - refresh: vi.fn(), - }; - const googletag = { - cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: nativeDefineSlot, - display: nativeDisplay, - pubads: vi.fn().mockReturnValue(pubads), - }; - const staleHandoff = { - gamUnitPath: '/123/atf', - formats: [[300, 250]], - divIdPrefix: 'div-atf-sidebar', - slotElementId: routeADiv.id, - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - const claimedHandoff = { - ...staleHandoff, - slotElementId: 'div-claimed', - publisherClaimed: true, - }; - (window as TestWindow).googletag = googletag; - (window as TestWindow).tsjs = { - gptSlotHandoffs: { - [staleHandoff.slotElementId]: staleHandoff, - 'div-atf-sidebar-hydrated': staleHandoff, - [claimedHandoff.slotElementId]: claimedHandoff, - }, - }; - - if (implementation === 'bootstrap') { - const bootstrap = readFileSync( - resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' - ); - window.eval(bootstrap); - } - await importGptModule(); - - routeADiv.remove(); - const routeBDiv = document.createElement('div'); - routeBDiv.id = 'div-atf-sidebar-2'; - document.body.appendChild(routeBDiv); - history.pushState({}, '', '/route-b'); - - const publisherSlot = ( - googletag.defineSlot as unknown as ( - adUnitPath: string, - formats: number[][], - elementId: string - ) => typeof routeBSlot - )('/123/atf', [[300, 250]], routeBDiv.id); - googletag.display(routeBDiv.id); - - expect(publisherSlot).toBe(routeBSlot); - expect(nativeDefineSlot).toHaveBeenCalledWith('/123/atf', [[300, 250]], routeBDiv.id); - expect(nativeDisplay).toHaveBeenCalledWith(routeBDiv.id); - expect((window as TestWindow).tsjs!.gptSlotHandoffs).toEqual({ - [claimedHandoff.slotElementId]: claimedHandoff, - }); - expect(resolveFetch).toBeDefined(); - - resolveFetch!({ ok: true, json: async () => ({ slots: [], bids: {} }) }); - 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', - 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 (template switch, auction gate, or consent - // denial) returns no slots. With no prior TS state to sweep, the hook must - // not call adInit() so a gated navigation cannot activate publisher GPT. - 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('applies bids immediately when a prefix-configured placement exists but is hidden', async () => { - // A breakpoint-hidden placement (mobile-only config while on desktop) has - // rendered its div but the tiered resolver returns no element for it. The - // slot wait must count it as present — otherwise every navigation to the - // route stalls for the full SPA_SLOT_WAIT_MS before applying bids to the - // visible slots, and adInit skips the hidden slot anyway. - document.body.innerHTML = - '
' + ''; - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [ - { id: 'visible', div_id: 'div-visible' }, - { id: 'hidden', div_id: 'ad-hidden-' }, - ], - bids: { visible: { hb_pb: '2.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/mixed-route'); - await flushAsync(); - - // Bids apply without waiting out the slot timeout. - expect(ts.adSlots).toEqual([ - { id: 'visible', div_id: 'div-visible' }, - { id: 'hidden', div_id: 'ad-hidden-' }, - ]); - expect(ts.bids).toEqual({ visible: { hb_pb: '2.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('checks for route containers directly in a hidden document', async () => { - vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); - vi.stubGlobal('requestAnimationFrame', undefined); - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 'hidden', div_id: 'div-hidden' }], - bids: { hidden: { hb_pb: '3.00' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/hidden-route'); - await flushAsync(); - expect(adInit).not.toHaveBeenCalled(); - - document.body.innerHTML = '
'; - await flushAsync(); - - expect(ts.adSlots).toEqual([{ id: 'hidden', div_id: 'div-hidden' }]); - expect(ts.bids).toEqual({ hidden: { hb_pb: '3.00' } }); - expect(adInit).toHaveBeenCalledTimes(1); - }); - - it('cancels a pending visible-tab frame when the document becomes hidden', async () => { - let visibility: DocumentVisibilityState = 'visible'; - vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility); - const requestAnimationFrameMock = vi.fn().mockReturnValue(17); - const cancelAnimationFrameMock = vi.fn(); - vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock); - vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameMock); - fetchStub.mockResolvedValue({ - ok: true, - json: async () => ({ - slots: [{ id: 'hidden-late', div_id: 'div-hidden-late' }], - bids: { 'hidden-late': { hb_pb: '3.50' } }, - }), - }); - const { installSpaAuctionHook } = await importGptModule(); - installSpaAuctionHook(); - const ts = (window as TestWindow).tsjs!; - const adInit = vi.fn(); - ts.adInit = adInit; - - history.pushState({}, '', '/hidden-late-route'); - await flushAsync(); - - // A mutation while visible schedules a frame that never runs. - document.body.appendChild(document.createElement('span')); - await flushAsync(); - expect(requestAnimationFrameMock).toHaveBeenCalledTimes(1); - - // The next mutation happens after the document is hidden. It must cancel - // the stale frame and perform the presence check immediately. - visibility = 'hidden'; - document.body.innerHTML = '
'; - await flushAsync(); - - expect(cancelAnimationFrameMock).toHaveBeenCalledWith(17); - expect(adInit).toHaveBeenCalledTimes(1); - expect(ts.adSlots).toEqual([{ id: 'hidden-late', div_id: 'div-hidden-late' }]); - }); - - 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('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 141420675..6a930f468 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 @@ -4747,143 +4747,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('keeps basic Prebid enabled when only the APS lifecycle API is unavailable', async () => { - vi.resetModules(); - const registerBidAdapter = vi.fn(); - const onEvent = vi.fn(); - const originalRequestBids = vi.fn(); - const compatiblePbjs = { - ...mockPbjs, - registerBidAdapter, - onEvent, - requestBids: originalRequestBids, - markWinningBidAsUsed: undefined, - que: [] as Array<() => void>, - cmd: [] as Array<() => void>, - }; - testWindow.pbjs = compatiblePbjs; - const pubads = { refresh: vi.fn() }; - const cmdPush = vi.fn((callback: () => void) => callback()); - testWindow.googletag = { cmd: { push: cmdPush }, pubads: () => pubads }; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await import('../../../src/integrations/prebid/index'); - - expect(registerBidAdapter).toHaveBeenCalledTimes(1); - expect(compatiblePbjs.requestBids).not.toBe(originalRequestBids); - - const adapter = registerBidAdapter.mock.calls[0][2] as TestAdapterSpec; - const convertedBids = adapter.interpretResponse( - { - body: { - seatbid: [ - { - seat: 'appnexus', - bid: [ - { - impid: 'ordinary-slot', - price: 2.5, - adm: '
ordinary creative
', - w: 300, - h: 250, - }, - ], - }, - { - seat: 'aps', - bid: [ - { - impid: 'aps-slot', - price: 3.5, - ext: { trusted_server: { renderer: apsRenderer() } }, - }, - ], - }, - ], - }, - }, - { - tsjsBidRequests: [ - { adUnitCode: 'ordinary-slot', bidId: 'ordinary-request' }, - { adUnitCode: 'aps-slot', bidId: 'aps-request' }, - ], - } - ); - - expect(convertedBids).toHaveLength(1); - expect(convertedBids[0]).toEqual( - expect.objectContaining({ - requestId: 'ordinary-request', - bidderCode: 'appnexus', - ad: '
ordinary creative
', - }) - ); - expect(convertedBids).not.toEqual( - expect.arrayContaining([expect.objectContaining({ bidderCode: 'aps' })]) - ); - expect(onEvent).not.toHaveBeenCalledWith('bidResponse', expect.any(Function)); - expect( - warnSpy.mock.calls.some((args) => - args.some( - (value) => typeof value === 'string' && value.includes('APS renderer bids disabled') - ) - ) - ).toBe(true); - expect(testWindow.__tsjsPrebidShimInstalled).toBe(true); - - warnSpy.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 62c826088..3e5548889 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 @@ -470,172 +470,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: [], - serverSideBidders: ['appnexus'], - }; - - 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: 'trustedServer', - params: { - bidderParams: { - appnexus: { placementId: 1 }, - pbsProviderId: { placementId: 2 }, - returnedSeatAlias: { placementId: 3 }, - }, - }, - }, - ], - }, - ], - 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'); - // Stored trustedServer params retain only authoritative server-side route - // codes; provider IDs and returned aliases cannot reach /auction. - 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 7a567d54e..ebbbf26a7 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 7006671cce95ab389a15b4aec80f308afed6ab7e 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 706/844] 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 | 1521 +++-------------- .../tests/shared/creative-sandbox.spec.ts | 107 +- .../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, 1521 insertions(+), 1496 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.width = String(bid.w); - frame.height = String(bid.h); - frame.style.border = '0'; - 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 580e7db3e..80a25899b 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 @@ -15,13 +15,57 @@ const CREATIVE_SANDBOX_TOKENS = [ "allow-top-navigation-by-user-activation", ].join(" "); -// A creative script that tries to redirect click resolution to an -// attacker origin. It executes before the runtime injected at the top of -// , so the stamp must be non-writable for the recovery below to stay on -// the first-party origin. -const HOSTILE_STAMP_OVERWRITE = ``; +// Mirrors the srcdoc document the client builds: the first-party parent stamps +// 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, + 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 ` + + + + + + + ad + +`; +} test.describe("Sandboxed creative iframe", () => { test("recovers a mutated click through the signed GET rebuild chain", async ({ @@ -55,15 +99,19 @@ test.describe("Sandboxed creative iframe", () => { // Reuse 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="), ); - 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}$/); // Mirrors the srcdoc document the client builds: the first-party parent // stamps its own origin ahead of any creative markup, then the runtime, @@ -105,12 +153,49 @@ test.describe("Sandboxed creative iframe", () => { iframe.style.height = "250px"; document.body.appendChild(iframe); }, - { sandbox: CREATIVE_SANDBOX_TOKENS, html: creativeDocument }, + { + sandbox: CREATIVE_SANDBOX_TOKENS, + 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 04aaad35e3c93256ed4af8e4517d4d2b09992d5b 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 707/844] 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 | 51 +++---- 12 files changed, 292 insertions(+), 109 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index a6c14d25c..8dc549b42 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. /// @@ -2077,7 +2076,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 2d4b7d29c..7817940d0 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -13039,7 +13039,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::with_plan( &settings, @@ -13089,12 +13089,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 709d0701f..08a0a96f6 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -312,7 +312,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, @@ -325,7 +325,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 883848509..000000000 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ /dev/null @@ -1,843 +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 || {}); - var tag; - - if (window.__tsjs_gam_attribution_enabled === true) { - tag = window.googletag = window.googletag || { cmd: [] }; - tag.cmd = tag.cmd || []; - tag.cmd.push(function () { - try { - var gpt = window.googletag; - if (gpt && typeof gpt.setConfig === "function") { - // "ts" is the fixed GAM key, not the local window.tsjs alias. - gpt.setConfig({ targeting: { ts: "true" } }); - } - } catch (error) { - // Attribution must not interrupt the existing bootstrap queue. - ts.log && - ts.log.warn && - ts.log.warn("GAM attribution targeting failed", error); - } - }); - } - - 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; - } - - tag = tag || (window.googletag = window.googletag || { cmd: [] }); - tag.cmd = tag.cmd || []; - tag.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; - }); - - var FIRST_IMPRESSION_LEASE_MS = 5000; - - function firstImpressionState(now) { - var generation = ts.navGeneration || 0; - if ( - !ts.firstImpression || - ts.firstImpression.generation !== generation - ) { - ts.firstImpression = { - generation: generation, - nextToken: 0, - slots: {}, - fallbackSlots: {}, - }; - } - var state = ts.firstImpression; - state.slots = state.slots || {}; - state.fallbackSlots = state.fallbackSlots || {}; - Object.keys(state.slots).forEach(function (elementId) { - var claim = state.slots[elementId]; - if ( - claim.generation !== generation || - claim.slotElementId !== elementId || - claim.element !== document.getElementById(elementId) || - !claim.element.isConnected - ) { - delete state.slots[elementId]; - return; - } - Object.keys(claim.publisherAuctions || {}).forEach(function (token) { - if (claim.publisherAuctions[token].expiresAt <= now) { - delete claim.publisherAuctions[token]; - } - }); - if ( - claim.owner === "publisher" && - (claim.phase === "auctioning" || claim.phase === "delivery_pending") && - Object.keys(claim.publisherAuctions || {}).length === 0 && - claim.expiresAt <= now - ) { - delete state.slots[elementId]; - } - }); - Object.keys(state.fallbackSlots).forEach(function (elementId) { - var element = state.fallbackSlots[elementId]; - if ( - !element.isConnected || - element.id !== elementId || - document.getElementById(elementId) !== element - ) { - delete state.fallbackSlots[elementId]; - } - }); - return state; - } - - function firstImpressionClaim(element) { - return firstImpressionState(Date.now()).slots[element.id]; - } - - function claimFirstImpressionForTrustedServer(element) { - var now = Date.now(); - var state = firstImpressionState(now); - if (state.slots[element.id]) return null; - var claim = { - generation: state.generation, - slotElementId: element.id, - element: element, - owner: "trusted_server", - phase: "delivery_pending", - expiresAt: now + FIRST_IMPRESSION_LEASE_MS, - publisherAuctions: {}, - }; - state.slots[element.id] = claim; - return claim; - } - - function releaseTrustedServerFirstImpressionClaim(element, claim) { - var state = firstImpressionState(Date.now()); - if ( - state.slots[element.id] === claim && - claim.owner === "trusted_server" && - claim.phase === "delivery_pending" && - Object.keys(claim.publisherAuctions || {}).length === 0 - ) { - delete state.slots[element.id]; - } - } - - function installFirstImpressionListeners() { - if (ts.firstImpressionListenersInstalled) return; - tag.cmd.push(function () { - if (ts.firstImpressionListenersInstalled) return; - var pubads = window.googletag.pubads(); - if (!pubads || typeof pubads.addEventListener !== "function") return; - var observe = function (phase) { - return function (event) { - var elementId = - event.slot && event.slot.getSlotElementId - ? event.slot.getSlotElementId() - : ""; - var element = elementId && document.getElementById(elementId); - if (!element) return; - var state = firstImpressionState(Date.now()); - var claim = state.slots[elementId]; - if (!claim) { - claim = state.slots[elementId] = { - generation: state.generation, - slotElementId: elementId, - element: element, - owner: "publisher", - phase: phase, - expiresAt: Number.POSITIVE_INFINITY, - publisherAuctions: {}, - }; - } else { - claim.phase = phase; - if (claim.owner === "publisher") { - claim.expiresAt = Number.POSITIVE_INFINITY; - } - } - }; - }; - pubads.addEventListener("slotRequested", observe("requested")); - pubads.addEventListener("slotRenderEnded", observe("rendered")); - ts.firstImpressionListenersInstalled = true; - }); - } - - installFirstImpressionListeners(); - - // 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, initialSlots) { - // The bundle may replace this scheduler after the fallback claims the initial - // pass. Keep the latch on the shared document API so replacement cannot reset it. - if ((ts.navGeneration || 0) !== 0 || ts.initialAdInitScheduled) return; - ts.initialAdInitScheduled = true; - // Slots are generation-guarded for the same reason the bids are: the - // shared-template seam sends both, and an assignment made before this call - // would overwrite a committed SPA navigation's slots. - if (initialSlots !== undefined) ts.adSlots = initialSlots; - if (initialBids !== undefined) 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 }); - }; - - 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) { - if (typeof element.checkVisibility === "function") { - return element.checkVisibility({ - checkVisibilityCSS: true, - visibilityProperty: true, - }); - } - - for (var current = element; current; current = current.parentElement) { - var style = window.getComputedStyle(current); - if ( - style.display === "none" || - style.visibility === "hidden" || - style.visibility === "collapse" - ) { - return false; - } - } - return true; - } - - 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; - } - - function resolveSlotElementByDivId(divId) { - if (!divId) { - return { element: null, prefixMatchCount: 0, activeMatchCount: 0 }; - } - // Exact-id matches intentionally skip the visibility tiers below: a - // configured literal id is unambiguous, so a hidden match is still the - // right element. Prefix matches go through the tiers because a prefix can - // match several candidates and only visibility/layout disambiguates them — - // so a hidden exact-id match resolves while a hidden prefix match does not. - var exact = document.getElementById(divId); - if (exact) { - return { element: exact, prefixMatchCount: 1, activeMatchCount: 1 }; - } - - 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, - // but it must still be visible through its ancestor containers. - if (prefixMatches.length === 1 && isElementVisible(prefixMatches[0])) { - return { - element: prefixMatches[0], - prefixMatchCount: 1, - activeMatchCount: 1, - }; - } - - var visibleMatches = prefixMatches.filter(isElementVisible); - if (visibleMatches.length === 1) { - return { - element: visibleMatches[0], - prefixMatchCount: prefixMatches.length, - activeMatchCount: 1, - }; - } - - var activeMatches = visibleMatches.filter(slotElementHasLayout); - return { - element: activeMatches.length === 1 ? activeMatches[0] : null, - prefixMatchCount: prefixMatches.length, - activeMatchCount: activeMatches.length, - }; - } - - 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(); - - function bootstrapTargeting(slot, bid) { - var targeting = Object.assign({}, slot.targeting || {}); - ["hb_pb", "hb_bidder", "hb_adid", "hb_cache_host", "hb_cache_path"].forEach( - function (key) { - if (bid[key]) targeting[key] = String(bid[key]); - }, - ); - targeting.ts_initial = "1"; - return targeting; - } - - function scheduleFirstImpressionFallback(slot, bid, element, generation) { - var state = firstImpressionState(Date.now()); - if (state.fallbackSlots[element.id]) return; - state.fallbackSlots[element.id] = element; - - var retry = function () { - if ( - (ts.navGeneration || 0) !== generation || - !element.isConnected || - document.getElementById(element.id) !== element - ) { - return; - } - var claim = firstImpressionClaim(element); - if (claim) { - if ( - claim.owner !== "publisher" || - claim.phase === "requested" || - claim.phase === "rendered" - ) { - return; - } - var delay = Math.max(0, claim.expiresAt - Date.now()); - if (delay > 0) { - window.setTimeout(retry, delay + 1); - return; - } - } - - tag.cmd.push(function () { - if ( - (ts.navGeneration || 0) !== generation || - !element.isConnected || - document.getElementById(element.id) !== element - ) { - return; - } - var fallbackClaim = claimFirstImpressionForTrustedServer(element); - if (!fallbackClaim) return; - var pubads = window.googletag.pubads(); - var existingSlots = pubads.getSlots ? pubads.getSlots() : []; - var gptSlot = - existingSlots.find(function (candidate) { - return candidate.getSlotElementId() === element.id; - }) || null; - var tsOwned = false; - if (!gptSlot) { - gptSlot = runHandoffInternal(function () { - return window.googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - element.id, - ); - }); - if (!gptSlot) { - releaseTrustedServerFirstImpressionClaim(element, fallbackClaim); - return; - } - gptSlot.addService(pubads); - tsOwned = true; - ts.gptSlotHandoffs = ts.gptSlotHandoffs || {}; - ts.gptSlotHandoffs[element.id] = { - gamUnitPath: slot.gam_unit_path, - formats: slot.formats, - divIdPrefix: slot.div_id, - slotElementId: element.id, - publisherClaimed: false, - suppressPublisherDisplay: false, - suppressPublisherRefresh: false, - }; - } - - var targeting = bootstrapTargeting(slot, bid); - Object.entries(targeting).forEach(function (entry) { - gptSlot.setTargeting(entry[0], entry[1]); - }); - fallbackClaim.targeting = targeting; - var slotElementId = gptSlot.getSlotElementId() || element.id; - ts.divToSlotId = ts.divToSlotId || {}; - ts.divToSlotId[element.id] = slot.id; - ts.divToSlotId[slotElementId] = slot.id; - if (tsOwned) { - ts.prevGptSlots = ts.prevGptSlots || []; - ts.prevGptSlots.push(gptSlot); - } - if (!ts.servicesEnabled) { - pubads.enableSingleRequest(); - window.googletag.enableServices(); - ts.servicesEnabled = true; - } - if (tsOwned) { - runHandoffInternal(function () { - window.googletag.display(slotElementId); - }); - } - syncInitialLoadDisabled(window.googletag); - if (!tsOwned || ts.gptInitialLoadDisabled) { - ts.adInitRefreshInProgress = true; - try { - runHandoffInternal(function () { - pubads.refresh([gptSlot]); - }); - } finally { - ts.adInitRefreshInProgress = false; - } - } - }); - }; - - retry(); - } - - 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; - var warnedResolutionFailures = Object.create(null); - - 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 visibility and - // geometry tiers for prefix matches. Responsive publishers may emit - // several mutually exclusive siblings for one stable prefix, so - // document order is not sufficient. - var resolution = resolveSlotElementByDivId(slot.div_id); - var el = resolution.element; - if (!el) { - if (!warnedResolutionFailures[slot.div_id]) { - if (resolution.prefixMatchCount > 1) { - warnedResolutionFailures[slot.div_id] = true; - if (ts.log && typeof ts.log.warn === "function") { - ts.log.warn( - "GPT slot prefix did not resolve to one active element", - { - divId: slot.div_id, - prefixMatchCount: resolution.prefixMatchCount, - activeMatchCount: resolution.activeMatchCount, - }, - ); - } - } else if ( - resolution.prefixMatchCount === 1 && - resolution.activeMatchCount === 0 - ) { - // The common breakpoint-hidden config: the prefix matched one - // element but it is hidden, so the slot is skipped. Logged so a - // blank placement is diagnosable without stepping the resolver. - warnedResolutionFailures[slot.div_id] = true; - if (ts.log && typeof ts.log.debug === "function") { - ts.log.debug( - "GPT slot prefix matched only a hidden element; skipping slot", - { divId: slot.div_id }, - ); - } - } - } - return; - } - var actualDivId = el.id; - var b = bids[slot.id] || {}; - var tsClaim = claimFirstImpressionForTrustedServer(el); - if (!tsClaim) { - var currentClaim = firstImpressionClaim(el); - if (currentClaim && currentClaim.owner === "publisher") { - scheduleFirstImpressionFallback(slot, b, el, generation); - } - return; - } - - 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) { - releaseTrustedServerFirstImpressionClaim(el, tsClaim); - 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, - }; - } - - var targeting = bootstrapTargeting(slot, b); - Object.entries(targeting).forEach(function (entry) { - s.setTargeting(entry[0], entry[1]); - }); - tsClaim.targeting = targeting; - // 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; - var hasRenderableWork = - slotsToDisplay.length > 0 || slotsToRefresh.length > 0; - if (!ts.servicesEnabled && hasRenderableWork) { - 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); - }); - }); - syncInitialLoadDisabled(window.googletag); - - // 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 - // 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 397561ca6..9e6469ea8 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -223,9 +223,8 @@ pub struct LegacyPrebidServerConfig { 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 7817940d0..c753258fb 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -21,21 +21,15 @@ //! 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::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime}; -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}; @@ -88,8 +82,8 @@ use crate::settings::{ AuctionDebugCommentFormat, AuctionDebugCommentOptions, AuctionDebugCommentVerbosity, 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; @@ -980,253 +974,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 @@ -2110,8 +1857,9 @@ async fn store_template_if_authorized( /// /// # Panics /// -/// Panics if the `ad_bids_state` mutex is poisoned, which requires a prior panic while -/// it was held. Every holder is a short, infallible read or write of an `Option`. +/// 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, @@ -3126,22 +2874,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` @@ -3158,356 +2890,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, - input_compression: Compression, - output_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, - input_compression, - output_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)?; - if output_compression == Compression::None { - return match input_compression { - Compression::None => body_close_hold_loop(body, output, processor, ctx).await, - Compression::Gzip => { - let decoder = GzipDecodeReader::new(body, max_body_bytes); - body_close_hold_loop(decoder, output, processor, ctx).await - } - Compression::Deflate => { - let decoder = ZlibDecoder::new(body); - body_close_hold_loop(decoder, output, processor, ctx).await - } - Compression::Brotli => { - let decoder = Decompressor::new(body, STREAM_CHUNK_SIZE); - body_close_hold_loop(decoder, output, processor, ctx).await - } - }; - } - - debug_assert_eq!(input_compression, output_compression); - match input_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, - input_compression: Compression, - output_compression: Compression, - ctx: AuctionCollectCtx<'_>, - max_body_bytes: usize, -) -> Result<(), Report> { - let AuctionCollectCtx { - dispatched, - telemetry, - deps: collect_refs, - } = ctx; - let mut decoder = BodyStreamDecoder::new(input_compression, max_body_bytes); - let mut encoder = BodyStreamEncoder::new(output_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, @@ -3581,7 +2963,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( @@ -3598,18 +2980,14 @@ async fn collect_stream_auction( settings, request_origin, } = deps; - let auction_id = telemetry - .auction_request - .as_ref() - .and_then(|_| diagnostics_auction_id(settings)); - 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( @@ -3646,35 +3024,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. @@ -4683,53 +4032,6 @@ pub(crate) fn build_auction_request( } } -/// Mint the browser-visible auction correlation token for GPT diagnostics. -/// -/// The token is freshly generated per auction and carries no user identity. -/// [`AuctionRequest::id`] must never be used here: for a consented visitor it is -/// `ts-{ec_id}`, so publishing it in `window.tsjs.bids` would hand the `HttpOnly` -/// EC identifier to any script on the page, and — being stable per visitor — it -/// could not distinguish one auction from the next either. -/// -/// Returns `None` unless the GPT diagnostics integration is enabled, since -/// nothing else consumes the value. -fn diagnostics_auction_id(settings: &Settings) -> Option { - crate::integrations::gpt_diagnostics::is_enabled(settings) - .then(|| format!("ts-auc-{}", uuid::Uuid::new_v4().simple())) -} - -/// 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::*; @@ -4948,349 +4250,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. -#[cfg(test)] -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, - ) -} - -pub(crate) 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.and_then(|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()), - ); - if let Some(auction_id) = auction_id { - obj.insert( - "hb_auction_id".to_string(), - serde_json::Value::String(auction_id.to_owned()), - ); - } - // 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)); - } - // hb_adid: use the PBS Cache UUID when present — the Prebid - // Universal Creative uses this as the cache lookup key. Fall back - // to the selected typed-renderer bid ID, then `adid`, then the - // OpenRTB bid ID. The latter is the last resort: it is unique per - // bid instance rather than a creative, but GAM echoes it verbatim - // so the render bridge can find the exact winning bid. - let renderer_bid_id = bid.renderer.as_ref().and_then(|renderer| { - renderer - .as_aps() - .map(|renderer| renderer.bid_id.as_str()) - }); - let hb_adid = non_empty(bid.cache_id.as_deref()) - .or_else(|| renderer_bid_id.and_then(|id| non_empty(Some(id)))) - .or_else(|| non_empty(bid.ad_id.as_deref())) - .or_else(|| non_empty(bid.bid_id.as_deref())); - if let Some(id) = hb_adid { - // GAM drops an over-long targeting value, so the creative - // echoes nothing and the bridge's equality check never - // matches. Log rather than truncate: a truncated ID is no - // longer unique per bid. - if id.len() > GAM_TARGETING_VALUE_MAX_LEN { - log::warn!( - "hb_adid for slot '{slot_id}' is {} characters, over GAM's \ - {GAM_TARGETING_VALUE_MAX_LEN}-character targeting value limit — \ - GAM may drop the key and the creative will not render", - id.len() - ); - } - 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)); - } - // 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. - let has_renderer = if let Some(ref renderer) = bid.renderer { - let renderer = match serde_json::to_value(renderer) { - Ok(renderer) => renderer, - Err(error) => { - log::warn!( - "Skipping winning bid for slot '{}' because its typed renderer could not be serialized: {error}", - slot_id - ); - return None; - } - }; - obj.insert("renderer".to_string(), renderer); - true - } else { - false - }; - // Every `Some(raw)` — including an explicit empty string, which - // PBS can return — counts as 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. - if let Some(ref raw_creative) = bid.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); - let adm = crate::creative::process_inline_auction_creative( - settings, - &base_origin, - &priced, - ); - if adm.trim().is_empty() { - // Rejected. The PBS Cache coordinates are deliberately - // NOT emitted as a fallback: the Universal Creative - // fetches the cached bid's ORIGINAL adm, which would - // hand the client an unprocessed copy of the very - // markup processing just refused. - if !has_renderer { - log::warn!( - "Skipping winning bid for slot '{}' because creative processing rejected its only render source", - slot_id - ); - return None; - } - log::warn!( - "Creative for slot '{}' from '{}' rejected by processing; rendering through its typed renderer without a cache fallback", - slot_id, - bid.bidder - ); - } else { - obj.insert("adm".to_string(), serde_json::Value::String(adm)); - } - } else { - // No creative of the bid's own, so the cache is the sole - // render source and its coordinates are safe to emit. The - // Prebid Universal Creative constructs: - // https://?uuid= - // - // Gated on a non-blank `cache_id`: PBS reports the cache - // `url` and `cacheId` independently, and hb_adid falls back - // to a non-cache identifier (`adid`, then the bid id). - // Emitting the coordinates without a cache UUID would point - // the Universal Creative at `?uuid=` — a - // guaranteed cache miss. The gate matches the `non_empty` - // chain used for hb_adid above so a blank `cacheId` cannot - // pass here while losing the hb_adid precedence. - if non_empty(bid.cache_id.as_deref()).is_some() { - 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, - "ad_id": bid.ad_id, - "cache_id": bid.cache_id, - "cache_host": bid.cache_host, - "cache_path": bid.cache_path, - "metadata": bid.metadata, - }), - ); - } - Some((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], @@ -5362,758 +4321,147 @@ fn match_renderable_slots( .collect() } -/// Why a response must not enter the shared transformed-template cache (template cache). -/// -/// `cache::core` is not an HTTP cache: it stores whatever bytes it is handed and -/// rejects nothing on its own. Every safety condition is the caller's to enforce, -/// so they are enumerated here rather than left implicit. +/// Whether the content type requires processing (URL rewriting, HTML injection). /// -/// Spike-only, for the #1009 ESI validation. -#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] -pub(crate) enum TemplateCacheBypassReason { - /// Not a shared-template mode; there is no template cache object to write. - #[display("assembly mode is inline")] - InlineMode, - /// The origin set a cookie. Caching this would replay one visitor's cookie - /// to the next — and the cookie-privacy net downgrades *our* response, which - /// happens after the cache has already stored the origin's. - #[display("origin response carries Set-Cookie")] - OriginSetCookie, - /// The origin declared the response non-shareable, or supplied CDN-specific - /// policy directives the template-cache path does not interpret and therefore cannot safely override. - #[display("origin cache policy is not eligible for template cache sharing")] - OriginNotShareable, - /// Cache directives or HTTP dates were malformed. Failing closed prevents a - /// parser disagreement from extending a representation's lifetime. - #[display("origin cache policy is malformed")] - MalformedCachePolicy, - /// Core Cache has no HTTP heuristic freshness. Template cache therefore requires an explicit, - /// still-positive origin lifetime rather than inventing one. - #[display("origin response has no positive shared freshness")] - NoPositiveFreshness, - /// The request was authenticated. #1009 describes a Basic-Auth-gated - /// deployment, so an authorized response entering a shared cache is a live - /// concern rather than a hypothetical one. - #[display("request carried Authorization")] - AuthorizedRequest, - /// Not a 200. This is also what covers a `DataDome` block, which replaces the - /// document with a `403` (`integrations/datadome/protection.rs:778`). - #[display("status was not 200 OK")] - NonOkStatus, - /// Not HTML, so there is no template to transform. - #[display("content type is not text/html")] - NotHtml, - /// A single-valued representation header was absent, repeated or malformed. - #[display("origin representation headers are ambiguous or malformed")] - MalformedRepresentationHeaders, - /// The body cannot be decoded by the transform. Authorizing it would rewrite - /// `Content-Encoding` while returning the untouched bytes on the fallback route. - #[display("origin content encoding is not supported by the template transform")] - UnsupportedContentEncoding, - /// The request carried a `Cookie`, which TS forwards to origin unchanged — there - /// is no `Cookie` strip on the publisher path. Cookie-personalized HTML is - /// therefore cross-servable unless the origin declares `Vary: Cookie` or marks - /// those responses private, and a response can be personalized without carrying - /// `Set-Cookie` itself when the session was established earlier. Named in §4 of - /// the design doc; disqualifying until the origin's `Vary` is verified to cover - /// it. - #[display("request carried Cookie and the origin's Vary does not cover it")] - CookieForwarded, - /// The origin varies on a header the cache key does not cover. - /// - /// The key is built *before* the fetch from a configured [`VarySpec`], because a - /// lookup cannot know what the origin varies on until it has responded. That makes - /// the configured list capable of going stale. This is the guard: once the origin's - /// `Vary` is finally known, a template whose key missed one of its headers must not - /// be stored, because a request differing only in that header would read it. - /// - /// Carries the uncovered header names rather than a bare flag, so a stale config is - /// identifiable from the log line instead of requiring a bisect. - #[display("origin varies on {_0}, which the cache key does not cover")] - VaryNotCovered(VaryGap), - /// `Vary: *` — the origin says no request key can select this representation. - /// - /// `VarySpec::uncovered_by` filters the wildcard out on the grounds that "the - /// eligibility gate handles it". It did not: nothing rejected it, so a `Vary: *` - /// response was shareable. A review found the gap between the comment and the code. - #[display("origin sent Vary: *, so no request key can select this response")] - VaryWildcard, - /// Cookie-selected HTML contradicts the reader-neutral-template contract even if - /// an operator accidentally lists `cookie` in the configured Vary key. - #[display("origin sent Vary: Cookie, contradicting cookie independence")] - VaryCookie, - /// A response-bound CSP nonce cannot safely be replayed with a shared document. - #[display("origin CSP contains a response-bound nonce")] - CspNonce, - /// A policy header selected for replay could not be represented losslessly. - #[display("origin policy header is malformed")] - MalformedPolicyHeader, +/// Text-based and JavaScript/JSON responses are processable; binary types +/// (images, fonts, video, etc.) pass through unchanged. +fn is_processable_content_type(content_type: &str) -> bool { + let normalized = content_type.to_ascii_lowercase(); + normalized.contains("text/") + || normalized.contains("application/javascript") + || normalized.contains("application/json") } -fn request_bypasses_template_cache(headers: &edgezero_core::http::HeaderMap) -> bool { - const CONDITIONAL_OR_PARTIAL: &[&str] = &[ - "range", - "if-range", - "if-match", - "if-none-match", - "if-modified-since", - "if-unmodified-since", - ]; - if CONDITIONAL_OR_PARTIAL - .iter() - .any(|name| headers.contains_key(*name)) - { - return true; - } +fn is_html_content_type(content_type: &str) -> bool { + content_type_contains_ascii_case_insensitive(content_type, "text/html") +} - for value in headers.get_all(header::CACHE_CONTROL) { - let Ok(value) = value.to_str() else { - return true; - }; - for directive in value.split(',').map(str::trim) { - let (name, argument) = directive - .split_once('=') - .map_or((directive, None), |(name, argument)| (name, Some(argument))); - match name.trim().to_ascii_lowercase().as_str() { - "no-cache" | "no-store" => return true, - // A browser reload's `max-age=0` requires a newly assembled response, - // not a second origin fetch for its reader-neutral template. The hit - // still runs this reader's auction and is stamped private/no-store. - // Positive or malformed constraints remain unprovable because template cache does - // not expose object age/remaining freshness at this layer. - "max-age" - if argument.and_then(|argument| parse_delta_seconds(argument).ok()) - != Some(0) => - { - return true; - } - "min-fresh" => return true, - _ => {} - } - } - } +fn content_type_contains_ascii_case_insensitive(content_type: &str, needle: &str) -> bool { + content_type.to_ascii_lowercase().contains(needle) +} - headers.get_all(header::PRAGMA).iter().any(|value| { - value.to_str().map_or(true, |value| { - value.split(',').any(|directive| { - directive - .split_once('=') - .map_or(directive, |(name, _)| name) - .trim() - .eq_ignore_ascii_case("no-cache") - }) - }) - }) +/// Whether the `Content-Encoding` is one the streaming pipeline can handle. +/// +/// Unsupported encodings (e.g. `zstd` from a misbehaving origin) bypass the +/// rewrite pipeline entirely and are returned unchanged. Processing such bodies +/// as identity-encoded would produce garbled output. +fn is_supported_content_encoding(encoding: &str) -> bool { + matches!(encoding, "" | "identity" | "gzip" | "deflate" | "br") } -/// The header names an origin's `Vary` named that the cache key did not cover. +/// Canonical URL path of the SPA re-auction endpoint. /// -/// A newtype rather than a bare `Vec` so [`TemplateCacheBypassReason`] stays `Display`-able -/// as one line, and so the empty case is unrepresentable at the call site — an empty gap -/// is not a bypass, it is a pass. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct VaryGap(Vec); +/// 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"; -impl core::fmt::Display for VaryGap { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str(&self.0.join(", ")) +/// 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. +/// Without a gate, any third-party page could trigger it from a visitor's +/// browser (it cannot read the JSON, but it burns SSP quota and leaks +/// outbound partner calls). +/// +/// A request is allowed when: +/// - `Sec-Fetch-Site` is `same-origin` (the tsjs SPA hook fetches a relative +/// URL, so a genuine same-origin navigation always reports this). `same-site` +/// is intentionally rejected: it admits sibling origins under the same +/// registrable domain, which are not trusted to spend SSP quota on the +/// visitor's behalf. +/// - `Sec-Fetch-Site` is absent (legacy client predating Fetch Metadata) **and** +/// the request carries the non-simple `X-TSJS-Page-Bids` header set by the +/// tsjs SPA hook — cross-origin callers cannot attach it without a CORS +/// preflight, which this endpoint never grants. +fn page_bids_request_allowed(req: &Request) -> bool { + match req + .headers() + .get("sec-fetch-site") + .and_then(|v| v.to_str().ok()) + { + Some(site) => site == "same-origin", + None => req.headers().contains_key("x-tsjs-page-bids"), } } -/// Operator policy applied after the origin authorizes shared freshness. -#[derive(Debug, Clone)] -struct TemplateCachePolicy { - key_vary: VarySpec, - max_age: Duration, +/// Builds the `403 Forbidden` returned when the side-effecting +/// `/_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. +/// +/// The GET handler's [`page_bids_request_allowed`] gate trusts the +/// `X-TSJS-Page-Bids` header precisely because this endpoint never grants a +/// 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`. +pub fn page_bids_preflight_denied() -> Response { + let mut response = Response::new(EdgeBody::from("Forbidden")); + *response.status_mut() = StatusCode::FORBIDDEN; + enforce_terminal_private_cache_privacy(&mut response); + response } -impl TemplateCachePolicy { - fn from_settings(settings: &Settings) -> Self { - settings.creative_opportunities.as_ref().map_or_else( - || Self { - key_vary: VarySpec::new([]), - max_age: Duration::from_secs(60), - }, - |config| Self { - key_vary: config.template_cache_vary(), - max_age: config.template_cache_max_age(), - }, - ) - } - - #[cfg(test)] - fn for_test(key_vary: &VarySpec, max_age: Duration) -> Self { - Self { - key_vary: key_vary.clone(), - max_age, - } - } +/// Builds the `400 Bad Request` returned for an unrecognized `format`. +/// +/// `private, no-store` like every other response from this endpoint, so an error +/// cannot be cached and replayed. +fn page_bids_unknown_format() -> Response { + let mut response = Response::new(EdgeBody::from("Unknown format")); + *response.status_mut() = StatusCode::BAD_REQUEST; + enforce_terminal_private_cache_privacy(&mut response); + response } -/// Whether a response may be written to the shared transformed-template cache. -/// -/// Returns [`None`] when it is safe to cache, or the first disqualifying reason. -/// Leak vectors are checked before mere ineligibility so the reported reason is -/// the most serious one that applies. +/// Normalizes the client-supplied `path` query parameter before glob matching. /// -/// See `docs/superpowers/archive/2026-08-08-esi-cacheable-root-validation-design.md` -/// §6.6 for why the C1 raw-origin/read-through cache, the reader-neutral template cache, and -/// the forbidden C3 final assembled-response cache are distinct. -#[cfg(test)] -pub(crate) fn template_cache_bypass_reason( - mode: AssemblyMode, - request_had_authorization: bool, - cookie_disqualifies: bool, - status: StatusCode, - content_type: &str, - response_headers: &edgezero_core::http::HeaderMap, - key_vary: &VarySpec, -) -> Option { - let policy = TemplateCachePolicy::for_test(key_vary, Duration::from_secs(60)); - template_cache_ttl( - mode, - request_had_authorization, - cookie_disqualifies, - status, - content_type, - response_headers, - &policy, - ) - .err() +/// The SPA hook sends `location.pathname`, but the parameter is +/// client-controlled: strip any query string or fragment and force a leading +/// `/` so slot `page_patterns` always match against a canonical path shape. +/// How the page-bids endpoint serializes its answer. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum PageBidsFormat { + /// `application/json`. What the SPA navigation hook consumes. + #[default] + Json, } -fn single_header_value( - headers: &edgezero_core::http::HeaderMap, - name: header::HeaderName, -) -> Result, TemplateCacheBypassReason> { - let mut values = headers.get_all(name).iter(); - let Some(first) = values.next() else { - return Ok(None); - }; - if values.next().is_some() { - return Err(TemplateCacheBypassReason::MalformedCachePolicy); +impl PageBidsFormat { + /// Parse the `format` query parameter. + /// + /// # Errors + /// + /// Returns the offending value if it names no known format. Unknown values are + /// rejected rather than defaulting so callers cannot silently negotiate a response + /// representation the endpoint no longer supports. + fn parse(raw: Option<&str>) -> Result { + match raw { + None | Some("json") => Ok(Self::Json), + Some(other) => Err(other.to_string()), + } } - first - .to_str() - .map(Some) - .map_err(|_| TemplateCacheBypassReason::MalformedCachePolicy) } -fn single_representation_header_value( - headers: &edgezero_core::http::HeaderMap, - name: header::HeaderName, -) -> Result, TemplateCacheBypassReason> { - let mut values = headers.get_all(name).iter(); - let Some(first) = values.next() else { - return Ok(None); - }; - if values.next().is_some() { - return Err(TemplateCacheBypassReason::MalformedRepresentationHeaders); +fn normalize_page_bids_path(raw: &str) -> String { + let path = raw.split(['?', '#']).next().unwrap_or(""); + if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") } - first - .to_str() - .map(Some) - .map_err(|_| TemplateCacheBypassReason::MalformedRepresentationHeaders) } -fn parse_delta_seconds(value: &str) -> Result { - let value = value.trim(); - let quoted_at_start = value.starts_with('"'); - let quoted_at_end = value.ends_with('"'); - let digits = match (quoted_at_start, quoted_at_end) { - (true, true) if value.len() >= 2 => &value[1..value.len() - 1], - (false, false) => value, - _ => return Err(TemplateCacheBypassReason::MalformedCachePolicy), - }; - if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(TemplateCacheBypassReason::MalformedCachePolicy); - } - digits - .parse::() - .map_err(|_| TemplateCacheBypassReason::MalformedCachePolicy) -} - -/// Parse the Fastly-specific freshness policy used by template cache's hosting platform. -/// -/// Fastly documents `max-age`, `stale-while-revalidate`, and `stale-if-error` for -/// `Surrogate-Control`. Template cache uses only `max-age` as fresh lifetime; the stale windows -/// are validated so malformed policy cannot hide beside a valid max age, but Core -/// Cache assembly does not serve stale templates under either extension. -/// -/// Unknown directives fail closed rather than inheriting semantics from another CDN. -fn surrogate_control_freshness( - headers: &edgezero_core::http::HeaderMap, -) -> Result, TemplateCacheBypassReason> { - let mut saw_header = false; - let mut max_age = None; - let mut stale_while_revalidate = None; - let mut stale_if_error = None; - - for value in headers.get_all("surrogate-control") { - saw_header = true; - let value = value - .to_str() - .map_err(|_| TemplateCacheBypassReason::MalformedCachePolicy)?; - if value.trim().is_empty() { - return Err(TemplateCacheBypassReason::MalformedCachePolicy); - } - for directive in value.split(',') { - let directive = directive.trim(); - if directive.is_empty() { - return Err(TemplateCacheBypassReason::MalformedCachePolicy); - } - let (name, value) = directive - .split_once('=') - .map_or((directive, None), |(name, value)| (name, Some(value))); - let name = name.trim().to_ascii_lowercase(); - match name.as_str() { - "private" | "no-store" | "no-cache" => { - return Err(TemplateCacheBypassReason::OriginNotShareable); - } - "max-age" => { - let parsed = parse_delta_seconds( - value.ok_or(TemplateCacheBypassReason::MalformedCachePolicy)?, - )?; - if max_age.replace(parsed).is_some() { - return Err(TemplateCacheBypassReason::MalformedCachePolicy); - } - } - "stale-while-revalidate" => { - let parsed = parse_delta_seconds( - value.ok_or(TemplateCacheBypassReason::MalformedCachePolicy)?, - )?; - if stale_while_revalidate.replace(parsed).is_some() { - return Err(TemplateCacheBypassReason::MalformedCachePolicy); - } - } - "stale-if-error" => { - let parsed = parse_delta_seconds( - value.ok_or(TemplateCacheBypassReason::MalformedCachePolicy)?, - )?; - if stale_if_error.replace(parsed).is_some() { - return Err(TemplateCacheBypassReason::MalformedCachePolicy); - } - } - _ => return Err(TemplateCacheBypassReason::MalformedCachePolicy), - } - } - } - - if !saw_header { - return Ok(None); - } - let max_age = max_age.ok_or(TemplateCacheBypassReason::NoPositiveFreshness)?; - if max_age == 0 { - return Err(TemplateCacheBypassReason::NoPositiveFreshness); - } - Ok(Some(Duration::from_secs(max_age))) -} - -fn origin_shared_ttl( - headers: &edgezero_core::http::HeaderMap, - max_age: Duration, -) -> Result { - origin_shared_ttl_at(headers, SystemTime::now(), max_age) -} - -fn origin_shared_ttl_at( - headers: &edgezero_core::http::HeaderMap, - now: SystemTime, - template_cache_max_age: Duration, -) -> Result { - let mut max_age = None; - let mut shared_max_age = None; - - for value in headers.get_all(header::CACHE_CONTROL) { - let value = value - .to_str() - .map_err(|_| TemplateCacheBypassReason::MalformedCachePolicy)?; - for directive in value.split(',').map(str::trim).filter(|v| !v.is_empty()) { - let (name, value) = directive - .split_once('=') - .map_or((directive, None), |(name, value)| (name, Some(value))); - match name.trim().to_ascii_lowercase().as_str() { - "private" | "no-store" | "no-cache" => { - return Err(TemplateCacheBypassReason::OriginNotShareable); - } - "max-age" => { - let parsed = parse_delta_seconds( - value.ok_or(TemplateCacheBypassReason::MalformedCachePolicy)?, - )?; - if max_age.replace(parsed).is_some() { - return Err(TemplateCacheBypassReason::MalformedCachePolicy); - } - } - "s-maxage" => { - let parsed = parse_delta_seconds( - value.ok_or(TemplateCacheBypassReason::MalformedCachePolicy)?, - )?; - if shared_max_age.replace(parsed).is_some() { - return Err(TemplateCacheBypassReason::MalformedCachePolicy); - } - } - _ => {} - } - } - } - - let date = single_header_value(headers, header::DATE)? - .map(httpdate::parse_http_date) - .transpose() - .map_err(|_| TemplateCacheBypassReason::MalformedCachePolicy)?; - let standard_freshness = match shared_max_age.or(max_age) { - Some(seconds) => Some(Duration::from_secs(seconds)), - None => single_header_value(headers, header::EXPIRES)? - .map(|value| { - let expires = httpdate::parse_http_date(value) - .map_err(|_| TemplateCacheBypassReason::MalformedCachePolicy)?; - expires - .duration_since(date.unwrap_or(now)) - .map_err(|_| TemplateCacheBypassReason::NoPositiveFreshness) - }) - .transpose()?, - }; - // Fastly gives Surrogate-Control precedence for its edge cache. Standard - // restrictive directives were still parsed above and remain hard refusals. - let freshness = surrogate_control_freshness(headers)? - .or(standard_freshness) - .ok_or(TemplateCacheBypassReason::NoPositiveFreshness)?; - - let age = single_header_value(headers, header::AGE)? - .map(parse_delta_seconds) - .transpose()? - .unwrap_or(0); - // `Age` may be absent even when an upstream cache emitted an old `Date`. - // RFC 9111's corrected age is at least the apparent age; ignoring it would - // grant an already-expired representation a new template cache lifetime. - let apparent_age = date - .and_then(|date| now.duration_since(date).ok()) - .unwrap_or_default(); - let current_age = Duration::from_secs(age).max(apparent_age); - let remaining = freshness - .checked_sub(current_age) - .filter(|duration| !duration.is_zero()) - .ok_or(TemplateCacheBypassReason::NoPositiveFreshness)?; - let capped = remaining.min(template_cache_max_age); - if capped.is_zero() { - return Err(TemplateCacheBypassReason::NoPositiveFreshness); - } - Ok(capped) -} - -fn replayable_policy_headers( - headers: &edgezero_core::http::HeaderMap, -) -> Result, TemplateCacheBypassReason> { - let mut captured = Vec::new(); - for name in crate::platform::REPLAYABLE_POLICY_HEADERS { - for value in headers.get_all(*name) { - let value = value - .to_str() - .map_err(|_| TemplateCacheBypassReason::MalformedPolicyHeader)?; - if (*name == "content-security-policy" - || *name == "content-security-policy-report-only") - && value.to_ascii_lowercase().contains("'nonce-") - { - return Err(TemplateCacheBypassReason::CspNonce); - } - captured.push(((*name).to_string(), value.to_string())); - } - } - Ok(captured) -} - -fn template_cache_ttl( - mode: AssemblyMode, - request_had_authorization: bool, - cookie_disqualifies: bool, - status: StatusCode, - content_type: &str, - response_headers: &edgezero_core::http::HeaderMap, - policy: &TemplateCachePolicy, -) -> Result { - if matches!(mode, AssemblyMode::Inline) { - return Err(TemplateCacheBypassReason::InlineMode); - } - if request_had_authorization { - return Err(TemplateCacheBypassReason::AuthorizedRequest); - } - if cookie_disqualifies { - return Err(TemplateCacheBypassReason::CookieForwarded); - } - if response_headers.contains_key(header::SET_COOKIE) { - return Err(TemplateCacheBypassReason::OriginSetCookie); - } - // Core Cache has no HTTP semantics. Fastly's documented Surrogate-Control subset - // is parsed by `origin_shared_ttl`; every other vendor-specific policy remains a - // bypass rather than guessing that unrelated CDNs share its grammar or precedence. - if crate::response_privacy::CDN_CACHE_HEADERS - .iter() - .filter(|name| **name != "surrogate-control") - .any(|name| response_headers.contains_key(*name)) - { - return Err(TemplateCacheBypassReason::OriginNotShareable); - } - // Checked here, among the leak vectors, because storing under a key that does not - // cover the origin's Vary is cross-serving rather than mere ineligibility: a request - // differing only in the uncovered header would read this template. - let mut vary_values = Vec::new(); - for value in response_headers.get_all(header::VARY) { - let value = value - .to_str() - .map_err(|_| TemplateCacheBypassReason::MalformedCachePolicy)?; - for name in value - .split(',') - .map(str::trim) - .filter(|name| !name.is_empty()) - { - if name == "*" { - return Err(TemplateCacheBypassReason::VaryWildcard); - } - if name.eq_ignore_ascii_case(header::COOKIE.as_str()) { - return Err(TemplateCacheBypassReason::VaryCookie); - } - header::HeaderName::from_bytes(name.as_bytes()) - .map_err(|_| TemplateCacheBypassReason::MalformedCachePolicy)?; - } - vary_values.push(value); - } - let uncovered = policy.key_vary.uncovered_by(vary_values); - if !uncovered.is_empty() { - return Err(TemplateCacheBypassReason::VaryNotCovered(VaryGap( - uncovered, - ))); - } - if status != StatusCode::OK { - return Err(TemplateCacheBypassReason::NonOkStatus); - } - let declared_content_type = - single_representation_header_value(response_headers, header::CONTENT_TYPE)?; - if declared_content_type.is_some_and(|declared| declared != content_type) - || !is_html_content_type(content_type) - { - return Err(TemplateCacheBypassReason::NotHtml); - } - let content_encoding = - single_representation_header_value(response_headers, header::CONTENT_ENCODING)? - .map_or_else( - || "identity".to_string(), - |value| value.trim().to_ascii_lowercase(), - ); - if content_encoding.is_empty() { - return Err(TemplateCacheBypassReason::MalformedRepresentationHeaders); - } - if !is_supported_content_encoding(&content_encoding) { - return Err(TemplateCacheBypassReason::UnsupportedContentEncoding); - } - replayable_policy_headers(response_headers)?; - origin_shared_ttl(response_headers, policy.max_age) -} - -/// What the `` seam injects, given the assembly mode. -/// -/// Under [`AssemblyMode::Inline`] the response is per-navigation and not shared, -/// so emitting `tsjs.adSlots` only when the ad stack runs is correct. -/// -/// Under [`AssemblyMode::Esi`] the document is a -/// **shared template**, and `should_run_ad_stack` is request-dependent — it folds -/// in consent, bot classification, prefetch status and the auction kill switch. -/// Emitting conditionally there would freeze the first-filling request's decision -/// for every later reader of the cached object: a consent-denied fill would serve -/// a no-ads template to consenting users, and a consenting fill would serve ad -/// markup to someone who refused. -/// -/// So ESI returns [`None`] **unconditionally**, and `adSlots` moves to the -/// per-request body seam alongside the bids. The head is not a template hole. -/// -/// See `docs/superpowers/archive/2026-08-08-esi-cacheable-root-validation-design.md` -/// §6.7. -pub(crate) fn template_ad_slots_script( - mode: AssemblyMode, - should_run_ad_stack: bool, - settings: &Settings, - matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], - request_path: &str, -) -> Option { - match mode { - AssemblyMode::Esi => None, - AssemblyMode::Inline => { - if !should_run_ad_stack { - return None; - } - settings - .creative_opportunities - .as_ref() - .map(|co_config| build_ad_slots_script(matched_slots, co_config, request_path)) - } - } -} - -/// 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 -/// (images, fonts, video, etc.) pass through unchanged. -fn is_processable_content_type(content_type: &str) -> bool { - let normalized = content_type.to_ascii_lowercase(); - normalized.contains("text/") - || normalized.contains("application/javascript") - || normalized.contains("application/json") -} - -fn is_html_content_type(content_type: &str) -> bool { - content_type_contains_ascii_case_insensitive(content_type, "text/html") -} - -fn content_type_contains_ascii_case_insensitive(content_type: &str, needle: &str) -> bool { - content_type.to_ascii_lowercase().contains(needle) -} - -/// Whether the `Content-Encoding` is one the streaming pipeline can handle. -/// -/// Unsupported encodings (e.g. `zstd` from a misbehaving origin) bypass the -/// rewrite pipeline entirely and are returned unchanged. Processing such bodies -/// as identity-encoded would produce garbled output. -fn is_supported_content_encoding(encoding: &str) -> bool { - matches!(encoding, "" | "identity" | "gzip" | "deflate" | "br") -} - -/// 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"; - -/// 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. -/// Without a gate, any third-party page could trigger it from a visitor's -/// browser (it cannot read the JSON, but it burns SSP quota and leaks -/// outbound partner calls). -/// -/// A request is allowed when: -/// - `Sec-Fetch-Site` is `same-origin` (the tsjs SPA hook fetches a relative -/// URL, so a genuine same-origin navigation always reports this). `same-site` -/// is intentionally rejected: it admits sibling origins under the same -/// registrable domain, which are not trusted to spend SSP quota on the -/// visitor's behalf. -/// - `Sec-Fetch-Site` is absent (legacy client predating Fetch Metadata) **and** -/// the request carries the non-simple `X-TSJS-Page-Bids` header set by the -/// tsjs SPA hook — cross-origin callers cannot attach it without a CORS -/// preflight, which this endpoint never grants. -fn page_bids_request_allowed(req: &Request) -> bool { - match req - .headers() - .get("sec-fetch-site") - .and_then(|v| v.to_str().ok()) - { - Some(site) => site == "same-origin", - None => req.headers().contains_key("x-tsjs-page-bids"), - } -} - -/// Builds the `403 Forbidden` returned when the side-effecting -/// `/_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. -/// -/// The GET handler's [`page_bids_request_allowed`] gate trusts the -/// `X-TSJS-Page-Bids` header precisely because this endpoint never grants a -/// 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`. -pub fn page_bids_preflight_denied() -> Response { - let mut response = Response::new(EdgeBody::from("Forbidden")); - *response.status_mut() = StatusCode::FORBIDDEN; - enforce_terminal_private_cache_privacy(&mut response); - response -} - -/// Builds the `400 Bad Request` returned for an unrecognized `format`. -/// -/// `private, no-store` like every other response from this endpoint, so an error -/// cannot be cached and replayed. -fn page_bids_unknown_format() -> Response { - let mut response = Response::new(EdgeBody::from("Unknown format")); - *response.status_mut() = StatusCode::BAD_REQUEST; - enforce_terminal_private_cache_privacy(&mut response); - response -} - -/// Normalizes the client-supplied `path` query parameter before glob matching. -/// -/// The SPA hook sends `location.pathname`, but the parameter is -/// client-controlled: strip any query string or fragment and force a leading -/// `/` so slot `page_patterns` always match against a canonical path shape. -/// How the page-bids endpoint serializes its answer. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub(crate) enum PageBidsFormat { - /// `application/json`. What the SPA navigation hook consumes. - #[default] - Json, -} - -impl PageBidsFormat { - /// Parse the `format` query parameter. - /// - /// # Errors - /// - /// Returns the offending value if it names no known format. Unknown values are - /// rejected rather than defaulting so callers cannot silently negotiate a response - /// representation the endpoint no longer supports. - fn parse(raw: Option<&str>) -> Result { - match raw { - None | Some("json") => Ok(Self::Json), - Some(other) => Err(other.to_string()), - } - } -} - -fn normalize_page_bids_path(raw: &str) -> String { - let path = raw.split(['?', '#']).next().unwrap_or(""); - if path.starts_with('/') { - path.to_string() - } else { - format!("/{path}") - } -} - -/// Normalize the page path and query retained in the upstream auction context. -/// -/// Fragments never reach an HTTP server and are discarded if a client includes -/// one explicitly. Unlike [`normalize_page_bids_path`], the query is preserved -/// for the `OpenRTB` `site.page` URL and is not used for slot matching. -fn normalize_page_bids_path_and_query(raw: &str) -> String { - let path_and_query = raw.split('#').next().unwrap_or(""); - if path_and_query.starts_with('/') { - path_and_query.to_string() - } else { - format!("/{path_and_query}") +/// Normalize the page path and query retained in the upstream auction context. +/// +/// Fragments never reach an HTTP server and are discarded if a client includes +/// one explicitly. Unlike [`normalize_page_bids_path`], the query is preserved +/// for the `OpenRTB` `site.page` URL and is not used for slot matching. +fn normalize_page_bids_path_and_query(raw: &str) -> String { + let path_and_query = raw.split('#').next().unwrap_or(""); + if path_and_query.starts_with('/') { + path_and_query.to_string() + } else { + format!("/{path_and_query}") } } @@ -6274,14 +4622,13 @@ pub async fn handle_page_bids( ); } - // The dedicated template switch, [auction].enabled, 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 gate would stop SSP calls but still let the - // client create/refresh GPT slots client-side. Bot/prefetch requests, by - // contrast, keep their slot definitions (the placement structure is - // unchanged) but skip the live auction, matching the existing behavior. - let ad_stack_enabled = ad_templates_enabled && auction_enabled && consent_allows_auction; + // 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 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; let projection = if matched_slots.is_empty() { page_bids_terminal_projection_v1( @@ -6461,7 +4808,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; @@ -7622,47 +5968,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 @@ -10213,187 +8518,6 @@ mod tests { "disabled [creative_opportunities].enabled switch should skip TS ad stack and injection" ); } - #[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 = AdBidsState::default(); - 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 = AdBidsState::default(); - 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 - .script_cell() - .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 - .script_cell() - .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() { @@ -13355,26 +11479,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; - - // Default settings are enough for the creative boundary: the sanitize - // pass needs no config, and `rewrite_creative_html` only signs URLs it - // actually rewrites (none of these fixtures carry proxyable URLs). - fn test_settings() -> Settings { - Settings::default() - } fn make_config() -> CreativeOpportunitiesConfig { CreativeOpportunitiesConfig { @@ -13413,111 +11525,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(), - returned_seat: None, - width: 300, - height: 250, - nurl: Some(nurl.to_string()), - burl: Some(burl.to_string()), - bid_id: None, - creative_id: None, - renderer: None, - ad_id: Some(ad_id.to_string()), - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - } - } - - #[test] - fn ad_slots_script_contains_slot_data() { - let mut slot = make_slot(); - slot.targeting - .insert("ts".to_string(), "operator-value".to_string()); - let slots = vec![slot]; - let config = make_config(); - let script = build_ad_slots_script(&slots, &config, "/"); - let slot_json = crate::publisher::build_slot_json(&slots[0], &config, "example") - .expect("should build slot JSON"); - 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" - ); - assert_eq!( - slot_json["targeting"]["ts"], "operator-value", - "should forward operator-provided ts targeting verbatim" - ); - } - - #[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()); @@ -13526,25 +11535,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(); @@ -13556,113 +11562,147 @@ 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", - ), + 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: "/2024/01/my-article/", + }; + 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"), ); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, + + 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 ); - 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" + } + + #[test] + fn auction_request_uses_configured_publisher_domain_not_edge_host() { + // On the SSAT proxy path the browser addresses the trusted-server + // edge host, but the auction must advertise the configured + // publisher domain to SSPs — otherwise injected creatives and the + // brand-safety pixel leak the edge/staging host. + let slot = make_slot(); + let slots = [slot]; + let slots_ctx = MatchedSlotsContext { + matched_slots: &slots, + request_path: "/2024/01/my-article/?edition=fictional", + }; + let request_info = RequestInfo { + host: "ts.example.com".to_string(), + scheme: "https".to_string(), + }; + + let request = build_auction_request( + &slots_ctx, + None, + &ConsentContext::default(), + &request_info, + "www.example.com", + Some("Mozilla/5.0"), ); + assert_eq!( - obj.get("hb_bidder").and_then(|v| v.as_str()), - Some("kargo"), - "should include bidder" + request.publisher.domain, "www.example.com", + "publisher.domain should be the configured publisher domain, not the edge host" ); + let site = request.site.expect("should populate site metadata"); 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" + site.domain, "www.example.com", + "site.domain should be the configured publisher domain, not the edge host" ); assert_eq!( - obj.get("nurl").and_then(|v| v.as_str()), - Some("https://ssp/win"), - "should include nurl" + request.publisher.page_url.as_deref(), + Some("https://www.example.com/2024/01/my-article/"), + "page_url should use configured publisher identity without client query data" ); assert_eq!( - obj.get("burl").and_then(|v| v.as_str()), - Some("https://ssp/bill"), - "should include burl" + site.page, "https://www.example.com/2024/01/my-article/", + "site.page should use configured publisher identity without client query data" ); } - /// Guards the browser-visible token every auction path shares: it must - /// be fresh per auction and absent unless diagnostics can consume it. #[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)]); + fn auction_request_preserves_configured_publisher_domain_with_query() { + // On the SSAT proxy path the browser addresses the trusted-server + // edge host, but the auction must advertise the configured + // publisher domain to SSPs — otherwise injected creatives and the + // brand-safety pixel leak the edge/staging host. + let slot = make_slot(); + let slots = [slot]; + let slots_ctx = MatchedSlotsContext { + matched_slots: &slots, + request_path: "/2024/01/my-article/?edition=fictional", + }; + let request_info = RequestInfo { + host: "ts.example.com".to_string(), + scheme: "https".to_string(), + }; - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - let first = - diagnostics_auction_id(&settings).expect("enabled diagnostics should mint a token"); - let second = - diagnostics_auction_id(&settings).expect("enabled diagnostics should mint a token"); + let request = build_auction_request( + &slots_ctx, + None, + &ConsentContext::default(), + &request_info, + "www.example.com", + Some("Mozilla/5.0"), + ); - assert!( - first.starts_with("ts-auc-"), - "token should use the diagnostics prefix, got `{first}`" + assert_eq!( + request.publisher.domain, "www.example.com", + "publisher.domain should be the configured publisher domain, not the edge host" + ); + let site = request.site.expect("should populate site metadata"); + assert_eq!( + site.domain, "www.example.com", + "site.domain should be the configured publisher domain, not the edge host" + ); + assert_eq!( + request.publisher.page_url.as_deref(), + Some("https://www.example.com/2024/01/my-article/"), + "page_url should remove client query data" + ); + assert_eq!( + site.page, "https://www.example.com/2024/01/my-article/", + "site.page should remove client query data" ); - assert_ne!(first, second, "each auction should mint its own token"); } #[test] - fn initial_document_bids_script_includes_auction_id_only_for_winning_bids() { + fn auction_request_with_ec_id_sets_user_id_and_ec_request_id() { let slot = make_slot(); let slots = [slot]; let slots_ctx = MatchedSlotsContext { @@ -13673,1298 +11713,10 @@ mod tests { host: "publisher.example.com".to_string(), scheme: "https".to_string(), }; - let mut auction_request = build_auction_request( + + let request = build_auction_request( &slots_ctx, - None, - &ConsentContext::default(), - &request_info, - "publisher.example.com", - Some("Mozilla/5.0"), - ); - auction_request.id = "initial-auction-example-123".to_string(); - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - make_bid( - "atf_sidebar_ad", - 1.50, - "example_bidder", - "abc123", - "https://example.com/win", - "https://example.com/bill", - ), - ); - - let state = AdBidsState::default(); - write_bids_to_state( - &winning_bids, - PriceGranularity::Dense, - &state, - &test_settings(), - "", - false, - Some(&auction_request.id), - ); - let script = state - .script_cell() - .lock() - .expect("should lock initial bid state") - .clone() - .expect("should generate initial-document bids script"); - let bid_json = script - .strip_prefix( - "", - ) - }) - .expect("should emit the initial-document tsjs.bids script shape"); - let bid_json: String = serde_json::from_str(&format!("\"{bid_json}\"")) - .expect("should decode initial-document JSON.parse input"); - let bids: serde_json::Value = serde_json::from_str(&bid_json) - .expect("should serialize initial-document bids as JSON"); - - assert_eq!( - bids["atf_sidebar_ad"]["hb_auction_id"], auction_request.id, - "initial-document bids should expose the current request ID only on the winner" - ); - - write_bids_to_state( - &HashMap::new(), - PriceGranularity::Dense, - &state, - &test_settings(), - "", - false, - Some(&auction_request.id), - ); - let empty_script = state - .script_cell() - .lock() - .expect("should lock empty initial bid state") - .clone() - .expect("should generate empty initial-document bids script"); - let empty_bid_json = empty_script - .strip_prefix( - "", - ) - }) - .expect("should emit the empty initial-document tsjs.bids script shape"); - let empty_bid_json: String = serde_json::from_str(&format!("\"{empty_bid_json}\"")) - .expect("should decode empty initial-document JSON.parse input"); - let empty_bids: serde_json::Value = serde_json::from_str(&empty_bid_json) - .expect("should serialize empty initial-document bids as JSON"); - assert!( - empty_bids - .as_object() - .expect("initial-document bids should be an object") - .is_empty(), - "initial-document bids should not fabricate metadata without a winner" - ); - } - - #[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 bid is omitted rather than - // recording a blank winner or 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, - ); - assert!( - !map.contains_key("atf_sidebar_ad"), - "should omit the bid 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(), - returned_seat: None, - width: 300, - height: 250, - nurl: None, - burl: None, - ad_id: Some("bid-impression-id".to_string()), - bid_id: Some("openrtb-bid-id".to_string()), - creative_id: None, - // No typed renderer: these cases assert what happens when the - // supplied markup is the bid's only render source. - renderer: None, - cache_id: Some("cache-uuid".to_string()), - cache_host: Some("prebid-cache.example.com".to_string()), - cache_path: Some("/cache".to_string()), - metadata: Default::default(), - } - } - - // These fixtures carry cache coordinates but no typed renderer, so a - // rejected creative leaves the bid with no render source at all and it - // is dropped outright — which subsumes the property under test: the - // cache coordinates never reach the client, so the cached (unprocessed) - // copy of the markup cannot be fetched in place of what was refused. - 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); - - match map.get("atf_sidebar_ad").and_then(|v| v.as_object()) { - None => {} - Some(obj) => { - 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(); - - 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(); - - 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(); - - 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()); - bid.nurl = None; - bid.burl = None; - 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, - })); - let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &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_both_cache_id_and_ad_id_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: "amazon-aps".to_string(), - returned_seat: None, - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: None, - creative_id: None, - renderer: None, - ad_id: 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 and no ad_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(), - returned_seat: None, - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: None, - creative_id: None, - renderer: None, - ad_id: 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: "/2024/01/my-article/", - }; - 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 - ); - } - - #[test] - fn auction_request_uses_configured_publisher_domain_not_edge_host() { - // On the SSAT proxy path the browser addresses the trusted-server - // edge host, but the auction must advertise the configured - // publisher domain to SSPs — otherwise injected creatives and the - // brand-safety pixel leak the edge/staging host. - let slot = make_slot(); - let slots = [slot]; - let slots_ctx = MatchedSlotsContext { - matched_slots: &slots, - request_path: "/2024/01/my-article/?edition=fictional", - }; - let request_info = RequestInfo { - host: "ts.example.com".to_string(), - scheme: "https".to_string(), - }; - - let request = build_auction_request( - &slots_ctx, - None, - &ConsentContext::default(), - &request_info, - "www.example.com", - Some("Mozilla/5.0"), - ); - - assert_eq!( - request.publisher.domain, "www.example.com", - "publisher.domain should be the configured publisher domain, not the edge host" - ); - let site = request.site.expect("should populate site metadata"); - assert_eq!( - site.domain, "www.example.com", - "site.domain should be the configured publisher domain, not the edge host" - ); - assert_eq!( - request.publisher.page_url.as_deref(), - Some("https://www.example.com/2024/01/my-article/"), - "page_url should use configured publisher identity without client query data" - ); - assert_eq!( - site.page, "https://www.example.com/2024/01/my-article/", - "site.page should use configured publisher identity without client query data" - ); - } - - #[test] - fn auction_request_preserves_configured_publisher_domain_with_query() { - // On the SSAT proxy path the browser addresses the trusted-server - // edge host, but the auction must advertise the configured - // publisher domain to SSPs — otherwise injected creatives and the - // brand-safety pixel leak the edge/staging host. - let slot = make_slot(); - let slots = [slot]; - let slots_ctx = MatchedSlotsContext { - matched_slots: &slots, - request_path: "/2024/01/my-article/?edition=fictional", - }; - let request_info = RequestInfo { - host: "ts.example.com".to_string(), - scheme: "https".to_string(), - }; - - let request = build_auction_request( - &slots_ctx, - None, - &ConsentContext::default(), - &request_info, - "www.example.com", - Some("Mozilla/5.0"), - ); - - assert_eq!( - request.publisher.domain, "www.example.com", - "publisher.domain should be the configured publisher domain, not the edge host" - ); - let site = request.site.expect("should populate site metadata"); - assert_eq!( - site.domain, "www.example.com", - "site.domain should be the configured publisher domain, not the edge host" - ); - assert_eq!( - request.publisher.page_url.as_deref(), - Some("https://www.example.com/2024/01/my-article/"), - "page_url should remove client query data" - ); - assert_eq!( - site.page, "https://www.example.com/2024/01/my-article/", - "site.page should remove client query data" - ); - } - - #[test] - fn auction_request_with_ec_id_sets_user_id_and_ec_request_id() { - let slot = make_slot(); - let slots = [slot]; - let slots_ctx = MatchedSlotsContext { - matched_slots: &slots, - request_path: "/2024/01/my-article/", - }; - let request_info = RequestInfo { - host: "publisher.example.com".to_string(), - scheme: "https".to_string(), - }; - - let request = build_auction_request( - &slots_ctx, - Some("ec-abc"), + Some("ec-abc"), &ConsentContext::default(), &request_info, "publisher.example.com", @@ -14981,50 +11733,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 { @@ -15225,67 +11933,11 @@ mod tests { } fn make_page_bids_request(path: &str) -> Request { - make_page_bids_request_on(PAGE_BIDS_PATH, path) - } - - #[tokio::test] - async fn page_bids_format_absent_or_json_returns_json() { - let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - for path_and_format in ["/2024/article", "/2024/article&format=json"] { - let response = run_page_bids_response( - &settings, - &orchestrator, - &[], - make_page_bids_request(path_and_format), - ) - .await; - assert_eq!( - response.status(), - StatusCode::OK, - "should accept page-bids format in `{path_and_format}`" - ); - assert_eq!( - response.headers().get(header::CONTENT_TYPE), - Some(&HeaderValue::from_static("application/json")), - "should return JSON for `{path_and_format}`" - ); - assert!( - response - .extensions() - .get::() - .is_some(), - "successful per-user page-bids JSON should remain terminal-private" - ); - } - } - - #[tokio::test] - async fn page_bids_format_rejects_removed_unknown_and_empty_values() { - let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - for format in ["fragment", "scrpit", ""] { - let response = run_page_bids_response( - &settings, - &orchestrator, - &[], - make_page_bids_request(&format!("/2024/article&format={format}")), - ) - .await; - assert_eq!( - response.status(), - StatusCode::BAD_REQUEST, - "should reject page-bids format `{format}`" - ); - } - } - - /// 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 @@ -15596,10 +12248,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
' - ); - const trustedServer = vi.fn(() => true); - - expect( - dispatchDefaultApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer, - }) - ).toBe(true); - expect(trustedServer).toHaveBeenCalledOnce(); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - - document.head - .querySelectorAll( - 'meta[name="trusted-server-aps-rendering-mode"], script[data-ts-aps-rendering-mode]' - ) - .forEach((element) => element.remove()); - document.body.innerHTML = ''; - }); -}); - -describe('publisher-native APS runner contract tests', () => { - let dispatchApsRendering: typeof dispatchDefaultApsRendering; - - beforeEach(async () => { - vi.resetModules(); - document.body.innerHTML = '
existing
'; - const publisherScript = document.createElement('script'); - publisherScript.setAttribute(APS_RENDERING_MODE_ATTRIBUTE_NAME, 'publisher_native'); - const currentScriptSpy = vi - .spyOn(document, 'currentScript', 'get') - .mockReturnValue(publisherScript); - ({ dispatchApsRendering } = await import('../../../src/integrations/aps/render')); - currentScriptSpy.mockRestore(); - }); - - afterEach(() => { - delete window.tsjs; - vi.restoreAllMocks(); - document.body.innerHTML = ''; - }); - - it('queues the exact selected response for the fixed APS runner and commits on load', async () => { - const trustedServer = vi.fn(() => true); - const accepted = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer, - }); - const slot = document.getElementById('fictional-slot')!; - const frame = slot.querySelector('iframe')!; - const { runner, event } = nativeRunnerState(frame); - - expect(frame.getAttribute('sandbox')).toBeNull(); - expect(frame.style.display).toBe('none'); - expect(runner.src).toBe(APS_PREBID_CREATIVE_RUNNER_URL); - expect(event.type).toBe('prebid/creative/render'); - expect(event.detail).toEqual({ - aaxResponse: descriptor().aaxResponse, - seatBidId: descriptor().bidId, - }); - expect(slot.querySelector('span')).not.toBeNull(); - expect(trustedServer).not.toHaveBeenCalled(); - - const runnerDocument = frame.contentDocument!; - expect(runnerDocument.querySelector('meta[name="referrer"]')?.getAttribute('content')).toBe( - 'no-referrer' - ); - expect(runnerDocument.documentElement.style.margin).toBe('0px'); - expect(runnerDocument.documentElement.style.padding).toBe('0px'); - expect(runnerDocument.body.style.margin).toBe('0px'); - expect(runnerDocument.body.style.padding).toBe('0px'); - const creativeFrame = runnerDocument.createElement('iframe'); - runnerDocument.body.appendChild(creativeFrame); - await vi.waitFor(() => expect(creativeFrame.style.display).toBe('block')); - - runner.dispatchEvent(new Event('load')); - await expect(accepted).resolves.toBe(true); - expect(slot.querySelector('span')).toBeNull(); - expect(frame.style.display).toBe(''); - }); - - it('fails closed when the runner fails without clearing publisher content', async () => { - const trustedServer = vi.fn(() => true); - const accepted = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer, - }); - const frame = document.querySelector('#fictional-slot iframe')!; - const { runner } = nativeRunnerState(frame); - - runner.dispatchEvent(new Event('error')); - - await expect(accepted).resolves.toBe(false); - expect(trustedServer).not.toHaveBeenCalled(); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - }); - - it('cancels a pending runner when a newer dispatch replaces it', async () => { - const first = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer: () => true, - }); - const firstFrame = document.querySelector('#fictional-slot iframe')!; - - const second = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer: () => true, - }); - const secondFrame = document.querySelector('#fictional-slot iframe')!; - - expect(firstFrame.isConnected).toBe(false); - expect(secondFrame).not.toBe(firstFrame); - await expect(first).resolves.toBe(false); - nativeRunnerState(secondFrame).runner.dispatchEvent(new Event('load')); - await expect(second).resolves.toBe(true); - }); - - it('lets an invalid replacement cancel an older pending runner', async () => { - const first = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer: () => true, - }); - const second = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor({ aaxResponse: 'invalid' }), - trustedServer: () => true, - }); - - expect(second).toBe(false); - await expect(first).resolves.toBe(false); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - }); - - it('resolves a logical GPT slot through the injected div mapping', async () => { - document.body.innerHTML = '
existing
'; - window.tsjs = { divToSlotId: { 'div-header': 'homepage_header' } } as typeof window.tsjs; - - const accepted = dispatchApsRendering({ - slotId: 'homepage_header', - renderer: descriptor(), - trustedServer: () => true, - }); - const frame = document.querySelector('#div-header iframe')!; - nativeRunnerState(frame).runner.dispatchEvent(new Event('load')); - - await expect(accepted).resolves.toBe(true); - expect(document.querySelector('#div-header span')).toBeNull(); - }); - - it('renders inside the inner slot when Prebid uses its container ID', async () => { - document.body.innerHTML = - '
'; - const source = document.querySelector('#div-header > iframe')!.contentWindow; - - const accepted = dispatchApsRendering({ - slotId: 'div-header-container', - renderer: descriptor(), - source, - trustedServer: () => true, - }); - const frame = Array.from( - document.querySelectorAll('#div-header > iframe') - ).find((candidate) => candidate.title === 'Ad content')!; - nativeRunnerState(frame).runner.dispatchEvent(new Event('load')); - - await expect(accepted).resolves.toBe(true); - expect(document.getElementById('div-header-container')).not.toBeNull(); - expect(document.getElementById('div-header')).not.toBeNull(); - expect(document.querySelectorAll('#div-header > iframe')).toHaveLength(1); - }); - - it('uses the requesting frame to resolve a dynamic slot prefix', async () => { - document.body.innerHTML = - '
' + - '
'; - const source = document.querySelector( - '#div-header-second > iframe' - )!.contentWindow; - - const accepted = dispatchApsRendering({ - slotId: 'div-header-', - renderer: descriptor(), - source, - trustedServer: () => true, - }); - const frame = Array.from( - document.querySelectorAll('#div-header-second > iframe') - ).find((candidate) => candidate.title === 'Ad content')!; - nativeRunnerState(frame).runner.dispatchEvent(new Event('load')); - - await expect(accepted).resolves.toBe(true); - expect(document.querySelector('#div-header-first > iframe')).not.toBeNull(); - expect(document.querySelectorAll('#div-header-second > iframe')).toHaveLength(1); - }); - - it('contains throwing publisher slot mappings without falling back', async () => { - const tsjs = {} as NonNullable; - Object.defineProperty(tsjs, 'divToSlotId', { - get: () => { - throw new Error('fictional mapping lookup failure'); - }, - }); - window.tsjs = tsjs; - const trustedServer = vi.fn(() => true); - - await expect( - dispatchApsRendering({ - slotId: 'logical-slot', - renderer: descriptor(), - trustedServer, - }) - ).resolves.toBe(false); - expect(trustedServer).not.toHaveBeenCalled(); - expect(document.querySelector('iframe')).toBeNull(); - }); - - it('times out an unacknowledged runner without clearing publisher content', async () => { - vi.useFakeTimers(); - try { - const result = dispatchApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer: () => true, - }); - await vi.advanceTimersByTimeAsync(APS_NATIVE_RENDERER_TIMEOUT_MS); - - await expect(result).resolves.toBe(false); - expect(vi.getTimerCount()).toBe(0); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - } finally { - vi.useRealTimers(); - } - }); -}); - -describe('direct APS rendering', () => { - beforeEach(() => { - document.body.innerHTML = '
existing
'; - }); - - afterEach(() => { - vi.restoreAllMocks(); - document.body.innerHTML = ''; - }); - - it('keeps a valid default frame when an invalid replacement is rejected', () => { - const trustedServer = (renderer: ApsRendererV1): boolean => - renderApsCreative({ slotId: 'fictional-slot', renderer }); - expect( - dispatchDefaultApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor(), - trustedServer, - }) - ).toBe(true); - - const slot = document.getElementById('fictional-slot')!; - const iframe = slot.querySelector('iframe')!; - const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); - iframe.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0][0] as { nonce: string }; - - expect( - dispatchDefaultApsRendering({ - slotId: 'fictional-slot', - renderer: descriptor({ aaxResponse: 'invalid' }), - trustedServer, - }) - ).toBe(false); - expect(iframe.isConnected).toBe(true); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: iframe.contentWindow, - }) - ); - expect(slot.querySelector('span')).toBeNull(); - expect(iframe.style.display).toBe(''); - }); - - it('loads the static route with a fragment-bound 128-bit nonce and opaque sandbox', () => { - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - - const slot = document.getElementById('fictional-slot')!; - const iframe = slot.querySelector('iframe')!; - const existing = slot.querySelector('span'); - expect(existing).not.toBeNull(); - expect(iframe.src).toMatch(/\/integrations\/aps\/renderer#tsaps=[A-Za-z0-9_-]{22}$/); - expect(iframe.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); - expect(iframe.getAttribute('sandbox')).not.toContain('allow-same-origin'); - expect(iframe.srcdoc).toBe(''); - - const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); - iframe.dispatchEvent(new Event('load')); - - expect(slot.querySelector('span')).not.toBeNull(); - expect(iframe.style.display).toBe('none'); - expect(postMessage).toHaveBeenCalledTimes(1); - expect(postMessage).toHaveBeenCalledWith( - { - nonce: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/), - renderer: descriptor(), - }, - '*' - ); - - const message = postMessage.mock.calls[0]![0] as { nonce: string }; - window.dispatchEvent( - new MessageEvent('message', { - data: { - message: 'trusted-server/aps/renderer-ready', - nonce: `wrong-${message.nonce}`, - }, - source: iframe.contentWindow, - }) - ); - expect(slot.querySelector('span')).not.toBeNull(); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: message.nonce }, - source: iframe.contentWindow, - }) - ); - expect(slot.querySelector('span')).toBeNull(); - expect(iframe.style.display).toBe(''); - }); - - it('rejects a ready message with the correct nonce from a foreign window', () => { - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - - const slot = document.getElementById('fictional-slot')!; - const rendererFrame = slot.querySelector('iframe')!; - const postMessage = vi.spyOn(rendererFrame.contentWindow!, 'postMessage'); - rendererFrame.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0]![0] as { nonce: string }; - const foreignFrame = document.createElement('iframe'); - document.body.appendChild(foreignFrame); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: foreignFrame.contentWindow, - }) - ); - - expect(slot.querySelector('span')).not.toBeNull(); - expect(rendererFrame.style.display).toBe('none'); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: rendererFrame.contentWindow, - }) - ); - - expect(slot.querySelector('span')).toBeNull(); - expect(rendererFrame.style.display).toBe(''); - }); - - it('leaves existing slot content intact when validation or loading fails', () => { - expect( - renderApsCreative({ - slotId: 'fictional-slot', - renderer: descriptor({ aaxResponse: 'invalid' }), - }) - ).toBe(false); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const iframe = document.querySelector('#fictional-slot iframe')!; - iframe.dispatchEvent(new Event('error')); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - }); - - it('removes an unacknowledged frame without clearing publisher content', () => { - vi.useFakeTimers(); - try { - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const iframe = document.querySelector('#fictional-slot iframe')!; - iframe.dispatchEvent(new Event('load')); - - vi.advanceTimersByTime(10_000); - - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - expect(document.querySelector('#fictional-slot iframe')).toBeNull(); - } finally { - vi.useRealTimers(); - } - }); - - it('immediately cancels a superseded pending frame and its timeout', () => { - vi.useFakeTimers(); - const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); - try { - const baselineTimers = vi.getTimerCount(); - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const firstFrame = document.querySelector('#fictional-slot iframe')!; - const firstPostMessage = vi.spyOn(firstFrame.contentWindow!, 'postMessage'); - firstFrame.dispatchEvent(new Event('load')); - const firstSent = firstPostMessage.mock.calls[0]![0] as { nonce: string }; - const timersAfterFirst = vi.getTimerCount(); - expect(timersAfterFirst).toBeGreaterThan(baselineTimers); - - expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true); - const secondFrame = document.querySelector('#fictional-slot iframe')!; - expect(firstFrame.isConnected).toBe(false); - expect(vi.getTimerCount()).toBe(timersAfterFirst); - const postMessage = vi.spyOn(secondFrame.contentWindow!, 'postMessage'); - secondFrame.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0]![0] as { nonce: string }; - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: firstSent.nonce }, - source: firstFrame.contentWindow, - }) - ); - expect(document.querySelector('#fictional-slot span')).not.toBeNull(); - expect((secondFrame as HTMLIFrameElement).style.display).toBe('none'); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: secondFrame.contentWindow, - }) - ); - - vi.advanceTimersByTime(10_000); - expect(warnSpy).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); -}); - -describe('Universal Creative APS source', () => { - it('uses the deployed dynamic renderer protocol and only creates the opaque route frame', () => { - expect(APS_UNIVERSAL_CREATIVE_RENDERER_VERSION).toBeGreaterThanOrEqual(4); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('window.render=function'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('d&&d.apsRenderer'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain('d&&d.rendererUrl'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain(APS_RENDERER_PATH); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).toContain(APS_RENDERER_SANDBOX); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('allow-same-origin'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('srcdoc'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('document.write'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('creativeUrl'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('aaxResponse'); - expect(APS_UNIVERSAL_CREATIVE_RENDERER).not.toContain('example-account-id'); - }); - - it('computes an absolute renderer URL from the publisher origin', () => { - expect(apsRendererUrl()).toBe(new URL(APS_RENDERER_PATH, window.location.origin).href); - expect(apsRendererUrl('http://publisher.example')).toBe( - `http://publisher.example${APS_RENDERER_PATH}` - ); - expect(apsRendererUrl('not an origin')).toBeUndefined(); - }); - - it('creates the opaque route frame and resolves only after the bound acknowledgement', async () => { - const dynamicWindow = window as unknown as { - render?: (data: Record, helper: unknown, target: Window) => Promise; - }; - window.eval(APS_UNIVERSAL_CREATIVE_RENDERER); - - try { - const renderer = descriptor(); - const rendered = dynamicWindow.render!( - { - apsRenderer: renderer, - rendererUrl: apsRendererUrl(), - }, - undefined, - window - ); - const iframe = document.body.querySelector('iframe')!; - expect(iframe.src).toMatch(/\/integrations\/aps\/renderer#tsaps=[A-Za-z0-9_-]{22}$/); - expect(iframe.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); - expect(iframe.getAttribute('sandbox')).not.toContain('allow-same-origin'); - - const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage'); - iframe.dispatchEvent(new Event('load')); - const sent = postMessage.mock.calls[0]![0] as { nonce: string; renderer: ApsRendererV1 }; - expect(sent.renderer).toEqual(renderer); - - let settled = false; - void rendered.then(() => { - settled = true; - }); - await Promise.resolve(); - expect(settled).toBe(false); - - window.dispatchEvent( - new MessageEvent('message', { - data: { message: 'trusted-server/aps/renderer-ready', nonce: sent.nonce }, - source: iframe.contentWindow, - }) - ); - await expect(rendered).resolves.toBeUndefined(); - } finally { - delete dynamicWindow.render; - document.body.innerHTML = ''; - } - }); -}); 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 deleted file mode 100644 index 5ca9d7598..000000000 --- a/crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { installDidomiSdkProxy } from '../../../src/integrations/didomi'; - -const ORIGINAL_WINDOW = global.window; - -// Mirrors the non-exported DidomiConfig shape in src/integrations/didomi. -type TestDidomiConfig = { - sdkPath?: string; - [key: string]: unknown; -}; - -type TestDidomiWindow = Window & { - didomiConfig?: TestDidomiConfig; - __tsjs_didomi?: { proxyPath?: string }; -}; - -function createWindow(url: string) { - return { - location: new URL(url) as unknown as Location, - } as TestDidomiWindow; -} - -describe('integrations/didomi', () => { - let testWindow: ReturnType; - - beforeEach(() => { - testWindow = createWindow('https://example.com/page'); - Object.assign(globalThis, { window: testWindow }); - }); - - afterEach(() => { - Object.assign(globalThis, { window: ORIGINAL_WINDOW }); - }); - - it('initializes didomiConfig and forces sdkPath through trusted server proxy', () => { - installDidomiSdkProxy(); - - expect(testWindow.didomiConfig).toBeDefined(); - expect(testWindow.didomiConfig!.sdkPath).toBe( - 'https://example.com/integrations/didomi/consent/' - ); - }); - - it('preserves existing config fields while overriding sdkPath', () => { - testWindow.didomiConfig = { apiKey: 'abc', sdkPath: 'https://sdk.privacy-center.org/' }; - - installDidomiSdkProxy(); - - expect(testWindow.didomiConfig.apiKey).toBe('abc'); - expect(testWindow.didomiConfig.sdkPath).toBe( - 'https://example.com/integrations/didomi/consent/' - ); - }); - - it('uses the server-injected custom proxy path', () => { - testWindow.__tsjs_didomi = { proxyPath: '/my-custom-consent/' }; - - installDidomiSdkProxy(); - - expect(testWindow.didomiConfig!.sdkPath).toBe('https://example.com/my-custom-consent/'); - }); -}); 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 deleted file mode 100644 index 117483891..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts +++ /dev/null @@ -1,648 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import type { LegacyTsjsApi } from '../../../src/core/types'; - -/** - * Executable coverage for the edge-injected `gpt_bootstrap.js` — the - * head-inline fallback that keeps initial server-side ads working when the - * main TSJS bundle fails to load. The file ships from - * `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` and is - * evaluated here verbatim, so the degradation path (fallback `adInit` and - * fallback `scheduleInitialAdInit`) is executed, not string-matched. - * - * Vitest runs with the lib directory as cwd (the vitest.config.ts root), so - * the bootstrap is resolved relative to it rather than via import.meta.url, - * which the jsdom environment rewrites to a non-file scheme. - */ -const BOOTSTRAP_SOURCE = readFileSync( - path.resolve(process.cwd(), '../../trusted-server-core/src/integrations/gpt_bootstrap.js'), - 'utf8' -); -const FALLBACK_ENTRY_SOURCE = readFileSync( - path.resolve(process.cwd(), 'src/integrations/gpt/bootstrap_fallback.ts'), - 'utf8' -); -const RELEASE_SENTINEL = '__TSJS_RELEASE_ID_SENTINEL_V1__'; -const TEST_RELEASE_ID = 'a'.repeat(64); - -async function runProposedFallback(): Promise { - vi.resetModules(); - await import('../../../src/integrations/gpt/bootstrap_fallback'); -} - -// The command queue the bootstrap pushes into: a real array once GPT has -// loaded, or the bare `push`-only stub GPT installs before then. -type MockCommandQueue = Array<() => void> | { push: (fn: () => void) => unknown }; - -// Minimal googletag surface the bootstrap touches. -interface MockGoogleTag { - cmd: MockCommandQueue; - defineSlot: (adUnitPath: string, sizes: Array<[number, number]>, divId: string) => unknown; - pubads: () => unknown; - enableServices: () => void; - display: (divId: string) => void; - getConfig?: (key: string) => Record; - setConfig?: (config: Record) => void; -} - -// `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; -}; - -function makeGoogleTag(overrides: Partial = {}): MockGoogleTag { - const pubads = { - getSlots: vi.fn(() => []), - refresh: vi.fn(), - }; - - return { - cmd: [], - defineSlot: vi.fn(), - pubads: vi.fn(() => pubads), - enableServices: vi.fn(), - display: vi.fn(), - ...overrides, - }; -} - -function runBootstrap(): void { - // Evaluate in the jsdom global scope, exactly as an inline ', - 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' }], - true - ); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual( - expect.objectContaining({ - requestId: 'prebid-request-id', - bidderCode: 'aps', - ad: '', - trustedServerRenderer: renderer, - meta: expect.objectContaining({ 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' }], - true - ); - - 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, [], true); - - 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, true); - - 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['__tsApsBidResponseListenerInstalled']; - delete mockPbjs.bidderSettings; - }); - - afterEach(() => { - delete mockPbjs.bidderSettings; - 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 normalized APS descriptors at bidAccepted under Prebid generated ad IDs', () => { - installPrebidNpm(); - - const bidAcceptedListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidAccepted' - )?.[1] as ((bid: Record) => void) | undefined; - expect(bidAcceptedListener).toBeTypeOf('function'); - - const renderer = apsRenderer(); - const normalizedBid: Record = { - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'prebid-generated-ad-id', - adUnitCode: 'div-aps', - ttl: 300, - meta: { trustedServerRenderer: renderer }, - }; - bidAcceptedListener!(normalizedBid); - - const entry = apsPrebidRenderers()['prebid-generated-ad-id']!; - expect(entry).toEqual( - expect.objectContaining({ - adUnitCode: 'div-aps', - renderer, - expiresAt: expect.any(Number), - markUsed: expect.any(Function), - }) - ); - - expect(normalizedBid).not.toHaveProperty('trustedServerRenderer'); - expect(normalizedBid['meta']).not.toHaveProperty('trustedServerRenderer'); - entry?.markUsed(); - expect(mockMarkWinningBidAsUsed).toHaveBeenCalledWith({ - adId: 'prebid-generated-ad-id', - events: true, - }); - }); - - it('keeps bidResponse as a top-level renderer compatibility fallback', () => { - installPrebidNpm(); - const bidResponseListener = mockOnEvent.mock.calls.find( - ([eventName]) => eventName === 'bidResponse' - )?.[1] as ((bid: Record) => void) | undefined; - const renderer = apsRenderer(); - - bidResponseListener!({ - adapterCode: 'trustedServer', - bidderCode: 'aps', - adId: 'fallback-ad-id', - adUnitCode: 'div-aps', - trustedServerRenderer: renderer, - }); - - expect(testWindow.tsjs?.apsPrebidRenderers?.['fallback-ad-id']?.renderer).toEqual(renderer); - }); - - it('makes failed APS renderer registrations ineligible when zero-CPM bids are allowed', () => { - 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, - cpm: 1.23, - meta: { 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(malformedBid['meta']).not.toHaveProperty('trustedServerRenderer'); - // Prebid's allowZeroCpmBids path still requires cpm >= 0. - expect(malformedBid['cpm']).toBe(-1); - expect(warnSpy).toHaveBeenCalledWith( - '[tsjs-prebid] rejected APS renderer capability that failed registration' - ); - }); - - it('registers APS renderer via meta when Prebid strips the custom top-level 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' }], - true - ); - - // 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, markUsed: 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' }], - true - ); - 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' }], - true - ); - - // 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', () => { - beforeEach(() => { - testWindow.__tsjs_prebid = { - serverSideBidders: ['appnexus', 'rubicon', 'kargo', 'openx'], - }; - }); - - it('preserves publisher ts adserverTargeting while adding trustedServer settings', () => { - const publisherTargeting = [{ key: 'ts', val: () => 'publisher-value' }]; - mockPbjs.bidderSettings = { - exampleBidder: { adserverTargeting: publisherTargeting }, - }; - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [{ bids: [{ bidder: 'exampleBidder', params: {} }] }], - } as unknown as RequestBidsArg); - - const bidderSettings = mockPbjs.bidderSettings as { - exampleBidder: { adserverTargeting: typeof publisherTargeting }; - trustedServer: { - allowAlternateBidderCodes: boolean; - allowedAlternateBidderCodes: string[]; - }; - }; - expect(bidderSettings.exampleBidder.adserverTargeting).toBe(publisherTargeting); - expect(bidderSettings.exampleBidder.adserverTargeting[0].key).toBe('ts'); - expect(bidderSettings.exampleBidder.adserverTargeting[0].val()).toBe('publisher-value'); - expect(bidderSettings.trustedServer).toEqual( - expect.objectContaining({ - allowAlternateBidderCodes: true, - allowedAlternateBidderCodes: ['*'], - }) - ); - }); - - 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('folds only authoritative routes across mixed client, PBS, APS, and standard demand', () => { - testWindow.__tsjs_prebid = { - serverSideBidders: ['pbsRoute', 'standardRoute'], - clientSideBidders: ['exampleBrowser'], - }; - const pbjs = installPrebidNpm(); - - const adUnits = [ - { - bids: [ - { bidder: 'exampleBrowser', params: { placement: 'browser' } }, - { bidder: 'pbsRoute', params: { placement: 'pbs' } }, - { bidder: 'aps', params: { slot: 'aps' } }, - { bidder: 'standardRoute', params: { placement: 'standard' } }, - { bidder: 'pbs-provider-id', params: { forbidden: true } }, - ], - }, - ]; - 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('preserves the empty stored-request envelope on initial and repeated requests', () => { - const pbjs = installPrebidNpm(); - const adUnits = [ - { - code: 'stored-slot', - bids: [{ bidder: 'trustedServer', params: { bidderParams: {} } }], - }, - ]; - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - - 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('keeps browser timeout and debug independent from multiple PBS routes', () => { - testWindow.__tsjs_prebid = { - timeout: 1750, - debug: false, - serverSideBidders: ['pbsPrimaryRoute', 'pbsSecondaryRoute'], - }; - - installPrebidNpm(); - - expect(mockSetConfig).toHaveBeenCalledWith({ debug: false, bidderTimeout: 1750 }); - }); - - 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 = []; - mockPbjs.setTargetingForGPTAsync = undefined; - testWindow.tsjs = undefined; - delete testWindow.googletag; - testWindow.__tsjs_prebid = { - serverSideBidders: ['appnexus', 'rubicon', 'kargo', 'openx', 'exampleServer'], - }; - }); - - 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'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - // 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'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - 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 slotTargeting = new Map([ - ['ts_initial', ['1']], - ['zone', ['homepage']], - ]); - const clearTargeting = vi.fn((key: string) => { - slotTargeting.delete(key); - }); - const setTargeting = vi.fn((key: string, value: string | string[]) => { - slotTargeting.set(key, Array.isArray(value) ? value : [value]); - }); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-homepage-header'), - getTargeting: vi.fn((key: string) => slotTargeting.get(key) ?? []), - getSizes: vi.fn(() => [ - { getWidth: () => 970, getHeight: () => 250 }, - { getWidth: () => 728, getHeight: () => 90 }, - ]), - clearTargeting, - setTargeting, - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - const setTargetingForGPTAsync = vi.fn(() => { - gptSlot.setTargeting('ts', 'prebid-value'); - }); - 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(clearTargeting).not.toHaveBeenCalledWith('ts'); - expect(originalRefresh).not.toHaveBeenCalled(); - - const bidsBackHandler = mockRequestBids.mock.calls[0]![0].bidsBackHandler; - bidsBackHandler(); - - expect(setTargetingForGPTAsync).toHaveBeenCalled(); - expect(setTargetingForGPTAsync.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(slotTargeting.get('ts')).toEqual(['prebid-value']); - 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(undefined, 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([ - ['an empty suffix', ['']], - ['a non-array suffix list', {}], - ])('ignores %s from injected config and runs the refresh auction', (_description, suffixes) => { - const originalRefresh = vi.fn(); - const gptSlot = { - getSlotElementId: vi.fn(() => 'div-ad-display'), - getAdUnitPath: vi.fn(() => '/123/content'), - getTargeting: vi.fn(() => []), - }; - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [gptSlot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.__tsjs_prebid = { excludedGamAdUnitPathSuffixes: suffixes }; - - 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(); - }); - - it('keeps nested Prebid refreshes Prebid-only and restores the diagnostics context', () => { - const listeners = new Map void>(); - const store = new GptDiagnosticsStore({ defer: () => undefined }); - const explicitSlot = { - getSlotElementId: () => 'nested-explicit', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const bareSlot = { - getSlotElementId: () => 'nested-bare', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - let throwRefresh = false; - let getSlots: () => object[] = () => []; - const originalRefresh = vi.fn((slots?: unknown[]) => { - for (const slot of slots ?? getSlots()) listeners.get('slotRequested')?.({ slot }); - if (throwRefresh) throw new Error('delegated refresh failed'); - return 'delegated refresh result'; - }); - const pubads = { - addEventListener: vi.fn((name: string, listener: (event: { slot: object }) => void) => { - listeners.set(name, listener); - }), - refresh: originalRefresh, - getSlots: vi.fn(() => [bareSlot]), - }; - getSlots = pubads.getSlots; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - - new GptDiagnosticsObserver(store).install(); - installRefreshHandler(750); - const pbjs = installPrebidNpm(); - - const prepareDelivery = (code: string) => { - mockRequestBids.mockImplementationOnce((options) => { - options.bidsBackHandler?.(); - }); - pbjs.requestBids({ - adUnits: [{ code, bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: vi.fn(), - } as unknown as RequestBidsArg); - }; - - prepareDelivery('nested-explicit'); - expect(pubads.refresh([explicitSlot])).toBe('delegated refresh result'); - expect(store.snapshot().slots[0].requests[0].requestPath).toBe('prebid_refresh'); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - - prepareDelivery('nested-bare'); - expect(pubads.refresh()).toBe('delegated refresh result'); - expect(store.snapshot().slots[1].requests[0].requestPath).toBe('prebid_refresh'); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - - prepareDelivery('nested-explicit'); - throwRefresh = true; - expect(() => pubads.refresh([explicitSlot])).toThrow('delegated refresh failed'); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - - testWindow.tsjs = { - adInitRefreshInProgress: true, - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - throwRefresh = false; - expect(pubads.refresh([explicitSlot])).toBe('delegated refresh result'); - expect(store.snapshot().slots[0].requests[2].requestPath).toBe('unattributed'); - }); - - it.each([ - { order: 'diagnostics observer first', diagnosticsFirst: true, expectedPath: 'prebid_refresh' }, - { order: 'Prebid wrapper first', diagnosticsFirst: false, expectedPath: 'competing' }, - ])( - 'attributes a Prebid-consumed refresh as $expectedPath when installed with the $order', - ({ diagnosticsFirst, expectedPath }) => { - const listeners = new Map void>(); - const store = new GptDiagnosticsStore({ defer: () => undefined }); - const slot = { - getSlotElementId: () => 'install-order', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const originalRefresh = vi.fn((slots?: unknown[]) => { - for (const refreshed of slots ?? []) listeners.get('slotRequested')?.({ slot: refreshed }); - return 'delegated refresh result'; - }); - const pubads = { - addEventListener: vi.fn((name: string, listener: (event: { slot: object }) => void) => { - listeners.set(name, listener); - }), - refresh: originalRefresh as (slots?: unknown[], opts?: unknown) => unknown, - getSlots: vi.fn(() => [slot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - - // Only the bundle evaluation order enforces this today, so pin both - // outcomes: the diagnostics wrapper must sit inside the Prebid one to see - // the dispatch context that marks a refresh as Prebid's. - if (diagnosticsFirst) { - new GptDiagnosticsObserver(store).install(); - installRefreshHandler(750); - } else { - installRefreshHandler(750); - new GptDiagnosticsObserver(store).install(); - } - const pbjs = installPrebidNpm(); - mockRequestBids.mockImplementationOnce((options) => options.bidsBackHandler?.()); - pbjs.requestBids({ - adUnits: [{ code: 'install-order', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: vi.fn(), - } as unknown as RequestBidsArg); - - pubads.refresh([slot]); - - expect(store.snapshot().slots[0].requests[0].requestPath).toBe(expectedPath); - } - ); - - it('keeps the outer dispatch context set across a nested Prebid refresh', () => { - const store = new GptDiagnosticsStore({ defer: () => undefined }); - const slot = { - getSlotElementId: () => 'nested-reentrant', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const contextAfterInner: Array = []; - let reentered = false; - const originalRefresh = vi.fn(() => { - if (!reentered) { - reentered = true; - pubads.refresh([slot]); - contextAfterInner.push( - (testWindow.tsjs as { prebidRefreshDispatchInProgress?: boolean }) - .prebidRefreshDispatchInProgress - ); - } - return 'delegated refresh result'; - }); - const pubads = { - refresh: originalRefresh as (slots?: unknown[], opts?: unknown) => unknown, - getSlots: vi.fn(() => [slot]), - }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - testWindow.tsjs = { - gptDiagnosticsRecorder: { - recordPrebidRefresh: (slots: object[]) => store.recordPrebidRefresh(slots), - }, - }; - - const pbjs = installPrebidNpm(); - installRefreshHandler(750); - mockRequestBids.mockImplementation((options) => options.bidsBackHandler?.()); - pbjs.requestBids({ - adUnits: [{ code: 'nested-reentrant', bids: [{ bidder: 'exampleServer', params: {} }] }], - bidsBackHandler: vi.fn(), - } as unknown as RequestBidsArg); - - expect(pubads.refresh([slot])).toBe('delegated refresh result'); - // The inner dispatch owns the flag while it runs and must hand it back, or - // the observer would stop attributing every later publisher refresh. - expect(contextAfterInner).toEqual([true]); - expect( - originalRefresh, - 'the nested refresh must reach the delegated call' - ).toHaveBeenCalledTimes(2); - expect(Object.hasOwn(testWindow.tsjs as object, 'prebidRefreshDispatchInProgress')).toBe(false); - }); - - it('restores diagnostics context when its setter mutates and then throws', () => { - const slot = { - getSlotElementId: () => 'mutating-context-setter', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const originalRefresh = vi.fn(); - const pubads = { - refresh: originalRefresh, - getSlots: vi.fn(() => [slot]), - }; - const contextTarget: Record = {}; - let throwAfterMutation = true; - testWindow.tsjs = new Proxy(contextTarget, { - set(target, property, value) { - Reflect.set(target, property, value); - if (property === 'prebidRefreshDispatchInProgress' && throwAfterMutation) { - throwAfterMutation = false; - throw new Error('example mutating context setter failure'); - } - return true; - }, - }); - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - mockRequestBids.mockImplementation((options) => options.bidsBackHandler?.()); - - installPrebidNpm(); - installRefreshHandler(750); - pubads.refresh([slot]); - pubads.refresh([slot]); - - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect( - Object.prototype.hasOwnProperty.call(contextTarget, 'prebidRefreshDispatchInProgress') - ).toBe(false); - }); -}); - -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; - testWindow.__tsjs_prebid = { - serverSideBidders: ['exampleServer', 'exampleFallback'], - }; - 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); - } - - function installPrebidRefreshDiagnostics( - implementation?: (slots: Array>) => void - ) { - const recordPrebidRefresh = vi.fn(implementation); - testWindow.tsjs = { gptDiagnosticsRecorder: { recordPrebidRefresh } }; - return recordPrebidRefresh; - } - - it('suppresses one publisher delivery after TS claims first and allows a later refresh', () => { - const code = 'example-ts-first-slot'; - const element = document.createElement('div'); - element.id = code; - document.body.appendChild(element); - try { - const targeting = new Map([ - ['ts_initial', '1'], - ['hb_adid', 'example-ts-ad-id'], - ['hb_pb', '1.25'], - ]); - const slot = { - getSlotElementId: () => code, - getTargeting: (key: string) => { - const value = targeting.get(key); - return value === undefined ? [] : Array.isArray(value) ? value : [value]; - }, - setTargeting: vi.fn((key: string, value: string | string[]) => { - targeting.set(key, value); - return slot; - }), - clearTargeting: vi.fn((key: string) => { - targeting.delete(key); - return slot; - }), - getSizes: () => [[300, 250]], - }; - const ts = (testWindow.tsjs = {} as TsjsApi) as TsjsApi; - const claim = claimFirstImpressionForTrustedServer(ts, element)!; - claim.targeting = Object.fromEntries(targeting); - 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], { changeCorrelator: false }), - } as unknown as RequestBidsArg); - - expect(originalRefresh).not.toHaveBeenCalled(); - expect(slot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); - expect(slot.setTargeting).toHaveBeenCalledWith('hb_adid', 'example-ts-ad-id'); - expect(ts.firstImpression?.slots[code]?.suppressionConsumed).toBe(true); - - pubads.refresh([slot], { changeCorrelator: false }); - - expect(mockRequestBids).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenCalledOnce(); - expect(originalRefresh).toHaveBeenCalledWith([slot], { changeCorrelator: false }); - } finally { - element.remove(); - } - }); - - it('records a publisher delivery refresh immediately before its GPT request', () => { - const slot = { - getSlotElementId: () => 'example-delivery-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { code: 'example-delivery-marker', bids: [{ bidder: 'exampleServer', params: {} }] }, - ], - bidsBackHandler: () => pubads.refresh([slot]), - } as unknown as RequestBidsArg); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('records a completed synthetic refresh immediately before its GPT request', () => { - const slot = { - getSlotElementId: () => 'example-synthetic-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('records every slot in a mixed SRA refresh before its GPT request', () => { - const deliverySlot = { - getSlotElementId: () => 'example-mixed-delivery-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const independentSlot = { - getSlotElementId: () => 'example-mixed-independent-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const targetSlots = [deliverySlot, independentSlot]; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt(targetSlots); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - const pbjs = installPrebidNpm(); - - pbjs.requestBids({ - adUnits: [ - { - code: 'example-mixed-delivery-marker', - bids: [{ bidder: 'exampleServer', params: {} }], - }, - ], - bidsBackHandler: () => pubads.refresh(targetSlots), - } as unknown as RequestBidsArg); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith(targetSlots); - expect(recordPrebidRefresh.mock.calls[0][0][0]).toBe(deliverySlot); - expect(recordPrebidRefresh.mock.calls[0][0][1]).toBe(independentSlot); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(targetSlots, undefined); - }); - - it('records one synthetic timeout fallback before one GPT request', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-timeout-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation(() => undefined); - installPrebidNpm(); - - pubads.refresh([slot]); - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(640); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('records a caught synthetic auction failure before one GPT fallback request', () => { - const slot = { - getSlotElementId: () => 'example-failure-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation(() => { - throw new Error('example auction failure'); - }); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('does not record or refresh again for a late callback after timeout', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-late-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - let bidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - bidsBackHandler = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - vi.advanceTimersByTime(640); - bidsBackHandler?.(); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('does not record an adInit refresh bypass', () => { - const slot = { - getSlotElementId: () => 'example-adinit-marker-bypass', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = vi.fn(); - testWindow.tsjs = { - adInitRefreshInProgress: true, - gptDiagnosticsRecorder: { recordPrebidRefresh }, - }; - const { originalRefresh, pubads } = installGpt([slot]); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); - }); - - it('does not record empty or invalid refresh passthroughs', () => { - const slot = { - getSlotElementId: () => 'example-invalid-marker-bypass', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - const invalidSlots = [slot, null]; - - pubads.refresh([]); - pubads.refresh(invalidSlots); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(2); - expect(originalRefresh).toHaveBeenNthCalledWith(1, [], undefined); - expect(originalRefresh).toHaveBeenNthCalledWith(2, invalidSlots, undefined); - }); - - it('does not record a bare refresh when GPT cannot resolve its slot list', () => { - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const originalRefresh = vi.fn(); - const pubads = { refresh: originalRefresh }; - testWindow.googletag = { - cmd: { push: (fn: () => void) => fn() }, - pubads: () => pubads, - }; - installRefreshHandler(640); - - pubads.refresh(); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(mockRequestBids).not.toHaveBeenCalled(); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); - }); - - it('does not record while a synthetic refresh is still waiting for its auction', () => { - vi.useFakeTimers(); - try { - const slot = { - getSlotElementId: () => 'example-waiting-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(); - const { originalRefresh, pubads } = installGpt([slot]); - let bidsBackHandler: (() => void) | undefined; - mockRequestBids.mockImplementation((opts) => { - bidsBackHandler = opts.bidsBackHandler; - }); - installPrebidNpm(); - - pubads.refresh([slot]); - - expect(recordPrebidRefresh).not.toHaveBeenCalled(); - expect(originalRefresh).not.toHaveBeenCalled(); - - bidsBackHandler?.(); - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - } finally { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - } - }); - - it('still refreshes with unchanged arguments when diagnostics throws', () => { - const slot = { - getSlotElementId: () => 'example-throwing-marker', - getTargeting: () => [], - clearTargeting: vi.fn(), - }; - const refreshOptions = { changeCorrelator: false }; - const recordPrebidRefresh = installPrebidRefreshDiagnostics(() => { - throw new Error('example diagnostics failure'); - }); - const { originalRefresh, pubads } = installGpt([slot]); - mockRequestBids.mockImplementation((opts) => completePublisherAuction(opts)); - installPrebidNpm(); - - pubads.refresh([slot], refreshOptions); - - expect(recordPrebidRefresh).toHaveBeenCalledTimes(1); - expect(recordPrebidRefresh).toHaveBeenCalledWith([slot]); - expect(recordPrebidRefresh.mock.invocationCallOrder[0]).toBeLessThan( - originalRefresh.mock.invocationCallOrder[0] - ); - expect(originalRefresh).toHaveBeenCalledTimes(1); - expect(originalRefresh).toHaveBeenCalledWith([slot], refreshOptions); - }); - - it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { - testWindow.__tsjs_prebid = { - clientSideBidders: ['exampleBrowser'], - serverSideBidders: ['exampleServer', 'appnexus'], - }; - 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'], - serverSideBidders: ['exampleServer', 'appnexus'], - }; - 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'], - serverSideBidders: ['exampleServer', 'appnexus'], - }; - 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('filters unowned stored bidder params before snapshot, reuse, and refresh recovery', () => { - testWindow.__tsjs_prebid = { serverSideBidders: ['exampleServer'] }; - const code = 'example-stored-envelope-slot'; - const slot = { - getSlotElementId: () => code, - getTargeting: () => [], - getSizes: () => [[300, 250]], - clearTargeting: vi.fn(), - }; - const { pubads } = installGpt([slot]); - const pbjs = installPrebidNpm(); - const adUnits = [ - { - code, - bids: [ - { - bidder: 'trustedServer', - params: { - bidderParams: { - exampleServer: { placement: 'authoritative' }, - pbsProviderId: { placement: 'provider' }, - returnedSeatAlias: { placement: 'alias' }, - }, - }, - }, - ], - }, - ]; - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - expect(adUnits[0].bids[0].params?.bidderParams).toEqual({ - exampleServer: { placement: 'authoritative' }, - }); - - pbjs.requestBids({ adUnits } as unknown as RequestBidsArg); - expect(adUnits[0].bids[0].params?.bidderParams).toEqual({ - exampleServer: { placement: 'authoritative' }, - }); - - pubads.refresh([slot]); - expect(refreshAdUnitFromLastRequest().bids[0].params?.bidderParams).toEqual({ - exampleServer: { placement: 'authoritative' }, - }); - }); - - 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'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - - 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'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - - 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'], - serverSideBidders: ['appnexus', 'exampleServer'], - }; - - 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('leaves all unowned bidders in browser demand when no routes are configured', () => { - testWindow.__tsjs_prebid = { serverSideBidders: [] }; - 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: [], - serverSideBidders: ['appnexus', 'rubicon'], - }; - - 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'], - serverSideBidders: ['openx', 'exampleServer'], - }; - - 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'], - serverSideBidders: ['appnexus', 'exampleServer'], - }; - - 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'], - serverSideBidders: ['appnexus', 'exampleServer'], - }; - - 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'], - serverSideBidders: ['appnexus', 'exampleServer'], - }; - - 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'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - - 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'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - - 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'], - serverSideBidders: ['appnexus', 'exampleServer', 'kargo'], - }; - - 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 fe2e54fdf..2d24f191c 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -156,13 +156,8 @@ sequenceDiagram %% === Creative Rendering === rect rgb(239,246,255) Note over Client,Mock: Creative Rendering - alt APS winner - Client->>Client: Validate renderer descriptor
Create opaque sandbox iframe
Load /integrations/aps/renderer - Note right of Client: Fragment-bound nonce and one-time acknowledgement
No allow-same-origin on the outer frame - else Ordinary creative - Client->>Client: Inject winning creative
Render iframe
Load creative resources - Note right of Client: Default: first-party proxy/click URLs
rewrite_creatives=false: accepted external URLs remain direct - end + 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 ``` @@ -743,8 +738,64 @@ allow_script_creatives = false enabled = true rendering_mode = "trusted_server" -[auction.bidders.example-server] -provider = "pbs-main" +### Configuration Reference + +#### `[auction]` + +| Field | Type | Default | Description | +| -------------------- | -------- | ------- | --------------------------------------------------------------- | +| `enabled` | bool | `false` | Enable the auction system | +| `sanitize_creatives` | bool | `false` | Strip executable markup from winning-bid `adm` before delivery | +| `rewrite_creatives` | bool | `true` | Rewrite winning-bid `adm` through first-party endpoints | +| `providers` | string[] | `[]` | Ordered list of provider names to call | +| `mediator` | string? | `null` | Provider name to use as mediator (enables `parallel_mediation`) | +| `timeout_ms` | u32 | `2000` | Overall auction timeout in milliseconds | + +Both creative-processing fields must be present in the TOML for their +environment overrides to apply; see +[Environment Variable Overrides](#environment-variable-overrides). + +#### `[integrations.prebid]` + +| Field | Type | Default | Description | +| ---------------- | -------- | ----------------- | -------------------------------------------------------------------------------------- | +| `enabled` | bool | `true` | Enable Prebid provider | +| `server_url` | string | — | Prebid Server URL (required) | +| `timeout_ms` | u32 | `1000` | Request timeout | +| `bidders` | string[] | `["mocktioneer"]` | Default bidders when not specified per-slot | +| `auto_configure` | bool | `true` | Auto-remove client-side prebid.js scripts | +| `debug` | bool | `false` | Enable Prebid debug mode (sets `ext.prebid.debug` and `ext.prebid.returnallbidstatus`) | +| `test_mode` | bool | `false` | Set OpenRTB `test: 1` for non-billable test traffic | + +#### `[integrations.aps]` + +| 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]` + +| Field | Type | Default | Description | +| ------------- | ------ | ---------------------------------------- | ------------------------- | +| `enabled` | bool | `false` | Enable mediator | +| `endpoint` | string | `http://localhost:6767/adserver/mediate` | Mediator service endpoint | +| `timeout_ms` | u32 | `500` | Request timeout | +| `price_floor` | f64? | `null` | Global price floor CPM | + +### Timeout Tuning + +The orchestrator timeout should exceed the sum of provider timeouts to allow all providers to respond. Providers that exceed their individual timeouts are collected as they finish — the orchestrator doesn't wait indefinitely. + +```toml +[auction] +timeout_ms = 2000 # Overall ceiling [integrations.prebid] enabled = true diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 000224a9b..3e6028906 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -868,11 +868,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 24e8dc0c3..dcd279a0e 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -108,8 +108,9 @@ separately constrained asset capability is tracked in The second is **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 ``/`
'; - const publisherContainer = document.getElementById('publisher-owned')!; - const publisherFrame = publisherContainer.querySelector('iframe')!; - const render = attempt(); + function directApsHarness( + renderOptions: Partial[0]> = {} + ) { + document.body.innerHTML = '
placeholder
'; + const render = attempt(owner(), renderOptions); 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(), + const bootstrapNonce = indexedBootstrapNonce(2); + const rendererNonce = indexedRendererNonce(2); + let captureListener: ((event: MessageEvent) => void) | undefined; + const target = { + addEventListener: vi.fn( + (_type: 'message', listener: (event: MessageEvent) => void, _capture: true) => { + captureListener = listener; + } + ), removeEventListener: vi.fn(), - MessageChannel: class { - readonly port1 = retainedRaw; - readonly port2 = transferredRaw; - }, + }; + const messaging = createBrowserMessagingAdapter(target); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: bootstrapNonce }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: rendererNonce }), }); - const nonces = createRendererNonceRegistry(); + const rawRendererPort = browserMessagePort(); + const container = document.getElementById('fictional-slot')!; + const mounted = renderDirectApsAttempt({ + attempt: render, + bootstrapNonces: bootstraps, + container, + messaging, + nonces: renderers, + publisherOrigin: window.location.origin, + }); + const frame = container.querySelector('iframe'); + const sourceWindow = frame?.contentWindow; + + const emitGlobal = ( + data: string, + ports: readonly unknown[] = [], + source: unknown = sourceWindow, + origin = 'null' + ): void => { + captureListener?.({ data, origin, ports, source } as unknown as MessageEvent); + }; + const bootstrapReady = ( + ports: readonly unknown[] = [], + source: unknown = sourceWindow, + origin = 'null' + ): void => { + emitGlobal( + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + ports, + source, + origin + ); + }; + const containerReady = ( + ports: readonly unknown[] = [rawRendererPort], + source: unknown = sourceWindow, + origin = 'null' + ): void => { + emitGlobal( + JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, + }), + ports, + source, + origin + ); + }; + const cleanup = (): void => { + if (render.snapshot().outcome === undefined) render.cancel('caller_aborted'); + bootstraps.dispose(); + renderers.dispose(); + document.body.innerHTML = ''; + }; + return { + bootstrapNonce, + bootstrapReady, + bootstraps, + captureListener, + cleanup, + container, + containerReady, + emitGlobal, + frame, + messaging, + mounted, + rawRendererPort, + render, + rendererNonce, + renderers, + sourceWindow, + target, + }; + } + + function completeDirectApsHandshake(harness: ReturnType): void { + harness.bootstrapReady(); + harness.containerReady(); + } + it('rejects noncanonical, wrong-source, wrong-origin, and wrong-port bootstrap readiness', () => { + vi.useFakeTimers(); + const ignored = directApsHarness(); 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); + expect(ignored.mounted).toBe(true); + ignored.emitGlobal( + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: ignored.bootstrapNonce, + }), + [], + Object.freeze({}) + ); + ignored.emitGlobal( + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: ignored.bootstrapNonce, + }), + [], + ignored.sourceWindow, + window.location.origin + ); + ignored.emitGlobal( + ' {"message":"TS APS Bootstrap Ready","version":1,"bootstrapNonce":"' + + ignored.bootstrapNonce + + '"}', + [] + ); + expect(ignored.frame?.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); + expect(ignored.render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_document', + }); } finally { - createElement.mockRestore(); - nonces.dispose(); - document.body.innerHTML = ''; + ignored.cleanup(); } - }); - - 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) }), - }); + const rejected = directApsHarness(); + const unexpectedPort = browserMessagePort(); 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); + rejected.bootstrapReady([unexpectedPort]); + expect(unexpectedPort.close).toHaveBeenCalledOnce(); + expect(rejected.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(rejected.container.querySelector('iframe')).toBeNull(); } finally { - createElement.mockRestore(); - nonces.dispose(); - document.body.innerHTML = ''; + rejected.cleanup(); + vi.useRealTimers(); } }); - 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 }); + it('shares one three-second deadline through bootstrap, navigation, channel, and acceptance', () => { + vi.useFakeTimers(); + const noBootstrap = directApsHarness(); + try { + expect(noBootstrap.mounted).toBe(true); + vi.advanceTimersByTime(2_999); + expect(noBootstrap.render.snapshot().outcome).toBeUndefined(); + vi.advanceTimersByTime(1); + expect(noBootstrap.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(noBootstrap.container.querySelector('iframe')).toBeNull(); + } finally { + noBootstrap.cleanup(); + } - 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 = ''; + const noAcceptance = directApsHarness(); + try { + completeDirectApsHandshake(noAcceptance); + vi.advanceTimersByTime(2_999); + expect(noAcceptance.render.snapshot().outcome).toBeUndefined(); + vi.advanceTimersByTime(1); + expect(noAcceptance.render.snapshot().outcome).toEqual({ + outcome: 'failed', + reason: 'renderer_document_no_load', + }); + expect(noAcceptance.rawRendererPort.close).toHaveBeenCalledOnce(); + } finally { + noAcceptance.cleanup(); + vi.useRealTimers(); + } }); - it('binds the inserted renderer window and accepts only exact document-port completion', () => { + it('starts the sole ten-second completion deadline at document acceptance', () => { 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')!; - + const harness = directApsHarness(); 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({ + completeDirectApsHandshake(harness); + harness.rawRendererPort.emit({ message: 'TS APS Document Accepted', version: 1, - nonce: indexedRendererNonce(2), + nonce: harness.rendererNonce, }); - 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', + harness.rawRendererPort.emit({ + message: 'TS APS Runner Loaded', version: 1, - nonce, + nonce: harness.rendererNonce, + }); + vi.advanceTimersByTime(9_999); + expect(harness.render.snapshot()).toMatchObject({ + outcome: undefined, + state: 'waiting_for_aps_completion', + }); + vi.advanceTimersByTime(1); + expect(harness.render.snapshot().outcome).toEqual({ + outcome: 'failed', reason: 'runner_failed', }); - expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); - expect(iframe.isConnected).toBe(true); - expect(retainedRaw.close).toHaveBeenCalledOnce(); - expect(transferredRaw.close).not.toHaveBeenCalled(); + expect(harness.container.querySelector('iframe')).toBeNull(); } finally { - artifacts.dispose(); - nonces.dispose(); - document.body.innerHTML = ''; + harness.cleanup(); vi.useRealTimers(); } }); - it('maps document and APS completion deadlines through the attempt-owned timers', () => { + it.each([ + ['descriptor_invalid', 'winner_not_renderable'], + ['runner_no_load', 'runner_no_load'], + ['runner_failed', 'runner_failed'], + ] as const)('maps renderer failure %s to %s', (wireReason, expectedReason) => { 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; - }, + const harness = directApsHarness(); + try { + completeDirectApsHandshake(harness); + harness.rawRendererPort.emit({ + message: 'TS APS Render Failed', + version: 1, + nonce: harness.rendererNonce, + reason: wireReason, }); - 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({ + expect(harness.render.snapshot().outcome).toEqual({ outcome: 'failed', - reason: 'runner_failed', + reason: expectedReason, }); - expect(runnerFrame.isConnected).toBe(false); + expect(harness.container.querySelector('iframe')).toBeNull(); } finally { - nonces.dispose(); - document.body.innerHTML = ''; + harness.cleanup(); 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) => { + it('makes frame mutation, port errors, cancellation, and late replays terminal and inert', () => { 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 }), - }); + const mutated = directApsHarness(); 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({ + mutated.frame?.setAttribute( + 'src', + window.location.origin + APS_RENDERER_V1_PATH + '#b1_9999999999999999999999' + ); + mutated.bootstrapReady(); + expect(mutated.render.snapshot().outcome).toEqual({ outcome: 'failed', reason: 'renderer_document_no_load', }); - expect(transferredRaw.close).toHaveBeenCalledOnce(); + expect(mutated.container.querySelector('iframe')).toBeNull(); } finally { - nonces.dispose(); - document.body.innerHTML = ''; - vi.useRealTimers(); + mutated.cleanup(); } - }); - - 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')!; + const errored = directApsHarness(); 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({ + completeDirectApsHandshake(errored); + errored.rawRendererPort.emitError(); + expect(errored.render.snapshot().outcome).toEqual({ outcome: 'failed', reason: 'renderer_document_no_load', }); + expect(errored.rawRendererPort.close).toHaveBeenCalledOnce(); } finally { - nonces.dispose(); - document.body.innerHTML = ''; - vi.useRealTimers(); + errored.cleanup(); } - }); - - 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; - }, - }); + const cancelled = directApsHarness(); 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', + completeDirectApsHandshake(cancelled); + expect(cancelled.render.cancel('caller_aborted')).toBe(true); + expect(cancelled.container.querySelector('iframe')).toBeNull(); + expect(cancelled.rawRendererPort.close).toHaveBeenCalledOnce(); + cancelled.rawRendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: cancelled.rendererNonce, + }); + cancelled.rawRendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: cancelled.rendererNonce, + }); + expect(cancelled.render.snapshot().outcome).toEqual({ + outcome: 'cancelled', + reason: 'caller_aborted', }); + expect(cancelled.target.removeEventListener).toHaveBeenCalledOnce(); } finally { - nonces.dispose(); - document.body.innerHTML = ''; + cancelled.cleanup(); vi.useRealTimers(); } }); - it('does not remove DOM installed reentrantly by accepted-settlement observers', () => { + it('removes only insertion-time predecessors after completion', () => { 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')!; + const harness = directApsHarness(); + const duringRender = document.createElement('div'); + duringRender.id = 'during-render'; + let reentrant: HTMLDivElement | undefined; expect( - render.onSettled((outcome) => { + harness.render.onSettled((outcome) => { if (outcome.outcome !== 'accepted') return; - const successor = document.createElement('div'); - successor.id = 'reentrant-successor'; - container.appendChild(successor); + reentrant = document.createElement('div'); + reentrant.id = 'reentrant'; + harness.container.appendChild(reentrant); }) ).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(); + completeDirectApsHandshake(harness); + harness.rawRendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: harness.rendererNonce, + }); + harness.container.appendChild(duringRender); + harness.rawRendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: harness.rendererNonce, + }); + expect(harness.render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(harness.container.querySelector('span')).toBeNull(); + expect(duringRender.parentNode).toBe(harness.container); + expect(reentrant?.parentNode).toBe(harness.container); + expect(harness.frame?.parentNode).toBe(harness.container); } finally { - nonces.dispose(); - document.body.innerHTML = ''; + harness.cleanup(); 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', () => { + it('rejects invalid descriptors before nonce allocation, listener installation, or DOM mutation', () => { document.body.innerHTML = '
'; const render = attempt(); expect(render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT)).toBe(true); - const channelConstructor = vi.fn(); - const messaging = createBrowserMessagingAdapter({ + const target = { addEventListener: vi.fn(), removeEventListener: vi.fn(), - MessageChannel: channelConstructor as never, - }); - const nonces = createRendererNonceRegistry(); + }; + const bootstraps = createBootstrapNonceRegistry(); + const renderers = createRendererNonceRegistry(); const container = document.getElementById('fictional-slot')!; expect( renderDirectApsAttempt({ attempt: render, + bootstrapNonces: bootstraps, container, - messaging, - nonces, + messaging: createBrowserMessagingAdapter(target), + nonces: renderers, 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(target.addEventListener).not.toHaveBeenCalled(); + expect(bootstraps.snapshotForTest().bindings).toBe(0); + expect(renderers.snapshotForTest().bindings).toBe(0); expect(container.children).toHaveLength(0); - expect(render.snapshot().outcome).toEqual({ - outcome: 'failed', - reason: 'winner_not_renderable', - }); - nonces.dispose(); + bootstraps.dispose(); + renderers.dispose(); document.body.innerHTML = ''; }); diff --git a/scripts/generate-aps-renderer-contract.mjs b/scripts/generate-aps-renderer-contract.mjs index f3c82ad38..ba65830bb 100644 --- a/scripts/generate-aps-renderer-contract.mjs +++ b/scripts/generate-aps-renderer-contract.mjs @@ -308,7 +308,7 @@ const typescriptOutput = const documentValidatorOutput = generatedHeader + - "export const APS_RENDERER_VALIDATOR_ES5_V1 = " + + "export const APS_RENDERER_VALIDATOR_ES5_V1 =\n " + JSON.stringify(es5Output) + ";\n"; From 42d0e9080fff14fdcc60a865455d9e272004ba16 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:45:11 -0700 Subject: [PATCH 824/844] Move PUC APS rendering to top-page mounts --- .../browser/helpers/gam-test-network.ts | 2 +- .../tests/shared/aps-puc-lifecycle.spec.ts | 109 ++- .../lib/src/adapters/messaging.ts | 63 +- .../lib/src/composition/browser_test.ts | 18 +- .../src/first_display/adapters/googletag.ts | 74 +- .../src/first_display/adm_render_bridge.ts | 1 + .../lib/src/first_display/driver.ts | 1 + .../lib/src/first_display/render_bridge.ts | 177 +++-- .../lib/src/integrations/aps/module.ts | 19 +- .../lib/src/integrations/aps/render.ts | 154 +++- .../lib/src/integrations/gpt/module.ts | 17 +- .../src/kernel/contracts/message_protocol.ts | 4 +- .../src/kernel/contracts/puc_dynamic_owner.ts | 212 +---- .../lib/src/services/puc_bridge.ts | 399 ++-------- .../lib/src/services/slots.ts | 155 +++- .../lib/test/adapters/messaging.test.ts | 96 +-- .../lib/test/composition/browser.test.ts | 19 +- .../first_display/adm_render_bridge.test.ts | 1 + .../lib/test/first_display/agent.test.ts | 1 + .../lib/test/first_display/driver.test.ts | 1 + .../test/first_display/gpt_adapter.test.ts | 65 ++ .../test/first_display/render_bridge.test.ts | 136 +++- .../lib/test/integrations/aps/module.test.ts | 4 + .../lib/test/services/puc_bridge.test.ts | 736 ++++++------------ .../lib/test/services/render.test.ts | 152 ++++ .../lib/test/services/slots.test.ts | 106 +++ 26 files changed, 1401 insertions(+), 1321 deletions(-) diff --git a/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts b/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts index 5867d2b4b..3ba8f4173 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts @@ -29,7 +29,7 @@ const PROTOCOL_MESSAGES = [ "TS Render Owner Register", "TS Render Owner Registered", "TS Render Owner Refused", - "TS APS Start", + "TS APS Top Mount Started", "TS ADM Start", "TS Owner Inserted", "TS Owner Settled", diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts index 9a37378f5..878bdf3c2 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts @@ -1,14 +1,51 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { expect, test, type Page } from "@playwright/test"; import { installGptStub } from "../../helpers/gpt-stub.js"; +import { runtimeUrl } from "../../helpers/state.js"; import { loadRuntimeTsjsFixture, runtimeTsjsFixture, } from "../../helpers/tsjs-fixture.js"; -const GPT_FIXTURE = runtimeTsjsFixture(["render_runtime", "gpt"]); +const GPT_FIXTURE = runtimeTsjsFixture(["render_runtime", "aps", "gpt"]); const SLOT = "puc-lifecycle-slot"; const RESERVATION_ID = "r1_AAAAAAAAAAAAAAAAAAAAAA"; +const APS_RENDERER_URL = "https://puc.test/integrations/aps/renderer/v1"; +const APS_RUNNER_URL = "https://puc.test/integrations/aps/runner.js"; +const FICTIONAL_APS_RUNNER = readFileSync( + resolve(__dirname, "../../fixtures/fictional-aps-runner.js"), + "utf8", +); + +function apsDescriptor() { + const bid = { + id: "duplicate-puc-top-mount-bid", + price: 1.25, + w: 300, + h: 250, + ext: { + creativeurl: "https://creative.example/render", + tagtype: "iframe", + }, + }; + return { + type: "aps", + version: 1, + accountId: "fictional-account", + bidId: bid.id, + creativeId: "fictional-creative", + tagType: bid.ext.tagtype, + creativeUrl: bid.ext.creativeurl, + width: bid.w, + height: bid.h, + aaxResponse: Buffer.from( + JSON.stringify({ seatbid: [{ bid: [bid] }] }), + "utf8", + ).toString("base64"), + }; +} function boot() { return { @@ -43,13 +80,7 @@ function boot() { currency: "USD", targeting: { hb_bidder: "fictional" }, rendererReservationId: RESERVATION_ID, - renderSource: { - type: "adm", - version: 1, - adm: '
fictional creative
', - width: 300, - height: 250, - }, + renderSource: apsDescriptor(), }, ], }, @@ -72,6 +103,31 @@ async function openLifecyclePage( nonemptyCompletionOnDisplay = false, ): Promise { await installGptStub(page); + const rendererResponse = await page.request.get( + runtimeUrl("/integrations/aps/renderer/v1"), + ); + expect(rendererResponse.status()).toBe(200); + const rendererDocument = await rendererResponse.text(); + await page.route(APS_RENDERER_URL, (route) => + route.fulfill({ + status: 200, + body: rendererDocument, + headers: { + "content-type": "text/html; charset=utf-8", + "content-security-policy": + rendererResponse.headers()["content-security-policy"] ?? "", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + }, + }), + ); + await page.route(APS_RUNNER_URL, (route) => + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: FICTIONAL_APS_RUNNER, + }), + ); await page.route("https://puc.test/fixture", (route) => route.fulfill({ status: 200, @@ -96,7 +152,7 @@ async function openLifecyclePage( browserWindow.tsjs = { boot: initialBoot, que: [], - _integrationConfig: { gpt: {} }, + _integrationConfig: { aps: {}, gpt: {} }, }; }, { @@ -180,8 +236,8 @@ async function expectAcceptedLifecycle(page: Page): Promise { .toEqual({ lifecycle: ["accepted"], pucFrames: 1, - ownerFrames: [1], - ownerSources: [expect.stringContaining("fictional creative")], + ownerFrames: [0], + ownerSources: [""], emissions: [ { name: "slotRequested", listeners: 1, physical: true }, { name: "slotRenderEnded", listeners: 1, physical: true }, @@ -194,6 +250,12 @@ async function expectAcceptedLifecycle(page: Page): Promise { refreshes: [], slotChildren: [ { tag: "IFRAME", fictionalPuc: true, title: null, adm: false }, + { + tag: "IFRAME", + fictionalPuc: false, + title: "Ad content", + adm: false, + }, ], }); @@ -203,8 +265,8 @@ async function expectAcceptedLifecycle(page: Page): Promise { const puc = root?.querySelector( "iframe[data-fictional-puc]", ); - const creative = puc?.contentDocument?.querySelector( - 'iframe[title="Ad content"]', + const creative = root?.querySelector( + ':scope > iframe[title="Ad content"]', ); return { hbAdId: ( @@ -215,12 +277,15 @@ async function expectAcceptedLifecycle(page: Page): Promise { } ).__gptDiagnosticsStub.targeting(slot, "hb_adid"), pucCount: root?.querySelectorAll("iframe[data-fictional-puc]").length, - creativeCount: puc?.contentDocument?.querySelectorAll( + ownerCreativeCount: puc?.contentDocument?.querySelectorAll( 'iframe[title="Ad content"]', ).length, + creativeCount: root?.querySelectorAll( + ':scope > iframe[title="Ad content"]', + ).length, creativeWidth: creative?.getAttribute("width"), creativeHeight: creative?.getAttribute("height"), - creativeSource: creative?.srcdoc, + creativeVisibility: creative?.style.visibility, }; }, SLOT), ).toEqual({ @@ -229,7 +294,8 @@ async function expectAcceptedLifecycle(page: Page): Promise { creativeCount: 1, creativeWidth: "300", creativeHeight: "250", - creativeSource: expect.stringContaining("fictional creative"), + creativeVisibility: "visible", + ownerCreativeCount: 0, }); } @@ -303,7 +369,7 @@ test.describe("APS/PUC lifecycle over the hard-cutover runtime", () => { ); return { pucFrames: pucFrames.length, - creativeFrames: pucFrames.reduce( + ownerCreativeFrames: pucFrames.reduce( (total, frame) => total + (frame.contentDocument?.querySelectorAll( @@ -311,8 +377,15 @@ test.describe("APS/PUC lifecycle over the hard-cutover runtime", () => { ).length ?? 0), 0, ), + topCreativeFrames: + root?.querySelectorAll(':scope > iframe[title="Ad content"]') + .length ?? 0, }; }, SLOT), - ).toEqual({ pucFrames: 2, creativeFrames: 1 }); + ).toEqual({ + pucFrames: 2, + ownerCreativeFrames: 0, + topCreativeFrames: 1, + }); }); }); diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index 2640370b3..be4e8b4d9 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -63,11 +63,11 @@ export const PROTOCOL_MESSAGE_SCHEMAS_V1 = Object.freeze({ prebidResponse: schema( 'structured', ['message', 'adId', 'renderer', 'rendererVersion', 'tsOwner'], - { message: TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse, rendererVersion: '3' } + { message: TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse, rendererVersion: '4' } ), prebidResponseRefused: schema('structured', ['message', 'adId', 'rendererVersion', 'tsOwner'], { message: TSJS_MESSAGE_PROTOCOL_V1.message.prebidResponse, - rendererVersion: '3', + rendererVersion: '4', }), tsOwnerReady: schema('structured', ['version', 'status', 'kind', 'lifecycleTicket'], { version: 1, @@ -85,11 +85,10 @@ export const PROTOCOL_MESSAGE_SCHEMAS_V1 = Object.freeze({ message: TSJS_MESSAGE_PROTOCOL_V1.message.ownerRefused, version: 1, }), - apsStart: schema( - 'structured', - ['message', 'version', 'lifecycleTicket', 'rendererUrl', 'envelope'], - { message: TSJS_MESSAGE_PROTOCOL_V1.message.apsStart, version: 1 } - ), + apsTopMountStarted: schema('structured', ['message', 'version', 'lifecycleTicket'], { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsTopMountStarted, + version: 1, + }), apsEnvelope: schema('structured', ['version', 'nonce', 'publisherOrigin', 'renderer'], { version: 1, }), @@ -379,29 +378,6 @@ function exactHttpOrigin(value: unknown): value is string { } } -function rendererUrl(value: unknown, expected?: string): value is string { - const valid = (candidate: unknown): candidate is string => { - if (!boundedString(candidate, 2_048)) return false; - try { - const parsed = new URL(candidate); - return ( - (parsed.protocol === 'http:' || parsed.protocol === 'https:') && - parsed.hostname !== '' && - parsed.username === '' && - parsed.password === '' && - parsed.pathname === '/integrations/aps/renderer/v1' && - parsed.search === '' && - parsed.hash === '' - ); - } catch { - return false; - } - }; - if (!valid(value)) return false; - if (expected !== undefined) return valid(expected) && value === expected; - return true; -} - function apsContainerUrl(value: unknown, bootstrapNonce: unknown): value is string { if ( !capability(bootstrapNonce, 'bootstrapNonce') || @@ -640,7 +616,7 @@ function parseTsOwner(candidate: unknown): Readonly> | u function validProtocolFields( kind: ProtocolMessageKind, record: Readonly>, - options: MessagingValidationOptions + _options: MessagingValidationOptions ): boolean { const ticket = (): boolean => capability(record['lifecycleTicket'], 'ticket'); const nonce = (): boolean => capability(record['nonce'], 'nonce'); @@ -678,12 +654,8 @@ function validProtocolFields( return capability(record['adId'], 'reservation') && ticket(); case 'ownerRefused': return capability(record['adId'], 'reservation'); - case 'apsStart': - return ( - ticket() && - options.expectedRendererUrl !== undefined && - rendererUrl(record['rendererUrl'], options.expectedRendererUrl) - ); + case 'apsTopMountStarted': + return ticket(); case 'apsEnvelope': return true; case 'admStart': @@ -758,22 +730,7 @@ function canonicalProtocolRecord( const owner = parseTsOwner(record['tsOwner']); return owner ? replaceNested(record, keys, { tsOwner: owner }) : undefined; } - if (kind === 'apsStart' || kind === 'apsEnvelope') { - if ( - kind === 'apsStart' && - (!capability(record['lifecycleTicket'], 'ticket') || - options.expectedRendererUrl === undefined || - !rendererUrl(record['rendererUrl'], options.expectedRendererUrl)) - ) { - return undefined; - } - const candidate = kind === 'apsStart' ? record['envelope'] : record; - const canonicalEnvelope = canonicalApsEnvelope(candidate, options); - if (!canonicalEnvelope) return undefined; - return kind === 'apsStart' - ? replaceNested(record, keys, { envelope: canonicalEnvelope }) - : canonicalEnvelope; - } + if (kind === 'apsEnvelope') return canonicalApsEnvelope(record, options); if (kind === 'admStart') { const source = exactRecord(record['source'], ['type', 'version', 'adm', 'width', 'height']); return source ? replaceNested(record, keys, { source }) : undefined; diff --git a/crates/trusted-server-js/lib/src/composition/browser_test.ts b/crates/trusted-server-js/lib/src/composition/browser_test.ts index fe80dab1b..c4daea959 100644 --- a/crates/trusted-server-js/lib/src/composition/browser_test.ts +++ b/crates/trusted-server-js/lib/src/composition/browser_test.ts @@ -47,7 +47,11 @@ import { serializeAuctionRequestBody, } from '../core/registry'; import { prepareAdmIframe } from '../core/render'; -import { APS_RENDERER_V1_PATH, renderDirectApsAttempt } from '../integrations/aps/render'; +import { + APS_RENDERER_V1_PATH, + renderDirectApsAttempt, + renderPucApsAttempt, +} from '../integrations/aps/render'; import { installClickGuard } from '../integrations/creative/click'; import { installDynamicIframeProxy } from '../integrations/creative/iframe'; import { installDynamicImageProxy } from '../integrations/creative/image'; @@ -1792,14 +1796,20 @@ export function createTestBrowserRuntimeComposition( const pucBridge = createPucBridge({ messaging: composition.adapters.messaging, mintLifecycleTicket: mintBrowserLifecycleTicket, - publisherOrigin: prepared.publisherOrigin, + mountAps: (input) => + renderPucApsAttempt({ + ...input, + bootstrapNonces: prepared.services.bootstrapNonces, + messaging: composition.adapters.messaging, + nonces: prepared.services.rendererNonces, + publisherOrigin: prepared.publisherOrigin, + }), ...(compositionOptions.pucSchedulerForTest ? { scheduler: compositionOptions.pucSchedulerForTest } : {}), - rendererNonces: prepared.services.rendererNonces, - rendererUrl: prepared.rendererUrl, reservations: prepared.services.reservations, resizeCollapsedShell: resizeCollapsedPucShell, + slots: prepared.services.slots, }); context.onDispose(() => pucBridge.dispose()); browserServices = Object.freeze({ ...prepared.services, pucBridge }); diff --git a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts index a19c17e11..ba6086198 100644 --- a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts @@ -42,6 +42,8 @@ export type FirstDisplayGptFailureReason = export interface FirstDisplayGptBoundCycleV1 { readonly bid: FirstDisplayProjectionBidV1; readonly element: HTMLElement; + /** Closure-private physical-cycle validity; false after a later GPT request takes the slot. */ + readonly isCurrent: () => boolean; readonly ownership: 'publisher' | 'trusted_server'; readonly physicalSlot: object; readonly placement: FirstDisplayProjectionSlotV1; @@ -116,6 +118,7 @@ interface FirstDisplayDiagnosticCycleRecord { } interface ActiveCycle extends FirstDisplayGptBoundCycleV1 { + readonly bindingState: { current: boolean }; readonly diagnosticRecords: FirstDisplayDiagnosticCycleRecord[]; readonly elementId: string; readonly operations: readonly ('display' | 'refresh')[]; @@ -333,6 +336,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze({ bid: cycle.bid, element: cycle.element, + isCurrent: cycle.isCurrent, ownership: cycle.ownership, physicalSlot: cycle.physicalSlot, placement: cycle.placement, @@ -539,11 +543,14 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } const traceToken = `gt1_${traceTokenOrdinal.toString(36)}`; this.nextTraceTokenOrdinal += 1; + const bindingState = { current: true }; const cycle: ActiveCycle = { bid: row.bid, + bindingState, diagnosticRecords: [], element, elementId: element.id, + isCurrent: () => bindingState.current, operations: plan.operations, ownership, physicalSlot: slot, @@ -563,6 +570,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze({ bid: cycle.bid, element: cycle.element, + isCurrent: cycle.isCurrent, ownership: cycle.ownership, physicalSlot: cycle.physicalSlot, placement: cycle.placement, @@ -615,8 +623,12 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { this.notifyNativeMutation(); const slot = physicalSlot(member(event, 'slot')); const cycle = slot ? this.cycles.get(slot) : undefined; - if (!cycle) return; + if (!cycle) { + if (slot) this.invalidateCyclesForElement(this.readPhysicalElementId(slot), slot); + return; + } if (cycle.settled) { + cycle.bindingState.current = false; this.captureDiagnosticFact('slotRequested', cycle, event); return; } @@ -655,6 +667,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze({ bid: cycle.bid, element: cycle.element, + isCurrent: cycle.isCurrent, ownership: cycle.ownership, physicalSlot: cycle.physicalSlot, placement: cycle.placement, @@ -790,9 +803,14 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (typeof original !== 'function') continue; const prior = Object.getOwnPropertyDescriptor(receiver, key); const notify = (): void => this.notifyNativeMutation(); + const invalidate = (arguments_: readonly unknown[], result: unknown): void => + this.invalidateCyclesForPublisherCall(key, arguments_, result); const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { const result = Reflect.apply(original, this, arguments_); - if (this === receiver) notify(); + if (this === receiver) { + invalidate(arguments_, result); + notify(); + } return result; }; if ( @@ -819,6 +837,58 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { this.publisherCallRestorers.push(...installed); } + private readPhysicalElementId(slot: object): string | undefined { + try { + const read = member(slot, 'getSlotElementId'); + if (typeof read !== 'function') return undefined; + const value = Reflect.apply(read, slot, []); + return typeof value === 'string' && value.length > 0 ? value : undefined; + } catch { + return undefined; + } + } + + private invalidateCyclesForElement(elementId: string | undefined, replacement: object): void { + if (!elementId) return; + for (const cycle of this.cycles.values()) { + if (cycle.physicalSlot !== replacement && cycle.elementId === elementId) { + cycle.bindingState.current = false; + } + } + } + + private invalidateCyclesForPublisherCall( + key: string, + arguments_: readonly unknown[], + result: unknown + ): void { + try { + if (key === 'destroySlots' && result === true) { + const targets = arguments_[0]; + if (targets === undefined) { + for (const cycle of this.cycles.values()) cycle.bindingState.current = false; + return; + } + if (!Array.isArray(targets)) return; + for (let index = 0; index < targets.length; index += 1) { + const target = physicalSlot(targets[index]); + const cycle = target ? this.cycles.get(target) : undefined; + if (cycle) cycle.bindingState.current = false; + } + return; + } + if (key === 'defineSlot') { + const replacement = physicalSlot(result); + const elementId = arguments_[2]; + if (replacement && typeof elementId === 'string') { + this.invalidateCyclesForElement(elementId, replacement); + } + } + } catch { + // The publisher call already completed; observation cannot alter its result. + } + } + private restorePublisherCalls(): void { for (const restore of this.publisherCallRestorers.reverse()) { try { diff --git a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts index a82b585b1..1660fac23 100644 --- a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts @@ -34,6 +34,7 @@ function validAdmCycle(cycle: FirstDisplayGptBoundCycleV1): boolean { try { return ( cycle.bid.renderSource.type === 'adm' && + cycle.isCurrent() && cycle.slotId === cycle.bid.slot && cycle.slotId === cycle.placement.slot && cycle.element.ownerDocument.getElementById(cycle.element.id) === cycle.element && diff --git a/crates/trusted-server-js/lib/src/first_display/driver.ts b/crates/trusted-server-js/lib/src/first_display/driver.ts index 4b76cbe80..bcf9b1463 100644 --- a/crates/trusted-server-js/lib/src/first_display/driver.ts +++ b/crates/trusted-server-js/lib/src/first_display/driver.ts @@ -63,6 +63,7 @@ function sameCycle( candidate.slotId === expected.slotId && candidate.bid === expected.bid && candidate.element === expected.element && + candidate.isCurrent === expected.isCurrent && candidate.placement === expected.placement && candidate.physicalSlot === expected.physicalSlot && candidate.ownership === expected.ownership && diff --git a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts index 9d88c9459..03c88e783 100644 --- a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts @@ -79,9 +79,13 @@ interface Attempt { documentTimer: unknown; documentTransferred: PortLike | undefined; gam: FirstDisplayGptRenderResult | undefined; + hostPositionOwned: boolean; inserted: boolean; insertionTimer: unknown; ownerTicket: string | undefined; + overlay: boolean; + previousHostPosition: string; + previousHostPositionPriority: string; rendererNonce: string | undefined; ownerSource: object | undefined; pendingDocumentTerminal: @@ -457,11 +461,21 @@ function encodeOpaque(bytes: Uint8Array): string { function validCycle(cycle: FirstDisplayGptBoundCycleV1): boolean { try { const source = cycle.bid.renderSource; + const document = cycle.element.ownerDocument; + let exactElementMatches = 0; + const elements = document.getElementsByTagName('*'); + for (let index = 0; index < elements.length; index += 1) { + const candidate = elements.item(index); + if (candidate?.id !== cycle.element.id) continue; + if (candidate !== cycle.element) return false; + exactElementMatches += 1; + } return ( + cycle.isCurrent() && cycle.slotId === cycle.bid.slot && cycle.slotId === cycle.placement.slot && - cycle.element.ownerDocument !== null && - cycle.element.ownerDocument.getElementById(cycle.element.id) === cycle.element && + exactElementMatches === 1 && + document.getElementById(cycle.element.id) === cycle.element && RESERVATION_ID.test(cycle.bid.rendererReservationId) && (source.type === 'adm' || source.type === 'aps') ); @@ -474,7 +488,7 @@ function refusedResponse(adId: string): string { return JSON.stringify({ message: 'Prebid Response', adId, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'refused' }, }); } @@ -735,6 +749,27 @@ export function createFirstDisplayRenderBridge( // The exact frame is no longer authoritative after terminal settlement. } } + if (removeFrame && attempt.hostPositionOwned) { + attempt.hostPositionOwned = false; + try { + const style = attempt.cycle.element.style; + if ( + style.getPropertyValue('position') === 'relative' && + style.getPropertyPriority('position') === '' + ) { + if (attempt.previousHostPosition === '') style.removeProperty('position'); + else { + style.setProperty( + 'position', + attempt.previousHostPosition, + attempt.previousHostPositionPriority + ); + } + } + } catch { + // Compare-owned publisher style restoration is best-effort. + } + } attempt.directFrame = undefined; }; @@ -806,7 +841,7 @@ export function createFirstDisplayRenderBridge( return nonce; }; - const exactDirectApsFrame = (attempt: Attempt, permanent: boolean): boolean => { + const exactApsFrame = (attempt: Attempt, permanent: boolean): boolean => { const aps = apsProtocol(); const frame = attempt.directFrame; const bootstrapNonce = attempt.bootstrapNonce; @@ -814,7 +849,9 @@ export function createFirstDisplayRenderBridge( try { return ( attempt.active && - attempt.phaseValue === 'rendering_direct' && + (attempt.phaseValue === 'rendering_direct' || + (attempt.overlay && attempt.phaseValue === 'waiting_for_insertion')) && + validCycle(attempt.cycle) && attempt.inserted && frame.isConnected && frame.parentNode === attempt.cycle.element && @@ -873,7 +910,7 @@ export function createFirstDisplayRenderBridge( return true; } const aps = apsProtocol(); - if (!aps || attempt.bootstrapNavigated || !exactDirectApsFrame(attempt, false)) { + if (!aps || attempt.bootstrapNavigated || !exactApsFrame(attempt, false)) { fail(attempt, 'renderer_document_no_load'); return true; } @@ -894,7 +931,7 @@ export function createFirstDisplayRenderBridge( bootstrapNonce, containerUrl, }); - if (!exactDirectApsFrame(attempt, true) || !postWindow(source, navigation)) { + if (!exactApsFrame(attempt, true) || !postWindow(source, navigation)) { fail(attempt, 'renderer_document_no_load'); return true; } @@ -938,7 +975,7 @@ export function createFirstDisplayRenderBridge( !port || !attempt.bootstrapNavigated || attempt.documentPort || - !exactDirectApsFrame(attempt, true) + !exactApsFrame(attempt, true) ) { for (const candidate of inspection?.ports ?? []) closePort(candidate); fail(attempt, 'renderer_document_no_load'); @@ -964,7 +1001,7 @@ export function createFirstDisplayRenderBridge( publisherOrigin: aps.publisherOrigin, renderer: attempt.cycle.bid.renderSource, }) || - !exactDirectApsFrame(attempt, true) + !exactApsFrame(attempt, true) ) { fail(attempt, 'renderer_document_no_load'); } @@ -974,7 +1011,7 @@ export function createFirstDisplayRenderBridge( function handleApsDocument(attempt: Attempt, event: unknown): void { const aps = apsProtocol(); if (!attempt.active || !attempt.rendererNonce || !aps) return; - if (attempt.phaseValue === 'rendering_direct' && !exactDirectApsFrame(attempt, true)) { + if (!exactApsFrame(attempt, true)) { fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); return; } @@ -1006,7 +1043,7 @@ export function createFirstDisplayRenderBridge( ); const pending = attempt.pendingDocumentTerminal; attempt.pendingDocumentTerminal = undefined; - if (pending === 'completed') settle(attempt, 'accepted'); + if (pending === 'completed') completeAps(attempt); else if (pending) fail(attempt, pending); }; if (parsed.kind === 'document_accepted') { @@ -1019,7 +1056,7 @@ export function createFirstDisplayRenderBridge( return; } if (parsed.kind === 'render_completed') { - if (attempt.documentAccepted) settle(attempt, 'accepted'); + if (attempt.documentAccepted) completeAps(attempt); else if (attempt.documentAcceptancePending && !attempt.pendingDocumentTerminal) { attempt.pendingDocumentTerminal = 'completed'; } else fail(attempt, 'renderer_document_no_load'); @@ -1033,6 +1070,31 @@ export function createFirstDisplayRenderBridge( } else fail(attempt, 'renderer_document_no_load'); } + function completeAps(attempt: Attempt): void { + if (!attempt.active || !exactApsFrame(attempt, true)) { + fail(attempt, 'slot_unresolved'); + return; + } + if (attempt.overlay) { + try { + const frame = attempt.directFrame; + if (!frame) { + fail(attempt, 'slot_unresolved'); + return; + } + frame.style.setProperty('visibility', 'visible'); + if (frame.style.getPropertyValue('visibility') !== 'visible') { + fail(attempt, 'internal_error'); + return; + } + } catch { + fail(attempt, 'internal_error'); + return; + } + } + settle(attempt, 'accepted'); + } + const ownerInserted = (attempt: Attempt): void => { if (!attempt.active || attempt.inserted) return; attempt.inserted = true; @@ -1071,6 +1133,7 @@ export function createFirstDisplayRenderBridge( const ticket = attempt.ownerTicket; const inserted = exactRecord(data, ['message', 'version', 'lifecycleTicket']); if ( + attempt.cycle.bid.renderSource.type === 'adm' && inserted?.message === 'TS Owner Inserted' && inserted.version === 1 && inserted.lifecycleTicket === ticket @@ -1121,41 +1184,15 @@ export function createFirstDisplayRenderBridge( }); } if (!aps) return fail(attempt, 'winner_not_renderable'); - const nonce = issueRendererNonce(attempt); - if (!nonce) return fail(attempt, 'capability_registry_full'); - let channel: ChannelLike; - try { - channel = options.createChannel(); - } catch { - return fail(attempt, 'internal_error'); - } - attempt.documentPort = channel.port1; - attempt.documentTransferred = channel.port2; - attempt.documentRelease = listen( - channel.port1, - (event) => handleApsDocument(attempt, event), - () => fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load') - ); - if (!attempt.documentRelease) return fail(attempt, 'internal_error'); - const started = post( - controlPort, - { - message: 'TS APS Start', + attempt.overlay = true; + if (!renderAps(attempt)) return false; + return ( + post(controlPort, { + message: 'TS APS Top Mount Started', version: 1, lifecycleTicket: ticket, - rendererUrl: aps.rendererUrl, - envelope: { - version: 1, - nonce, - publisherOrigin: aps.publisherOrigin, - renderer: attempt.cycle.bid.renderSource, - }, - }, - [channel.port2] + }) || fail(attempt, 'internal_error') ); - closePort(channel.port2); - attempt.documentTransferred = undefined; - return started || fail(attempt, 'internal_error'); }; const handleOwnerRegistration = ( @@ -1302,7 +1339,7 @@ export function createFirstDisplayRenderBridge( message: 'Prebid Response', adId: attempt.reservationId, renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: owner, }); attempt.claim = undefined; @@ -1324,7 +1361,8 @@ export function createFirstDisplayRenderBridge( frame: HTMLIFrameElement, width: number, height: number, - sandbox: string + sandbox: string, + overlay = false ): void => { frame.setAttribute('sandbox', sandbox); frame.setAttribute('referrerpolicy', 'no-referrer'); @@ -1338,10 +1376,32 @@ export function createFirstDisplayRenderBridge( frame.setAttribute('aria-label', 'Advertisement'); frame.setAttribute( 'style', - `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + overlay + ? `border: 0; display: block; height: ${height}px; inset: 0; margin: 0; overflow: hidden; position: absolute; visibility: hidden; width: ${width}px; z-index: 2147483647;` + : `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` ); }; + const acquireOverlayPosition = (attempt: Attempt): boolean => { + if (!attempt.overlay) return true; + try { + if (!validCycle(attempt.cycle)) return false; + const host = attempt.cycle.element; + const browser = host.ownerDocument.defaultView; + if (!browser) return false; + if (browser.getComputedStyle(host).position !== 'static') return true; + attempt.previousHostPosition = host.style.getPropertyValue('position'); + attempt.previousHostPositionPriority = host.style.getPropertyPriority('position'); + host.style.setProperty('position', 'relative'); + attempt.hostPositionOwned = + host.style.getPropertyValue('position') === 'relative' && + host.style.getPropertyPriority('position') === ''; + return attempt.hostPositionOwned && validCycle(attempt.cycle); + } catch { + return false; + } + }; + const renderDirectAdm = (attempt: Attempt): boolean => { const source = attempt.cycle.bid.renderSource; if (source.type !== 'adm') return false; @@ -1374,14 +1434,16 @@ export function createFirstDisplayRenderBridge( } }; - const renderDirectAps = (attempt: Attempt): boolean => { + const renderAps = (attempt: Attempt): boolean => { const source = attempt.cycle.bid.renderSource; const aps = apsProtocol(); if (source.type !== 'aps' || !aps) return false; - attempt.insertionTimer = arm( - () => fail(attempt, 'owner_insertion_timeout'), - aps.deadlines.insertionMs - ); + if (attempt.insertionTimer === undefined) { + attempt.insertionTimer = arm( + () => fail(attempt, 'owner_insertion_timeout'), + aps.deadlines.insertionMs + ); + } if (attempt.insertionTimer === undefined) return fail(attempt, 'internal_error'); const bootstrapNonce = issueBootstrapNonce(attempt); const rendererNonce = issueRendererNonce(attempt); @@ -1403,18 +1465,19 @@ export function createFirstDisplayRenderBridge( } try { const frame = options.document.createElement('iframe'); - configureFrame(frame, source.width, source.height, aps.sandbox); + configureFrame(frame, source.width, source.height, aps.sandbox, attempt.overlay); const intended = aps.rendererUrl + '#' + bootstrapNonce; frame.onerror = () => fail(attempt, 'renderer_document_no_load'); frame.src = intended; attempt.apsContainerUrl = documents.outerUrl; attempt.directFrame = frame; + if (!acquireOverlayPosition(attempt)) return fail(attempt, 'slot_unresolved'); attempt.cycle.element.appendChild(frame); const intendedWindow = frame.contentWindow; if (!intendedWindow) return fail(attempt, 'renderer_document_no_load'); attempt.bootstrapSource = intendedWindow; ownerInserted(attempt); - return exactDirectApsFrame(attempt, false) || fail(attempt, 'renderer_document_no_load'); + return exactApsFrame(attempt, false) || fail(attempt, 'renderer_document_no_load'); } catch { return fail(attempt, 'renderer_document_no_load'); } @@ -1431,7 +1494,7 @@ export function createFirstDisplayRenderBridge( } return attempt.cycle.bid.renderSource.type === 'adm' ? renderDirectAdm(attempt) - : renderDirectAps(attempt); + : renderAps(attempt); }; const dispatch = (event: unknown): void => { @@ -1535,9 +1598,13 @@ export function createFirstDisplayRenderBridge( documentTimer: undefined, documentTransferred: undefined, gam: undefined, + hostPositionOwned: false, inserted: false, insertionTimer: undefined, ownerTicket: undefined, + overlay: false, + previousHostPosition: '', + previousHostPositionPriority: '', rendererNonce: undefined, onTerminal, ownerSource: undefined, diff --git a/crates/trusted-server-js/lib/src/integrations/aps/module.ts b/crates/trusted-server-js/lib/src/integrations/aps/module.ts index be9b30336..dfaaeefb5 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/module.ts @@ -11,8 +11,14 @@ import type { RenderAttempt, } from '../../services/render'; import { validatePersistentFirstDisplaySliceAdoptionV1 } from '../../shared/takeover'; +import type { PucApsMountInput } from '../../services/puc_bridge'; -import { renderDirectApsAttempt, resolveApsRendererV1Url, validateApsRenderer } from './render'; +import { + renderDirectApsAttempt, + renderPucApsAttempt, + resolveApsRendererV1Url, + validateApsRenderer, +} from './render'; interface RenderCapability { readonly bootstrapNonces: BootstrapNonceRegistry; @@ -76,7 +82,16 @@ export function createApsIntegrationRegistration(releaseId: string): Integration nonces: render.rendererNonces, publisherOrigin: render.publisherOrigin, }); - const apsCapability = Object.freeze({ render: renderer }); + const renderPuc = (input: PucApsMountInput): boolean => + active && + renderPucApsAttempt({ + ...input, + bootstrapNonces: render.bootstrapNonces, + messaging: messages.messaging, + nonces: render.rendererNonces, + publisherOrigin: render.publisherOrigin, + }); + const apsCapability = Object.freeze({ render: renderer, renderPuc }); const validation = Object.freeze({ expectedPublisherOrigin: render.publisherOrigin, expectedRendererUrl: rendererUrl, 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 79b2bab57..ef2eec771 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -107,6 +107,16 @@ export interface DirectApsAttemptOptions { readonly publisherOrigin: string; } +export interface PucApsAttemptOptions extends DirectApsAttemptOptions { + readonly baseArtifact: CommittedRenderArtifact; + readonly isBindingCurrent: () => boolean; + readonly onArtifactTransferred: () => void; +} + +type ApsTopPageAttemptOptions = + | (DirectApsAttemptOptions & Readonly<{ mode: 'direct' }>) + | (PucApsAttemptOptions & Readonly<{ mode: 'puc_overlay' }>); + function freeze(value: Value): Readonly { return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; } @@ -209,8 +219,8 @@ function readNonceIssueResult( } } -/** Drive one direct APS attempt through the three-phase top-page mount protocol. */ -export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolean { +/** Drive one APS attempt through the shared three-phase top-page mount protocol. */ +export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boolean { if ( !directRenderDocument || !directIframePrototype || @@ -248,6 +258,10 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea let attemptGeneration: object; let navigationGeneration: object; let ownerDocument: Document; + let mode: ApsTopPageAttemptOptions['mode']; + let baseArtifact: CommittedRenderArtifact | undefined; + let isBindingCurrent: () => boolean; + let onArtifactTransferred: () => void; try { attempt = options.attempt; bootstrapNonces = options.bootstrapNonces; @@ -261,6 +275,11 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea attemptGeneration = attempt.generation; navigationGeneration = attempt.navigationGeneration; ownerDocument = Reflect.apply(nodeOwnerDocumentGetter, container, []) as Document; + mode = options.mode; + baseArtifact = options.mode === 'puc_overlay' ? options.baseArtifact : undefined; + isBindingCurrent = options.mode === 'puc_overlay' ? options.isBindingCurrent : () => true; + onArtifactTransferred = + options.mode === 'puc_overlay' ? options.onArtifactTransferred : () => undefined; } catch { return false; } @@ -328,13 +347,15 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea typeof issueRendererMethod !== 'function' || typeof bindRendererMethod !== 'function' || typeof consumeRendererMethod !== 'function' || - typeof beginDirectMethod !== 'function' || typeof beginDocumentMethod !== 'function' || typeof documentAcceptedMethod !== 'function' || typeof acceptMethod !== 'function' || typeof failMethod !== 'function' || typeof snapshotMethod !== 'function' || - Reflect.apply(beginDirectMethod, attempt, []) !== true + (mode === 'direct' + ? typeof beginDirectMethod !== 'function' || + Reflect.apply(beginDirectMethod, attempt, []) !== true + : attempt.snapshot().state !== 'waiting_for_insertion' || !isBindingCurrent()) ) { return false; } @@ -363,7 +384,8 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea attempt.id === attemptId && attempt.slot === attemptSlot && attempt.generation === attemptGeneration && - attempt.navigationGeneration === navigationGeneration + attempt.navigationGeneration === navigationGeneration && + (mode === 'direct' || isBindingCurrent()) ); } catch { return false; @@ -429,11 +451,17 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea ['sandbox', APS_RENDERER_SANDBOX], [ 'style', - 'border: 0; display: block; height: ' + - String(renderer.height) + - 'px; margin: 0; overflow: hidden; width: ' + - String(renderer.width) + - 'px', + mode === 'puc_overlay' + ? 'border: 0; display: block; height: ' + + String(renderer.height) + + 'px; inset: 0; margin: 0; overflow: hidden; position: absolute; visibility: hidden; width: ' + + String(renderer.width) + + 'px; z-index: 2147483647' + : 'border: 0; display: block; height: ' + + String(renderer.height) + + 'px; margin: 0; overflow: hidden; width: ' + + String(renderer.width) + + 'px', ], ] as const; for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex += 1) { @@ -460,8 +488,41 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea let documentPort: MessagingPort | undefined; let releaseGlobalListener: (() => void) | undefined; let insertionPredecessors: readonly Element[] = []; + let baseArtifactDisposed = false; + let baseArtifactTransferred = false; + let hostPositionOwned = false; + let previousHostPosition = ''; + let previousHostPositionPriority = ''; const expectedFrameSource = rendererUrl + '#' + bootstrapNonce; + const disposeBaseArtifact = (): void => { + if (mode !== 'puc_overlay' || !baseArtifactTransferred || baseArtifactDisposed) return; + baseArtifactDisposed = true; + try { + baseArtifact?.dispose(); + } catch { + // The combined artifact remains terminal even when prior cleanup is hostile. + } + }; + + const restoreHostPosition = (): void => { + if (!hostPositionOwned) return; + hostPositionOwned = false; + try { + const style = container.style; + if ( + style.getPropertyValue('position') !== 'relative' || + style.getPropertyPriority('position') !== '' + ) { + return; + } + if (previousHostPosition === '') style.removeProperty('position'); + else style.setProperty('position', previousHostPosition, previousHostPositionPriority); + } catch { + // Compare-owned style restoration cannot expand into publisher styles. + } + }; + const bootstrapListenerResource = freeze({ close: (): void => { const release = releaseGlobalListener; @@ -484,7 +545,7 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea }; const artifact: CommittedRenderArtifact = freeze({ - kind: 'direct_iframe' as const, + kind: mode === 'direct' ? ('direct_iframe' as const) : ('puc' as const), attemptId, slot: attemptSlot, navigationGeneration, @@ -503,6 +564,8 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea } catch { // DOM removal remains best-effort under a hostile publisher realm. } + restoreHostPosition(); + disposeBaseArtifact(); }, }); @@ -545,6 +608,27 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea } }; + const acquireOverlayPosition = (): boolean => { + if (mode !== 'puc_overlay') return true; + try { + if (!isBindingCurrent()) return false; + const view = ownerDocument.defaultView; + if (!view) return false; + const computed = view.getComputedStyle(container); + if (computed.position !== 'static') return true; + const style = container.style; + previousHostPosition = style.getPropertyValue('position'); + previousHostPositionPriority = style.getPropertyPriority('position'); + style.setProperty('position', 'relative'); + hostPositionOwned = + style.getPropertyValue('position') === 'relative' && + style.getPropertyPriority('position') === ''; + return hostPositionOwned && isBindingCurrent(); + } catch { + return false; + } + }; + const exactFrameBinding = (permanent: boolean): boolean => { try { return ( @@ -640,13 +724,29 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea if (loaded?.['nonce'] === rendererNonce) return; const completed = Reflect.apply(parseMessageMethod, messaging, ['apsRenderCompleted', data]); if (completed?.['nonce'] === rendererNonce) { + if (!documentAccepted) return; + if (mode === 'puc_overlay') { + try { + if (!exactFrameBinding(true) || !isBindingCurrent()) { + fail('slot_unresolved'); + return; + } + iframe.style.setProperty('visibility', 'visible'); + if (iframe.style.getPropertyValue('visibility') !== 'visible') { + fail('internal_error'); + return; + } + } catch { + fail('internal_error'); + return; + } + } if ( - documentAccepted && Reflect.apply(acceptMethod, attempt, []) === true && !disposed && exactFrameBinding(true) ) { - commitContainer(insertionPredecessors); + if (mode === 'direct') commitContainer(insertionPredecessors); } return; } @@ -787,6 +887,7 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea } try { + const initialState = mode === 'direct' ? 'rendering_direct' : 'waiting_for_insertion'; releaseGlobalListener = Reflect.apply(installCaptureMethod, messaging, [receiveGlobal]); if (!releaseGlobalListener) return startupFailure('renderer_document_no_load'); Reflect.apply(eventTargetAddListenerIntrinsic, iframe, ['error', onFrameError]); @@ -794,16 +895,17 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea if ( Reflect.apply(elementGetAttributeIntrinsic, iframe, ['src']) !== expectedFrameSource || Reflect.apply(iframeSourceGetter!, iframe, []) !== expectedFrameSource || - attemptState() !== 'rendering_direct' || + attemptState() !== initialState || Reflect.apply(nodeIsConnectedGetter, container, []) !== true ) { return startupFailure('renderer_document_no_load'); } - insertionPredecessors = snapshotContainerPredecessors(); + insertionPredecessors = mode === 'direct' ? snapshotContainerPredecessors() : []; if ( disposed || - attemptState() !== 'rendering_direct' || - Reflect.apply(nodeIsConnectedGetter, container, []) !== true + attemptState() !== initialState || + Reflect.apply(nodeIsConnectedGetter, container, []) !== true || + !acquireOverlayPosition() ) { return startupFailure('renderer_document_no_load'); } @@ -829,6 +931,14 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea return startupFailure('internal_error'); } artifactOwnedByAttempt = true; + if (mode === 'puc_overlay') { + try { + onArtifactTransferred(); + baseArtifactTransferred = true; + } catch { + return fail('internal_error'); + } + } if ( disposed || frameErrorObserved || @@ -842,3 +952,13 @@ export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolea return startupFailure('renderer_document_no_load'); } } + +/** Drive one direct APS attempt through the shared top-page mount service. */ +export function renderDirectApsAttempt(options: DirectApsAttemptOptions): boolean { + return mountApsTopPageAttempt({ ...options, mode: 'direct' }); +} + +/** Drive one PUC APS attempt through a hidden top-page overlay. */ +export function renderPucApsAttempt(options: PucApsAttemptOptions): boolean { + return mountApsTopPageAttempt({ ...options, mode: 'puc_overlay' }); +} 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 3deabe5a3..41f0bf9a7 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -34,7 +34,6 @@ import type { RenderAttempt, RenderAttemptCreationResult, RenderFailureReason, - RendererNonceRegistry, SlotOperationCreationResult, SlotOperationOptions, } from '../../services/render'; @@ -42,6 +41,7 @@ import { resizeCollapsedPucShell } from '../../core/puc_shell'; import { createPucBridge, type PucBridge, + type PucBridgeOptions, type PucGamAttemptInput, } from '../../services/puc_bridge'; import type { ReservationService } from '../../services/reservations'; @@ -416,6 +416,10 @@ interface ProductionSlotsCapability { readonly attachPhysicalService: (service: SlotService) => () => void; } +interface ProductionApsCapability { + readonly renderPuc: NonNullable; +} + interface ProductionRenderCapability { readonly attachPucGamAttemptRegistrar: ( registrar: (input: PucGamAttemptInput) => boolean @@ -435,8 +439,6 @@ interface ProductionRenderCapability { candidate: unknown ) => boolean; readonly mintLifecycleTicket: () => IdentityGenerationResult; - readonly publisherOrigin: string; - readonly rendererNonces: RendererNonceRegistry; readonly renderWinner: (attempt: RenderAttempt) => boolean; readonly reservations: ReservationService; } @@ -1578,6 +1580,7 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg } const auction = exactCapability(context.interfaces, 'auction.v1'); const slotCapability = exactCapability(context.interfaces, 'slots.v1'); + const aps = exactCapability(context.interfaces, 'aps.v1'); const render = exactCapability(context.interfaces, 'render.v1'); const messages = exactCapability(context.interfaces, 'messages.v1'); const trace = exactCapability(context.interfaces, 'trace.v1'); @@ -1598,7 +1601,7 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg typeof render.commitPageBids !== 'function' || typeof render.mintLifecycleTicket !== 'function' || typeof render.renderWinner !== 'function' || - typeof render.publisherOrigin !== 'string' || + (aps !== undefined && typeof aps.renderPuc !== 'function') || typeof trace.observations?.publish !== 'function' ) { throw new TypeError('GPT capability graph is malformed'); @@ -1633,7 +1636,6 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg scope.onDispose(() => slots.dispose()); const targeting = createTargetingService(); scope.onDispose(() => targeting.dispose()); - const rendererUrl = new URL('/integrations/aps/renderer/v1', render.publisherOrigin).href; const startup = createGptStartup({ googletag, slots: () => slots }); const diagnosticsEnabled = gptDiagnosticsActive(runtime); const diagnosticsFacts = diagnosticsEnabled @@ -1864,12 +1866,11 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg const bridge = createPucBridge({ messaging: messages.messaging, mintLifecycleTicket: render.mintLifecycleTicket, + ...(aps ? { mountAps: aps.renderPuc } : {}), now: () => document.defaultView!.performance.now(), - publisherOrigin: render.publisherOrigin, - rendererNonces: render.rendererNonces, - rendererUrl, reservations: render.reservations, resizeCollapsedShell: resizeCollapsedPucShell, + slots, }); const ticketAdoption = adoptionCandidate === undefined diff --git a/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts b/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts index a5e2cbd74..663c497d5 100644 --- a/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts +++ b/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts @@ -1,14 +1,14 @@ /** Every protocol literal shared by the version-1 browser message channels. */ export const TSJS_MESSAGE_PROTOCOL_V1 = Object.freeze({ version: 1 as const, - rendererVersion: '3' as const, + rendererVersion: '4' as const, message: Object.freeze({ prebidRequest: 'Prebid Request' as const, prebidResponse: 'Prebid Response' as const, ownerRegister: 'TS Render Owner Register' as const, ownerRegistered: 'TS Render Owner Registered' as const, ownerRefused: 'TS Render Owner Refused' as const, - apsStart: 'TS APS Start' as const, + apsTopMountStarted: 'TS APS Top Mount Started' as const, admStart: 'TS ADM Start' as const, ownerInserted: 'TS Owner Inserted' as const, ownerSettled: 'TS Owner Settled' as const, diff --git a/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts b/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts index 9d354ddc8..5f7797054 100644 --- a/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts +++ b/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts @@ -12,8 +12,6 @@ function installPucDynamicOwner(): void { 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', @@ -318,56 +316,6 @@ function installPucDynamicOwner(): void { } return true; }; - const utf8Length = (value: string): number => new TextEncoder().encode(value).byteLength; - 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']; - 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 || - containsAsciiControl(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) => { let outer: Record | undefined; @@ -392,7 +340,7 @@ function installPucDynamicOwner(): void { !outer || !owner || outer['message'] !== 'Prebid Response' || - outer['rendererVersion'] !== '3' || + outer['rendererVersion'] !== '4' || typeof adId !== 'string' || !reservationPattern.test(adId) || owner['version'] !== 1 || @@ -413,11 +361,9 @@ function installPucDynamicOwner(): void { let helperDisposer: (() => void) | undefined; let ownerTimer: number | undefined; 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; const removeFrameHandlers = (): void => { @@ -485,9 +431,7 @@ function installPucDynamicOwner(): void { // One hostile handler setter cannot retain the remaining authority. } } - closePort(documentPort); closePort(controlPort); - documentPort = undefined; controlPort = undefined; ownerFrameCurrent = undefined; } finally { @@ -579,139 +523,12 @@ function installPucDynamicOwner(): void { 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 || - !parsedUrl || - !parsedPublisherOrigin || - !validApsRenderer(renderer, 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 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; - 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; - } - removeFrame(next); - }; - next.onload = () => { - if (settled || ownerFrameCurrent?.() !== true || !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 = intendedSource; - frame = next; - creativeWindow.document.body.appendChild(next); - intendedWindow = next.contentWindow; - 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 ports = eventPorts(event, 0); const dataValue = eventDataValue(event); const routedMessage = ownDataValue(dataValue, 'message'); const routedOutcome = ownDataValue(dataValue, 'outcome'); @@ -719,15 +536,13 @@ function installPucDynamicOwner(): void { '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'] - : []), + ...(routedMessage === 'TS ADM Start' + ? ['source'] + : routedMessage === 'TS Owner Settled' && routedOutcome !== undefined + ? routedOutcome === 'accepted' + ? ['outcome'] + : ['outcome', 'reason'] + : []), ]); if ( !message || @@ -750,20 +565,19 @@ function installPucDynamicOwner(): void { return; } if ( - message['message'] === 'TS APS Start' && + message['message'] === 'TS APS Top Mount Started' && ownerKind === 'aps' && - ports.length === 1 && + ports.length === 0 && !started ) { started = true; - insertAps(message, ports); return; } if (message['message'] === 'TS Owner Settled' && ports.length === 0) { if ( message['outcome'] === 'accepted' && - !localApsFailure && - ownerFrameCurrent?.() === true + started && + (ownerKind === 'aps' || ownerFrameCurrent?.() === true) ) { frameCommitted = true; finish(true, ''); 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 c385f598a..09d39050f 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -3,19 +3,13 @@ import { TSJS_MESSAGE_PROTOCOL_V1 } from '../kernel/contracts/message_protocol'; import { PUC_DYNAMIC_OWNER } from '../kernel/contracts/puc_dynamic_owner'; import type { IdentityGenerationResult } from '../kernel/identity'; -interface CollapsedPucShellResizeInput { - readonly source: object; - readonly width: number; - readonly height: number; -} - import type { CommittedRenderArtifact, RenderAttempt, RenderFailureReason, RenderOutcome, - RendererNonceRegistry, } from './render'; +import type { SlotService } from './slots'; import type { ReservationAttempt, ReservationRecognition, @@ -25,6 +19,12 @@ import type { export { PUC_DYNAMIC_OWNER }; +interface CollapsedPucShellResizeInput { + readonly source: object; + readonly width: number; + readonly height: number; +} + const CLAIM_DEADLINE_MS = 3_000; const LIFECYCLE_TICKET_TTL_MS = 3_000; const MAX_DYNAMIC_OWNER_BYTES = 64 * 1_024; @@ -100,14 +100,22 @@ export interface PucBridgeOptions { readonly reservations: Pick & Partial>; readonly mintLifecycleTicket: () => IdentityGenerationResult; + readonly mountAps?: (input: PucApsMountInput) => boolean; readonly now?: () => number; - readonly publisherOrigin?: string; readonly resizeCollapsedShell?: (input: CollapsedPucShellResizeInput) => boolean; - readonly rendererNonces?: Pick; - readonly rendererUrl?: string; + readonly slots?: Pick; readonly scheduler?: PucBridgeScheduler; } +export interface PucApsMountInput { + readonly attempt: RenderAttempt; + readonly baseArtifact: CommittedRenderArtifact; + readonly container: HTMLElement; + readonly isBindingCurrent: () => boolean; + readonly messaging: MessagingAdapter; + readonly onArtifactTransferred: () => void; +} + export interface PucBridgeInventory { readonly attempts: number; readonly disposed: boolean; @@ -146,17 +154,9 @@ interface GamAttemptBinding { 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; @@ -266,56 +266,6 @@ function readMintedTicket(value: unknown): string | 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 @@ -501,9 +451,8 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { let reservations: PucBridgeOptions['reservations']; let mintLifecycleTicket: () => IdentityGenerationResult; let nowSource: () => number; - let publisherOrigin: string | undefined; - let rendererNonces: PucBridgeOptions['rendererNonces']; - let rendererUrl: string | undefined; + let mountAps: PucBridgeOptions['mountAps']; + let slots: PucBridgeOptions['slots']; let resizeCollapsedShell: PucBridgeOptions['resizeCollapsedShell']; let scheduler: PucBridgeScheduler; try { @@ -511,9 +460,8 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { reservations = options.reservations; mintLifecycleTicket = options.mintLifecycleTicket; nowSource = options.now ?? defaultNow; - publisherOrigin = options.publisherOrigin; - rendererNonces = options.rendererNonces; - rendererUrl = options.rendererUrl; + mountAps = options.mountAps; + slots = options.slots; resizeCollapsedShell = options.resizeCollapsedShell; scheduler = options.scheduler ?? defaultScheduler(); } catch { @@ -521,9 +469,8 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { reservations = options.reservations; mintLifecycleTicket = () => frozen({ ok: false, reason: 'identity_generation_failed' }); nowSource = () => Number.NaN; - publisherOrigin = undefined; - rendererNonces = undefined; - rendererUrl = undefined; + mountAps = undefined; + slots = undefined; resizeCollapsedShell = undefined; scheduler = defaultScheduler(); } @@ -752,27 +699,10 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { // 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); @@ -1190,259 +1120,64 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { 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) { + if (!controlPort || binding.controlStarted || !binding.active || !mountAps || !slots) { failBinding(binding, 'internal_error', false); return false; } - if ( - !binding.active || - binding.controlPort !== controlPort || - !currentBindingState(binding, 'waiting_for_insertion') - ) { - closeChannel(documentChannel); + const mountBinding = slots.resolveApsMountBinding( + binding.navigationGeneration, + binding.slot, + binding.attemptId + ); + if (!mountBinding || !mountBinding.isCurrent()) { + failBinding(binding, 'slot_unresolved', false); 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 receiveUnexpected = (event: unknown): void => { + const ports = messaging.inspectTransferredPorts(event)?.ports ?? []; + for (let index = 0; index < ports.length; index += 1) { + const port = ports[index]; + if (port) closePort(port); } - 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; + failBinding(binding, 'internal_error', false); + }; try { - source = binding.attempt.renderSource; + binding.controlListenerDispose = controlPort.listen(receiveUnexpected, () => + failBinding(binding, 'internal_error', false) + ); + binding.controlStarted = true; } catch { - source = undefined; + failBinding(binding, 'internal_error', false); + return false; } - const start = messaging.parseProtocolMessage('apsStart', { - message: TSJS_MESSAGE_PROTOCOL_V1.message.apsStart, + const start = messaging.parseProtocolMessage('apsTopMountStarted', { + message: TSJS_MESSAGE_PROTOCOL_V1.message.apsTopMountStarted, version: 1, lifecycleTicket, - rendererUrl, - envelope: { - version: 1, - nonce, - publisherOrigin, - renderer: source, - }, }); if (!start) { - failBinding(binding, 'winner_not_renderable', false); + failBinding(binding, 'internal_error', 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) { - if (binding.documentTerminalPending === undefined) { - 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) { - if (binding.documentTerminalPending === undefined) { - 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) { + const mounted = Reflect.apply(mountAps, undefined, [ + { + attempt: binding.attempt as unknown as RenderAttempt, + baseArtifact: binding.artifact, + container: mountBinding.element as HTMLElement, + isBindingCurrent: mountBinding.isCurrent, + messaging, + onArtifactTransferred: () => { + binding.artifactOwned = false; + binding.ownerInserted = true; + }, + }, + ]) as boolean; + if (!mounted || !binding.active || !binding.ownerInserted) return false; + if (controlPort.post(start, []) !== true) { failBinding(binding, 'internal_error', false); return false; } - closePort(documentChannel.transferred); return binding.active; } catch { failBinding(binding, 'internal_error', false); @@ -1781,17 +1516,9 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { 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, diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 901c114ed..42f86c917 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -140,6 +140,14 @@ export interface SlotServiceInventory { readonly records: number; } +export interface ApsSlotMountBinding { + readonly bindingEpoch: object; + readonly cycle: Readonly; + readonly element: object; + readonly physicalSlot: object; + readonly isCurrent: () => boolean; +} + /** Runtime-owned slot registry and physical-cycle boundary. */ export interface SlotService { readonly activate: () => void; @@ -163,6 +171,11 @@ export interface SlotService { registeredSlotId: string, slot: object ) => boolean; + readonly resolveApsMountBinding: ( + navigationGeneration: object, + registeredSlotId: string, + intentId: string + ) => Readonly | undefined; readonly prepareProjectionSlots: ( owner: NavigationSession, slots: readonly ProjectionSlotRegistration[] @@ -256,7 +269,9 @@ interface PhysicalCycle { interface PhysicalSlot { activeCycle: PhysicalCycle | undefined; + apsMountClaim: Readonly<{ cycle: PhysicalCycle; token: object }> | undefined; artifactRetirementAttempted: boolean; + bindingEpoch: object; definition: GoogletagReplacementDefinition | undefined; domElement: object | undefined; elementIdPrefix: string | undefined; @@ -828,9 +843,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const reconciliationElementIds = ( record: InternalSlotRecord, - definition: GoogletagReplacementDefinition + definition: GoogletagReplacementDefinition | undefined ): readonly string[] => { - const values: string[] = [definition.elementId]; + const values: string[] = definition ? [definition.elementId] : []; for (let aliasIndex = 0; aliasIndex < record.view.domAliases.length; aliasIndex += 1) { const alias = record.view.domAliases[aliasIndex]; if (alias === undefined) continue; @@ -848,7 +863,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const resolveReconciliationElement = ( record: InternalSlotRecord, - definition: GoogletagReplacementDefinition + definition: GoogletagReplacementDefinition | undefined ): SlotReconciliationResolution | undefined => { if (!reconciliationBoundary || !reconciliationResolve) return undefined; try { @@ -1045,7 +1060,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const replacementTraceToken = traceTokenFor(replacement); const physical: PhysicalSlot = { activeCycle: undefined, + apsMountClaim: undefined, artifactRetirementAttempted: false, + bindingEpoch: Object.freeze({}), definition, domElement, elementIdPrefix: oldPhysical.elementIdPrefix, @@ -1113,7 +1130,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!orphanedSlot || orphanedSlot === source.slot) return; const orphan: PhysicalSlot = { activeCycle: undefined, + apsMountClaim: undefined, artifactRetirementAttempted: true, + bindingEpoch: Object.freeze({}), definition: source.definition, domElement: undefined, elementIdPrefix: source.elementIdPrefix, @@ -2277,10 +2296,7 @@ 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 initialResolution = resolveReconciliationElement(record, definition); const domElement = initialResolution?.status === 'unique' ? initialResolution.element : undefined; const slotObject = slot as object; @@ -2318,6 +2334,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const previousDefinition = existing.definition; const previousDomElement = existing.domElement; const previousElementIdPrefix = existing.elementIdPrefix; + const previousBindingEpoch = existing.bindingEpoch; const previousPlacementKeys = existing.placementKeys; const previousPublisherElementIds = existing.publisherElementIds; const previousView = record.view; @@ -2331,6 +2348,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { existing.definition = definition; existing.domElement = domElement; existing.elementIdPrefix = elementIdPrefix; + existing.bindingEpoch = Object.freeze({}); existing.placementKeys = bindingPlacementKeys; existing.publisherElementIds = ownership === 'publisher' && definition @@ -2347,6 +2365,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { existing.definition = previousDefinition; existing.domElement = previousDomElement; existing.elementIdPrefix = previousElementIdPrefix; + existing.bindingEpoch = previousBindingEpoch; existing.placementKeys = previousPlacementKeys; existing.publisherElementIds = previousPublisherElementIds; record.view = previousView; @@ -2359,7 +2378,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } const physical: PhysicalSlot = { activeCycle: undefined, + apsMountClaim: undefined, artifactRetirementAttempted: false, + bindingEpoch: Object.freeze({}), definition, domElement, elementIdPrefix, @@ -3093,6 +3114,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } physical.ownership = 'publisher'; physical.publisherElementIds = aliases; + physical.bindingEpoch = Object.freeze({}); physical.suppressPublisherDisplay = true; physical.suppressPublisherRefresh = initialLoadDisabled === true; return Object.freeze({ action: 'handoff', slot: physical.slot }); @@ -3225,6 +3247,124 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; }; + const resolveApsMountBinding = ( + navigationGeneration: object, + registeredSlotId: string, + intentId: string + ): Readonly | undefined => { + try { + const state = mapValue(navigationStates, navigationGeneration); + const record = state ? mapValue(state.records, registeredSlotId) : undefined; + const physical = record?.physical; + const cycle = physical?.lastCycle; + const intent = cycle?.intent; + if ( + !state || + state.disposed || + !state.owner.isCurrent() || + !record || + !physical || + physical.record !== record || + physical.state !== 'live' || + !cycle || + cycle.kind !== 'trusted_server' || + cycle.traceRetirement.retired || + !intent || + intent.input.intentId !== intentId || + intent.input.registeredSlotId !== registeredSlotId || + intent.input.navigationGeneration !== navigationGeneration || + intent.input.expectedSlot !== physical.slot || + !intent.terminal || + !reconciliationBoundary || + !reconciliationResolve || + !reconciliationIsConnected + ) { + return undefined; + } + + const ids: string[] = []; + const addId = (value: string | undefined): void => { + if (!value) return; + for (let index = 0; index < ids.length; index += 1) { + if (ids[index] === value) return; + } + ids[ids.length] = value; + }; + addId(physical.definition?.elementId); + for (let index = 0; index < physical.publisherElementIds.length; index += 1) { + addId(physical.publisherElementIds[index]); + } + for (let index = 0; index < record.view.domAliases.length; index += 1) { + addId(record.view.domAliases[index]); + } + if (ids.length === 0) return undefined; + + const resolution = Reflect.apply(reconciliationResolve, reconciliationBoundary, [ + Object.freeze(ids), + ]) as SlotReconciliationResolution; + if ( + resolution.status !== 'unique' || + !Reflect.apply(reconciliationIsConnected, reconciliationBoundary, [resolution.element]) || + physical.domElement === undefined || + physical.domElement !== resolution.element || + !Reflect.apply(reconciliationIsConnected, reconciliationBoundary, [physical.domElement]) + ) { + return undefined; + } + + const existingClaim = physical.apsMountClaim; + if (existingClaim && !existingClaim.cycle.traceRetirement.retired) return undefined; + const token = Object.freeze({}); + const bindingEpoch = physical.bindingEpoch; + physical.apsMountClaim = Object.freeze({ cycle, token }); + const current = (): boolean => { + try { + if ( + state.disposed || + !state.owner.isCurrent() || + mapValue(state.records, registeredSlotId) !== record || + record.physical !== physical || + physical.record !== record || + physical.state !== 'live' || + intent.input.expectedSlot !== physical.slot || + physical.bindingEpoch !== bindingEpoch || + physical.lastCycle !== cycle || + cycle.traceRetirement.retired || + physical.apsMountClaim?.cycle !== cycle || + physical.apsMountClaim.token !== token + ) { + return false; + } + const revalidated = Reflect.apply(reconciliationResolve, reconciliationBoundary, [ + Object.freeze([...ids]), + ]) as SlotReconciliationResolution; + return ( + revalidated.status === 'unique' && + revalidated.element === resolution.element && + Reflect.apply(reconciliationIsConnected, reconciliationBoundary, [ + resolution.element, + ]) === true + ); + } catch { + return false; + } + }; + if (!current()) { + if (physical.apsMountClaim?.token === token) physical.apsMountClaim = undefined; + return undefined; + } + return Object.freeze({ + bindingEpoch, + cycle: cycle.traceHandle, + element: resolution.element, + physicalSlot: physical.slot, + isCurrent: current, + }); + } catch { + return undefined; + } + }; + const service: SlotService = Object.freeze({ activate: (): void => { if (reconciliationActive) return; @@ -3295,6 +3435,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return false; } }, + resolveApsMountBinding, prepareProjectionSlots: ( owner: NavigationSession, slots: readonly ProjectionSlotRegistration[] 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 22b96fa91..357121707 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -679,14 +679,14 @@ describe('browser messaging adapter', () => { it('centralizes every protocol literal and exact message shape as frozen data', () => { expect(TSJS_MESSAGE_PROTOCOL_V1).toEqual({ version: 1, - rendererVersion: '3', + rendererVersion: '4', message: { prebidRequest: 'Prebid Request', prebidResponse: 'Prebid Response', ownerRegister: 'TS Render Owner Register', ownerRegistered: 'TS Render Owner Registered', ownerRefused: 'TS Render Owner Refused', - apsStart: 'TS APS Start', + apsTopMountStarted: 'TS APS Top Mount Started', admStart: 'TS ADM Start', ownerInserted: 'TS Owner Inserted', ownerSettled: 'TS Owner Settled', @@ -719,7 +719,7 @@ describe('browser messaging adapter', () => { expect(Object.isFrozen(TSJS_MESSAGE_PROTOCOL_V1)).toBe(true); expect(Object.isFrozen(TSJS_MESSAGE_PROTOCOL_V1.message)).toBe(true); expect(Object.isFrozen(PROTOCOL_MESSAGE_SCHEMAS_V1)).toBe(true); - expect(Object.isFrozen(PROTOCOL_MESSAGE_SCHEMAS_V1.apsStart.keys)).toBe(true); + expect(Object.isFrozen(PROTOCOL_MESSAGE_SCHEMAS_V1.apsTopMountStarted.keys)).toBe(true); }); it('parses global JSON and structured messages through exact descriptor-safe schemas', () => { @@ -1070,44 +1070,20 @@ describe('browser messaging adapter', () => { ).toBeUndefined(); }); - it('fails APS start closed without exact generation expectations and semantic validation', () => { + it('accepts only the exact data-free APS top-mount notification', () => { const message = { - message: 'TS APS Start', + message: 'TS APS Top Mount Started', version: 1, lifecycleTicket: 't1_abcdefghijklmnopqrstuv', - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', - envelope: { - version: 1, - nonce: 'n1_abcdefghijklmnopqrstuv', - publisherOrigin: 'https://publisher.example', - renderer: Object.freeze(createApsRenderer()), - }, }; + const adapter = createBrowserMessagingAdapter(createTarget()); + expect(adapter.parseProtocolMessage('apsTopMountStarted', message)).toEqual(message); expect( - createBrowserMessagingAdapter(createTarget()).parseProtocolMessage('apsStart', message) - ).toBeUndefined(); - - const adapter = createBrowserMessagingAdapter(createTarget(), { - expectedPublisherOrigin: 'https://publisher.example', - expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', - validateApsRenderer: () => true, - }); - expect(adapter.parseProtocolMessage('apsStart', message)).toBeDefined(); - expect( - adapter.parseProtocolMessage('apsStart', { + adapter.parseProtocolMessage('apsTopMountStarted', { ...message, - envelope: { ...message.envelope, publisherOrigin: 'https://wrong.example' }, + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', }) ).toBeUndefined(); - const throwing = createBrowserMessagingAdapter(createTarget(), { - expectedPublisherOrigin: 'https://publisher.example', - expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', - validateApsRenderer: () => { - throw new Error('validator failed'); - }, - }); - expect(() => throwing.parseProtocolMessage('apsStart', message)).not.toThrow(); - expect(throwing.parseProtocolMessage('apsStart', message)).toBeUndefined(); }); it('canonicalizes an exact APS renderer before invoking the semantic validator', () => { @@ -1123,17 +1099,11 @@ describe('browser messaging adapter', () => { expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', validateApsRenderer: validator, }); - const parsed = adapter.parseProtocolMessage('apsStart', { - message: 'TS APS Start', + const parsed = adapter.parseProtocolMessage('apsEnvelope', { version: 1, - lifecycleTicket: 't1_abcdefghijklmnopqrstuv', - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', - envelope: { - version: 1, - nonce: 'n1_abcdefghijklmnopqrstuv', - publisherOrigin: 'https://publisher.example', - renderer, - }, + nonce: 'n1_abcdefghijklmnopqrstuv', + publisherOrigin: 'https://publisher.example', + renderer, }); expect(validator).toHaveBeenCalledTimes(1); @@ -1141,9 +1111,7 @@ describe('browser messaging adapter', () => { expect(Object.getPrototypeOf(canonical)).toBeNull(); expect(Object.isFrozen(canonical)).toBe(true); expect((canonical as Record)['bidId']).toBe('bid-1'); - expect( - (parsed?.['envelope'] as Readonly> | undefined)?.['renderer'] - ).toBe(canonical); + expect(parsed?.['renderer']).toBe(canonical); }); it('rejects APS renderer accessors, proxies, and unknown keys before validation', () => { @@ -1177,38 +1145,6 @@ describe('browser messaging adapter', () => { expect(validator).not.toHaveBeenCalled(); }); - it('validates both renderer URL expectations and candidates before exact equality', () => { - const invalidUrls = [ - '/integrations/aps/renderer/v1', - 'ftp://publisher.example/integrations/aps/renderer/v1', - 'https://user@publisher.example/integrations/aps/renderer/v1', - 'https://publisher.example/integrations/aps/renderer/v1?query=1', - 'https://publisher.example/integrations/aps/renderer/v1#fragment', - 'https://publisher.example/wrong-path', - ]; - for (const invalidUrl of invalidUrls) { - const adapter = createBrowserMessagingAdapter(createTarget(), { - expectedPublisherOrigin: 'https://publisher.example', - expectedRendererUrl: invalidUrl, - validateApsRenderer: () => true, - }); - expect( - adapter.parseProtocolMessage('apsStart', { - message: 'TS APS Start', - version: 1, - lifecycleTicket: 't1_abcdefghijklmnopqrstuv', - rendererUrl: invalidUrl, - envelope: { - version: 1, - nonce: 'n1_abcdefghijklmnopqrstuv', - publisherOrigin: 'https://publisher.example', - renderer: createApsRenderer(), - }, - }) - ).toBeUndefined(); - } - }); - it('returns canonical frozen nested records without invoking prototype serialization hooks', () => { const adapter = createBrowserMessagingAdapter(createTarget()); const owner = { @@ -1226,7 +1162,7 @@ describe('browser messaging adapter', () => { message: 'Prebid Response', adId: 'r1_abcdefghijklmnopqrstuv', renderer: 'renderer program', - rendererVersion: '3', + rendererVersion: '4', tsOwner: owner, }); expect(parsed).toBeDefined(); @@ -1245,7 +1181,7 @@ describe('browser messaging adapter', () => { const refused = { message: 'Prebid Response', adId: 'r1_abcdefghijklmnopqrstuv', - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'refused' }, }; expect(adapter.parseProtocolMessage('prebidResponseRefused', refused)).toBeDefined(); 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 4364adc20..f0ed3446a 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1017,27 +1017,24 @@ describe('browser composition', () => { aaxResponse: 'renderer-envelope', }; const message = { - message: 'TS APS Start', + message: 'TS APS Top Mount Started', version: 1, lifecycleTicket: 't1_abcdefghijklmnopqrstuv', - rendererUrl: new URL('/integrations/aps/renderer/v1', window.location.origin).href, - envelope: { - version: 1, - nonce: 'n1_abcdefghijklmnopqrstuv', - publisherOrigin: window.location.origin, - renderer, - }, }; - const validation = { validateApsRenderer: () => true }; + const validation = { validateApsRenderer: () => renderer !== undefined }; const browser = createBrowserComposition({ messagingValidation: validation }); - expect(browser.adapters.messaging.parseProtocolMessage('apsStart', message)).toBeDefined(); + expect( + browser.adapters.messaging.parseProtocolMessage('apsTopMountStarted', message) + ).toBeDefined(); const injected = createBrowserComposition({ target: createTarget(), messagingValidation: validation, }); - expect(injected.adapters.messaging.parseProtocolMessage('apsStart', message)).toBeUndefined(); + expect( + injected.adapters.messaging.parseProtocolMessage('apsTopMountStarted', message) + ).toBeDefined(); }); it('installs the capture-phase message listener synchronously and disposes once', () => { diff --git a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts index 81ff95e62..1496b3a0e 100644 --- a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts @@ -31,6 +31,7 @@ function harness() { }), }), element, + isCurrent: () => true, ownership: 'trusted_server', physicalSlot: {}, placement: Object.freeze({ diff --git a/crates/trusted-server-js/lib/test/first_display/agent.test.ts b/crates/trusted-server-js/lib/test/first_display/agent.test.ts index 63b746ccb..12f21a467 100644 --- a/crates/trusted-server-js/lib/test/first_display/agent.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/agent.test.ts @@ -610,6 +610,7 @@ describe('bounded first-display agent', () => { Object.freeze({ bid: projectedBatch.projection.bids[0]!, element, + isCurrent: () => true, ownership: 'trusted_server' as const, physicalSlot, placement: projectedBatch.projection.slots[0]!, diff --git a/crates/trusted-server-js/lib/test/first_display/driver.test.ts b/crates/trusted-server-js/lib/test/first_display/driver.test.ts index 6da8db90f..62bd6a054 100644 --- a/crates/trusted-server-js/lib/test/first_display/driver.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/driver.test.ts @@ -144,6 +144,7 @@ function harness() { const cycle = Object.freeze({ bid: value.projection.bids[0]!, element: document.createElement('div'), + isCurrent: () => true, ownership: 'trusted_server' as const, physicalSlot: {}, placement: value.projection.slots[0]!, diff --git a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts index 826be05fd..30ec37539 100644 --- a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts @@ -258,6 +258,71 @@ describe('first-display GPT adapter', () => { ]); }); + it('invalidates the delayed-owner binding when a publisher replaces the physical slot', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const listeners = new Map void>(); + const slot = { + addService: () => slot, + getSlotElementId: () => 'slot-1', + setTargeting: () => slot, + }; + const replacement = { + addService: () => replacement, + getSlotElementId: () => 'slot-1', + setTargeting: () => replacement, + }; + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [], + removeEventListener: () => undefined, + }; + const defineSlot = vi.fn().mockReturnValueOnce(slot).mockReturnValueOnce(replacement); + const destroySlots = vi.fn((_slots?: readonly object[]) => true); + const binding = { + cmd: { push: (command: () => void) => command() }, + defineSlot, + destroySlots, + display: (_elementId: string) => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: binding, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + let bound: { readonly isCurrent: () => boolean } | undefined; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + adapter.start({ + onBound: (cycle) => { + bound = cycle; + }, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + + expect(bound?.isCurrent()).toBe(true); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + expect(bound?.isCurrent()).toBe(true); + + expect(binding.destroySlots([slot])).toBe(true); + expect(binding.defineSlot('/publisher/replacement', [[300, 250]], 'slot-1')).toBe(replacement); + binding.display('slot-1'); + expect(bound?.isCurrent()).toBe(false); + }); + it('cancels a pending command and compare-restores an adapter-created GPT queue', () => { const dom = new JSDOM('
', { url: 'https://publisher.example/', diff --git a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts index c41359ef0..e5df5578a 100644 --- a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts @@ -79,16 +79,18 @@ function fixture(kind: 'adm' | 'aps' = 'adm') { formats: Object.freeze([Object.freeze([300, 250] as const)]), targeting: Object.freeze({}), }); + let cycleCurrent = true; const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze({ bid, element, ownership: 'trusted_server', physicalSlot: {}, + isCurrent: () => cycleCurrent, placement, slotId: 'slot-1', traceToken: 'gt1_1', }); - return { cycle, dom, element }; + return { cycle, dom, element, invalidateCycle: () => (cycleCurrent = false) }; } function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) { @@ -294,6 +296,49 @@ function registerOwner(h: ReturnType) { return { source, ticket }; } +function startApsDocument(h: ReturnType) { + const frame = h.element.querySelector('iframe'); + if (!frame?.contentWindow) throw new Error('expected the top-page APS frame'); + const bootstrapNonce = new URL(frame.src).hash.slice(1); + const postMessage = vi + .spyOn(frame.contentWindow, 'postMessage') + .mockImplementation(() => undefined); + h.dispatch({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: 'null', + ports: [], + source: frame.contentWindow, + }); + const navigation = JSON.parse(postMessage.mock.calls[0]?.[0] as string) as Record< + string, + unknown + >; + const decodedOuter = decodeURIComponent( + (navigation.containerUrl as string) + .slice('data:text/html;charset=utf-8,'.length) + .split('#')[0] ?? '' + ); + const nonce = /rendererNonce=(n1_[A-Za-z0-9_-]{22})/.exec(decodedOuter)?.[1]; + if (!nonce) throw new Error('expected the renderer nonce'); + const documentPort = new FakePort(); + h.dispatch({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce: nonce, + }), + origin: 'null', + ports: [documentPort], + source: frame.contentWindow, + }); + return { documentPort, frame, nonce }; +} + describe('bounded first-display render bridge', () => { it('observes admitted bridge activity and terminal tombstone expiry', () => { const mutations = vi.fn(() => true); @@ -339,7 +384,7 @@ describe('bounded first-display render bridge', () => { expect(JSON.parse(refusedPort.postMessage.mock.calls[0]?.[0] as string)).toEqual({ message: 'Prebid Response', adId: RESERVATION_ID, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'refused' }, }); expect(refusedPort.close).toHaveBeenCalledOnce(); @@ -388,7 +433,7 @@ describe('bounded first-display render bridge', () => { message: 'Prebid Response', adId: RESERVATION_ID, renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', kind: 'adm' }, }); const ticket = (outer.tsOwner as Record).lifecycleTicket; @@ -449,6 +494,25 @@ describe('bounded first-display render bridge', () => { }); }); + it('refuses delayed APS owner registration after a later physical GPT request', () => { + const h = harness('aps'); + const source = {}; + const responsePort = new FakePort(); + h.dispatch(requestEvent(responsePort, { source })); + expect(h.bridge.recordGam(h.cycle, 'nonempty_gam')).toBe(true); + const response = JSON.parse(responsePort.postMessage.mock.calls[0]?.[0] as string); + + h.invalidateCycle(); + const registrationPort = new FakePort(); + h.dispatch( + ownerRegistration(response.tsOwner.lifecycleTicket as string, registrationPort, source) + ); + + expect(h.terminals).toEqual(['failed']); + expect(h.terminalFacts).toEqual([['failed', 'slot_unresolved']]); + expect(h.element.querySelector('iframe')).toBeNull(); + }); + it('never resizes an anchored or already-expanded PUC shell', () => { const cases = [ { @@ -477,8 +541,9 @@ describe('bounded first-display render bridge', () => { } }); - it('requires APS document acceptance and callback completion after owner insertion', () => { + it('requires APS document acceptance and completion in a hidden top-page PUC overlay', () => { const h = harness('aps'); + h.element.innerHTML = 'publisher GAM content'; const source = {}; const responsePort = new FakePort(); h.dispatch(requestEvent(responsePort, { source })); @@ -489,43 +554,46 @@ describe('bounded first-display render bridge', () => { h.dispatch(ownerRegistration(ticket, registrationPort, source)); const start = h.channels[0]?.port1.postMessage.mock.calls[0]?.[0] as Record; - expect(start).toMatchObject({ - message: 'TS APS Start', + expect(start).toEqual({ + message: 'TS APS Top Mount Started', version: 1, lifecycleTicket: ticket, - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', - envelope: { - version: 1, - publisherOrigin: 'https://publisher.example', - renderer: h.cycle.bid.renderSource, - }, }); - const nonce = (start.envelope as Record).nonce as string; - expect(nonce).toMatch(/^n1_[A-Za-z0-9_-]{22}$/); - expect(h.channels[0]?.port1.postMessage.mock.calls[0]?.[1]).toEqual([h.channels[1]?.port2]); + expect(h.channels[0]?.port1.postMessage.mock.calls[0]?.[1]).toEqual([]); + expect(h.channels).toHaveLength(1); + expect(h.element.querySelector('span')?.textContent).toBe('publisher GAM content'); + const { documentPort, frame, nonce } = startApsDocument(h); + expect(frame.parentNode).toBe(h.element); + expect(frame.style.position).toBe('absolute'); + expect(frame.style.visibility).toBe('hidden'); + expect(h.element.style.position).toBe('relative'); - h.channels[1]?.port1.dispatch({ + documentPort.dispatch({ message: 'TS APS Document Accepted', version: 1, nonce, }); - h.channels[1]?.port1.dispatch({ + documentPort.dispatch({ message: 'TS APS Runner Loaded', version: 1, nonce, }); expect(h.terminals).toEqual([]); - h.channels[0]?.port1.dispatch({ - message: 'TS Owner Inserted', - version: 1, - lifecycleTicket: ticket, - }); - h.channels[1]?.port1.dispatch({ + expect(frame.style.visibility).toBe('hidden'); + documentPort.dispatch({ message: 'TS APS Render Completed', version: 1, nonce, }); expect(h.terminals).toEqual(['accepted']); + expect(frame.style.visibility).toBe('visible'); + expect(h.element.querySelector('span')?.textContent).toBe('publisher GAM content'); + expect(h.channels[0]?.port1.postMessage.mock.calls[1]?.[0]).toEqual({ + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: ticket, + outcome: 'accepted', + }); }); it('renders an attributable empty-GAM ADM fallback directly into the bound element', () => { @@ -731,29 +799,15 @@ describe('bounded first-display render bridge', () => { expect(adm.terminalFacts).toEqual([['failed', 'adm_document_no_load']]); const document = harness('aps'); - const documentOwner = registerOwner(document); - document.channels[0]?.port1.dispatch({ - message: 'TS Owner Inserted', - version: 1, - lifecycleTicket: documentOwner.ticket, - }); + registerOwner(document); document.fireLast(3_000); expect(document.terminals).toEqual(['failed']); expect(document.terminalFacts).toEqual([['failed', 'renderer_document_no_load']]); const completion = harness('aps'); - const completionOwner = registerOwner(completion); - const start = completion.channels[0]?.port1.postMessage.mock.calls[0]?.[0] as Record< - string, - unknown - >; - const nonce = (start.envelope as Record).nonce; - completion.channels[0]?.port1.dispatch({ - message: 'TS Owner Inserted', - version: 1, - lifecycleTicket: completionOwner.ticket, - }); - completion.channels[1]?.port1.dispatch({ + registerOwner(completion); + const { documentPort, nonce } = startApsDocument(completion); + documentPort.dispatch({ message: 'TS APS Document Accepted', version: 1, nonce, diff --git a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts index 75a0f4cba..e7c079a3e 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts @@ -7,6 +7,7 @@ import type { PreparedIntegration, } from '../../../src/kernel/integration_registry'; import type { RenderAttempt } from '../../../src/services/render'; +import type { PucApsMountInput } from '../../../src/services/puc_bridge'; const RELEASE_ID = 'a'.repeat(64); @@ -51,12 +52,14 @@ describe('APS provider', () => { ) as PreparedIntegration; const aps = prepared.interfaces?.['aps.v1'] as { render: (attempt: RenderAttempt, container: HTMLElement) => boolean; + renderPuc: (input: PucApsMountInput) => boolean; }; expect(Object.isFrozen(aps)).toBe(true); expect(render.registerRenderer).not.toHaveBeenCalled(); expect(messages.registerApsValidation).not.toHaveBeenCalled(); expect(aps.render({} as RenderAttempt, document.createElement('div'))).toBe(false); + expect(aps.renderPuc({} as PucApsMountInput)).toBe(false); prepared.activate( Object.freeze({ @@ -79,6 +82,7 @@ describe('APS provider', () => { expect(registeredRenderer).toBeUndefined(); expect(registeredValidation).toBeUndefined(); expect(aps.render({} as RenderAttempt, document.createElement('div'))).toBe(false); + expect(aps.renderPuc({} as PucApsMountInput)).toBe(false); preparationRelease.reverse().forEach((callback) => callback()); }); 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 9be107618..285c7f7e8 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,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; +import apsEnvelope from '../fixtures/aps-renderer-v1.json'; import { createBrowserMessagingAdapter } from '../../src/adapters/messaging'; +import { renderPucApsAttempt } from '../../src/integrations/aps/render'; import { createPucBridge, PUC_DYNAMIC_OWNER, @@ -50,29 +52,33 @@ interface HarnessOptions { readonly now?: PucBridgeOptions['now']; readonly publisherOrigin?: string; readonly resizeCollapsedShell?: PucBridgeOptions['resizeCollapsedShell']; - readonly rendererNonces?: PucBridgeOptions['rendererNonces']; - readonly rendererUrl?: string; + readonly mountAps?: PucBridgeOptions['mountAps']; readonly scheduler?: PucBridgeOptions['scheduler']; + readonly slots?: PucBridgeOptions['slots']; } function createHarness( recognize: (reservationId: unknown) => ReservationRecognition, options: HarnessOptions = {} ) { - let listener: ((event: MessageEvent) => void) | undefined; + const listeners: Array<(event: MessageEvent) => void> = []; const target = { addEventListener: vi.fn( (_type: 'message', next: (event: MessageEvent) => void, _capture: true) => { - listener = next; + listeners.push(next); + } + ), + removeEventListener: vi.fn( + (_type: 'message', removed: (event: MessageEvent) => void, _capture: true) => { + const index = listeners.indexOf(removed); + if (index >= 0) listeners.splice(index, 1); } ), - removeEventListener: vi.fn(), ...(options.messageChannel ? { MessageChannel: options.messageChannel } : {}), }; const bridgeOptions: PucBridgeOptions = { messaging: createBrowserMessagingAdapter(target, { ...(options.publisherOrigin ? { expectedPublisherOrigin: options.publisherOrigin } : {}), - ...(options.rendererUrl ? { expectedRendererUrl: options.rendererUrl } : {}), validateApsRenderer: () => true, }), mintLifecycleTicket: @@ -83,18 +89,19 @@ function createHarness( recognize, }, ...(options.now ? { now: options.now } : {}), - ...(options.publisherOrigin ? { publisherOrigin: options.publisherOrigin } : {}), ...(options.resizeCollapsedShell ? { resizeCollapsedShell: options.resizeCollapsedShell } : {}), - ...(options.rendererNonces ? { rendererNonces: options.rendererNonces } : {}), - ...(options.rendererUrl ? { rendererUrl: options.rendererUrl } : {}), + ...(options.mountAps ? { mountAps: options.mountAps } : {}), ...(options.scheduler ? { scheduler: options.scheduler } : {}), + ...(options.slots ? { slots: options.slots } : {}), }; 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); + if (listeners.length === 0) { + throw new Error('Expected the capture listener to be installed synchronously'); + } + for (const listener of [...listeners]) listener(event as unknown as MessageEvent); }; - return { bridge, dispatch, target }; + return { bridge, dispatch, listeners, target }; } function createGamAttempt(kind: 'aps' | 'adm' = 'aps', index = 0) { @@ -108,6 +115,7 @@ function createGamAttempt(kind: 'aps' | 'adm' = 'aps', index = 0) { let outcome: RenderOutcome | undefined; let renderSource: ReservationRenderSource | undefined; const settlementObservers: Array<(outcome: RenderOutcome) => void> = []; + const apsBid = apsEnvelope.seatbid[0]!.bid[0]!; const owner = Object.freeze({ id, slot: `slot-${index}`, @@ -145,12 +153,12 @@ function createGamAttempt(kind: 'aps' | 'adm' = 'aps', index = 0) { type: 'aps', version: 1, accountId: 'publisher-account', - bidId: 'bid-1', - tagType: 'iframe', - creativeUrl: 'https://creative.example/render', - width: 300, - height: 250, - aaxResponse: 'renderer-envelope', + bidId: apsBid.id, + tagType: apsBid.ext.tagtype, + creativeUrl: apsBid.ext.creativeurl, + width: apsBid.w, + height: apsBid.h, + aaxResponse: btoa(JSON.stringify(apsEnvelope)), } : { type: 'adm', @@ -342,44 +350,35 @@ describe('Universal Creative bridge dispatcher', () => { ).toBe(false); }); - it('installs owner iframe lifecycle handlers before assigning either document source', () => { + it('keeps APS out of the PUC realm while preserving ADM iframe ordering', () => { 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); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, 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(controlStart).toBeGreaterThan(admStart); 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;') - ); + expect(PUC_DYNAMIC_OWNER).not.toContain('const insertAps'); + expect(PUC_DYNAMIC_OWNER).not.toContain('TS APS Start'); + expect(PUC_DYNAMIC_OWNER).not.toContain('rendererUrl'); + expect(PUC_DYNAMIC_OWNER).not.toContain('aaxResponse'); + expect(PUC_DYNAMIC_OWNER).toContain('TS APS Top Mount Started'); }); - it('binds owner load and final acceptance to the exact inserted navigation', () => { + it('binds ADM 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); + const admOwner = PUC_DYNAMIC_OWNER.slice(admStart, 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'); }); @@ -427,7 +426,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -551,7 +550,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -656,7 +655,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -779,7 +778,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -904,7 +903,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -996,7 +995,7 @@ describe('Universal Creative bridge dispatcher', () => { } }); - it('accepts the optional APS creative id and preserves no-referrer on the owner iframe', async () => { + it('keeps APS DOM ownership out of the PUC frame after a data-free top-mount signal', async () => { const dynamicWindow = window as unknown as { render?: ( data: Readonly>, @@ -1026,8 +1025,6 @@ describe('Universal Creative bridge dispatcher', () => { }, set onmessageerror(_listener: ((event: unknown) => void) | null) {}, }; - const documentPort = createPort(); - try { const rendered = dynamicWindow.render!( window.JSON.parse( @@ -1035,7 +1032,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -1058,34 +1055,14 @@ describe('Universal Creative bridge dispatcher', () => { }); controlListener?.({ data: { - message: 'TS APS Start', + message: 'TS APS Top Mount Started', 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], + ports: [], }); - const frame = document.body.querySelector('iframe'); - expect(frame).not.toBeNull(); - expect(frame?.getAttribute('referrerpolicy')).toBe('no-referrer'); + expect(document.body.querySelector('iframe')).toBeNull(); controlListener?.({ data: { message: 'TS Owner Settled', @@ -1096,13 +1073,14 @@ describe('Universal Creative bridge dispatcher', () => { ports: [], }); await expect(rendered).resolves.toBeUndefined(); + expect(controlPort.close).toHaveBeenCalledOnce(); } finally { delete dynamicWindow.render; document.body.innerHTML = ''; } }); - it('keeps APS ownership alive after a local frame error until the kernel settles failure', async () => { + it('refuses the removed data-bearing APS start and closes every transferred port', async () => { vi.useFakeTimers(); const dynamicWindow = window as unknown as { render?: ( @@ -1143,7 +1121,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -1190,30 +1168,8 @@ describe('Universal Creative bridge dispatcher', () => { 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(); + await expect(rendered).rejects.toThrow('TS render owner control refused'); 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(); @@ -1243,7 +1199,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -1303,7 +1259,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -1384,7 +1340,7 @@ describe('Universal Creative bridge dispatcher', () => { adId: RESERVATION_ID, message: 'Prebid Response', renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -1450,132 +1406,90 @@ 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', - }, - { - 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(); - } - ); - 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', + { caseName: 'unexpected field', message: { unexpected: true }, ports: [] }, + { caseName: 'transferred port', message: {}, ports: [createPort()] }, + { caseName: 'wrong message', message: { message: 'TS APS Start' }, ports: [] }, + ])('refuses a malformed top-mount signal with an $caseName', async ({ message, ports }) => { + 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; + + try { + rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ adId: RESERVATION_ID, - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - }), - ports: [controlPort], - }); - controlListener?.({ - data: { - message: 'TS APS Start', - version: 1, - lifecycleTicket: LIFECYCLE_TICKET, - rendererUrl, - envelope: { + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { 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', - ...rendererOverrides, - }, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, }, - }, - 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 = ''; - } + }) + ) 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 Top Mount Started', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + ...message, + }, + ports, + }); + await expect(rendered).rejects.toThrow('TS render owner control refused'); + expect(document.body.querySelector('iframe')).toBeNull(); + for (const port of ports) expect(port.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 })); @@ -1660,7 +1574,7 @@ describe('Universal Creative bridge dispatcher', () => { expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0]))).toEqual({ message: 'Prebid Response', adId: RESERVATION_ID, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'refused' }, }); expect(port.postMessage.mock.calls[0]?.[1]).toEqual([]); @@ -1690,7 +1604,7 @@ describe('Universal Creative bridge dispatcher', () => { expect(JSON.parse(String(first.postMessage.mock.calls[0]?.[0]))).toEqual({ message: 'Prebid Response', adId: RESERVATION_ID, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'refused' }, }); expect(first.postMessage.mock.calls[0]?.[1]).toEqual([]); @@ -1947,7 +1861,7 @@ describe('Universal Creative bridge dispatcher', () => { message: 'Prebid Response', adId: RESERVATION_ID, renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '3', + rendererVersion: '4', tsOwner: { version: 1, status: 'ready', @@ -2816,27 +2730,38 @@ describe('Universal Creative bridge dispatcher', () => { expect(gam.artifact.dispose).toHaveBeenCalledOnce(); }); - it('sends exact APS start with one document port and accepts exact document completion', () => { + it('mounts APS in the exact top-page slot and sends only the data-free owner signal', () => { + document.body.innerHTML = + '
gam
'; 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: Parameters['issue']>[0]) => { - const port = input.port; - if (!port) throw new Error('should receive the PUC renderer port'); - expect(input.attempt.onSettled(() => port.close())).toBe(true); - return Object.freeze({ ok: true as const, nonce: 'n1_abcdefghijklmnopqrstuv' }); - } + const bootstrapNonce = `b1_${'b'.repeat(22)}`; + const rendererNonce = `n1_${'n'.repeat(22)}`; + const nonceRegistry = (nonce: string) => + Object.freeze({ + bindSource: vi.fn(() => true), + consume: vi.fn(() => true), + dispose: vi.fn(), + issue: vi.fn(() => Object.freeze({ ok: true as const, nonce })), + snapshotForTest: vi.fn(() => + Object.freeze({ bindings: 1, disposed: false, liveNonces: 1 }) + ), + }); + const bootstraps = nonceRegistry(bootstrapNonce); + const renderers = nonceRegistry(rendererNonce); + const topSlot = document.getElementById('top-slot')!; + const isCurrent = vi.fn(() => true); + const resolveApsMountBinding = vi.fn(() => + Object.freeze({ + bindingEpoch: Object.freeze({ binding: 1 }), + cycle: Object.freeze({ isRetired: () => false }), + element: topSlot, + physicalSlot: Object.freeze({ slot: 'physical' }), + isCurrent, + }) ); - const consume = vi.fn(() => true); const harness = createHarness( () => ({ recognized: true, state: 'renderable', expiresAt: 10_000 }), { @@ -2847,267 +2772,108 @@ describe('Universal Creative bridge dispatcher', () => { 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; - } + readonly port1 = controlRetained; + readonly port2 = controlTransferred; }, 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', + mountAps: (input) => + renderPucApsAttempt({ + ...input, + bootstrapNonces: bootstraps, + nonces: renderers, + publisherOrigin: window.location.origin, + }), + slots: { resolveApsMountBinding }, } ); - issueReadyTicket(harness, gam, pucSource); - harness.dispatch({ - data: exactOwnerRegistration(gam.reservationId), - ports: [createPort()], - source: pucSource, - stopImmediatePropagation: vi.fn(), - }); + try { + 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: { + expect(resolveApsMountBinding).toHaveBeenCalledWith( + gam.owner.navigationGeneration, + gam.owner.slot, + gam.attempt.id + ); + expect(gam.attempt.fail.mock.calls).toEqual([]); + expect(bootstraps.issue).toHaveBeenCalledOnce(); + expect(renderers.issue).toHaveBeenCalledOnce(); + expect(gam.attempt.beginApsDocument).toHaveBeenCalledOnce(); + expect(gam.artifact.dispose).not.toHaveBeenCalled(); + expect(document.body.querySelector('#publisher-child iframe')).toBeNull(); + expect(document.body.querySelectorAll('#top-slot iframe')).toHaveLength(1); + const frame = topSlot.querySelector('iframe')!; + expect(frame.style.position).toBe('absolute'); + expect(frame.style.visibility).toBe('hidden'); + expect(topSlot.querySelector('span')?.textContent).toBe('gam'); + expect(controlRetained.postMessage).toHaveBeenCalledExactlyOnceWith( + { + message: 'TS APS Top Mount Started', 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', - }, + lifecycleTicket: LIFECYCLE_TICKET, }, - }, - [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', - }); - 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 - // 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).toHaveBeenCalledWith(gam.artifact); - expect(gam.artifact.dispose).not.toHaveBeenCalled(); - 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(); - 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: Parameters['issue']>[0]) => { - const port = input.port; - if (!port) throw new Error('should receive the PUC renderer port'); - expect(input.attempt.onSettled(() => 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, + const source = frame.contentWindow!; + harness.dispatch({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, }), - 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' }); - 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, + origin: 'null', + ports: [], + source, + }); + const rendererPort = createPort(); + harness.dispatch({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, }), - messageChannel: class { - readonly port1: unknown; - readonly port2: unknown; + origin: 'null', + ports: [rendererPort], + source, + }); + dispatchPortMessage(rendererPort, { + message: 'TS APS Document Accepted', + version: 1, + nonce: rendererNonce, + }); + dispatchPortMessage(rendererPort, { + message: 'TS APS Render Completed', + version: 1, + nonce: rendererNonce, + }); - constructor() { - channelIndex += 1; - if (channelIndex === 1) { - this.port1 = controlRetained; - this.port2 = controlTransferred; - return; - } - this.port1 = documentRetained; - this.port2 = documentTransferred; - gam.attempt.fail('internal_error'); - } + expect(frame.style.visibility).toBe('visible'); + expect(gam.attempt.accept).toHaveBeenCalledOnce(); + expect(controlRetained.postMessage.mock.calls[1]).toEqual([ + { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', }, - 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); + [], + ]); + expect(controlRetained.close).toHaveBeenCalledOnce(); + } finally { + bootstraps.dispose(); + renderers.dispose(); + harness.bridge.dispose(); + document.body.innerHTML = ''; + } }); - 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' }); 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 afa21d6b7..b4fbd6ed9 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -12,6 +12,7 @@ import { APS_RENDERER_SANDBOX, APS_RENDERER_V1_PATH, renderDirectApsAttempt, + renderPucApsAttempt, resolveApsRendererV1Url, } from '../../src/integrations/aps/render'; import { @@ -1687,6 +1688,157 @@ describe('direct APS attempt rendering', () => { ); expect(resolveApsRendererV1Url('http://publisher.example')).toBeUndefined(); }); + + it('keeps a PUC APS overlay hidden until completion and preserves the slot host', () => { + document.body.innerHTML = '
GAM content
'; + const scope = owner(); + const render = attempt(scope); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, DIRECT_APS_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + const baseArtifact = artifact(scope, 'puc'); + const bootstrapNonce = indexedBootstrapNonce(41); + const rendererNonce = indexedRendererNonce(41); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: bootstrapNonce }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: rendererNonce }), + }); + let captureListener: ((event: MessageEvent) => void) | undefined; + const messaging = createBrowserMessagingAdapter({ + addEventListener: ( + _type: 'message', + listener: (event: MessageEvent) => void, + _capture: true + ) => { + captureListener = listener; + }, + removeEventListener: vi.fn(), + }); + const container = document.getElementById('fictional-slot')!; + const rendererPort = browserMessagePort(); + const isBindingCurrent = vi.fn(() => true); + const onArtifactTransferred = vi.fn(); + + try { + expect( + renderPucApsAttempt({ + attempt: render, + baseArtifact, + bootstrapNonces: bootstraps, + container, + isBindingCurrent, + messaging, + nonces: renderers, + onArtifactTransferred, + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frame = container.querySelector('iframe')!; + const source = frame.contentWindow!; + expect(onArtifactTransferred).toHaveBeenCalledOnce(); + expect(container.querySelector('span')?.textContent).toBe('GAM content'); + expect(container.style.position).toBe('relative'); + expect(frame.style.position).toBe('absolute'); + expect(frame.style.visibility).toBe('hidden'); + + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: 'null', + ports: [], + source, + } as unknown as MessageEvent); + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce, + rendererNonce, + }), + origin: 'null', + ports: [rendererPort], + source, + } as unknown as MessageEvent); + rendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: rendererNonce, + }); + rendererPort.emit({ + message: 'TS APS Runner Loaded', + version: 1, + nonce: rendererNonce, + }); + expect(frame.style.visibility).toBe('hidden'); + rendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: rendererNonce, + }); + + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(frame.style.visibility).toBe('visible'); + expect(container.querySelector('span')?.textContent).toBe('GAM content'); + expect(baseArtifact.dispose).not.toHaveBeenCalled(); + } finally { + bootstraps.dispose(); + renderers.dispose(); + document.body.innerHTML = ''; + } + }); + + it('removes only the failed PUC overlay and compare-restores its host position', () => { + document.body.innerHTML = '
publisher
'; + const scope = owner(); + const render = attempt(scope); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, DIRECT_APS_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + const baseArtifact = artifact(scope, 'puc'); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedBootstrapNonce(42) }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(42) }), + }); + const container = document.getElementById('fictional-slot')!; + + try { + expect( + renderPucApsAttempt({ + attempt: render, + baseArtifact, + bootstrapNonces: bootstraps, + container, + isBindingCurrent: () => true, + messaging: createBrowserMessagingAdapter({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + nonces: renderers, + onArtifactTransferred: vi.fn(), + publisherOrigin: window.location.origin, + }) + ).toBe(true); + expect(container.style.position).toBe('relative'); + expect(render.fail('runner_failed')).toBe(true); + expect(container.querySelector('iframe')).toBeNull(); + expect(container.querySelector('span')?.textContent).toBe('publisher'); + expect(container.style.position).toBe(''); + expect(baseArtifact.dispose).toHaveBeenCalledOnce(); + } finally { + bootstraps.dispose(); + renderers.dispose(); + document.body.innerHTML = ''; + } + }); }); function claimed( 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 1f3188985..aa6f4d321 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -1022,6 +1022,112 @@ describe('slot registry', () => { describe('navigation-owned DOM reconciliation', () => { afterEach(() => vi.useRealTimers()); + async function completedApsMountCycle( + ownership: 'publisher' | 'trusted_server' = 'trusted_server' + ) { + const gpt = createGptHarness(); + const dom = createReconciliationBoundary(); + const element = Object.freeze({ element: 'top-slot' }); + dom.put('slot-div', element); + const service = createSlotService({ googletag: gpt.adapter, reconciliation: dom.boundary }); + const navigation = createNavigation(); + let slot: object; + if (ownership === 'trusted_server') { + slot = bindTrustedSlot(service, navigation); + } else { + slot = Object.freeze({ publisher: true }); + expect(service.register(navigation, [serverRegistration('slot')])).toMatchObject({ + ok: true, + }); + expect( + service.adoptGptSlot(navigation.generation, 'slot', { + definition: Object.freeze({ + adUnitPath: '/network/slot', + elementId: 'slot-div', + sizes: Object.freeze([[300, 250] as const]), + }), + ownership, + slot, + }) + ).toEqual({ ok: true }); + } + const intentId = `a1_${'m'.repeat(22)}`; + const request = service.request({ + intentId, + expectedSlot: slot, + navigationGeneration: navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + service.handleGptEvent('slotRequested', { slot }); + service.handleGptEvent('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'aps-top-mount', + slot, + }); + await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); + return { dom, element, intentId, navigation, service, slot }; + } + + it('claims one exact completed TS cycle and invalidates it on DOM replacement', async () => { + const h = await completedApsMountCycle(); + const binding = h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId); + + expect(binding).toMatchObject({ element: h.element, physicalSlot: h.slot }); + expect(binding?.isCurrent()).toBe(true); + expect( + h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId) + ).toBeUndefined(); + + h.dom.replace('slot-div', Object.freeze({ element: 'replacement' })); + expect(binding?.isCurrent()).toBe(false); + }); + + it('refuses a publisher-owned cycle when its exact host was replaced before binding', async () => { + const h = await completedApsMountCycle('publisher'); + h.dom.replace('slot-div', Object.freeze({ element: 'publisher-replacement' })); + + expect( + h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId) + ).toBeUndefined(); + }); + + it('refuses missing, ambiguous, disconnected, and stale APS mount bindings', async () => { + const ambiguous = await completedApsMountCycle(); + ambiguous.dom.replaceAmbiguously('slot-div', [ + Object.freeze({ element: 'first' }), + Object.freeze({ element: 'second' }), + ]); + expect( + ambiguous.service.resolveApsMountBinding( + ambiguous.navigation.generation, + 'slot', + ambiguous.intentId + ) + ).toBeUndefined(); + + const disconnected = await completedApsMountCycle(); + disconnected.dom.disconnect('slot-div'); + expect( + disconnected.service.resolveApsMountBinding( + disconnected.navigation.generation, + 'slot', + disconnected.intentId + ) + ).toBeUndefined(); + + const stale = await completedApsMountCycle(); + stale.navigation.dispose(); + expect( + stale.service.resolveApsMountBinding(stale.navigation.generation, 'slot', stale.intentId) + ).toBeUndefined(); + expect( + stale.service.resolveApsMountBinding(stale.navigation.generation, 'missing', stale.intentId) + ).toBeUndefined(); + }); + it('installs reconciliation only for one explicit reversible deferred owner', () => { const gpt = createGptHarness(); const dom = createReconciliationBoundary(); From e18e590409233f32d7a6c29267942272e2ae034c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:51:20 -0700 Subject: [PATCH 825/844] Retire committed APS overlays exactly once --- .../browser/helpers/gpt-stub.ts | 28 +- .../tests/shared/aps-puc-lifecycle.spec.ts | 40 ++ .../lib/src/composition/browser_test.ts | 18 +- .../src/first_display/adapters/googletag.ts | 352 +++++++++-- .../src/first_display/adm_render_bridge.ts | 67 ++- .../lib/src/first_display/agent.ts | 23 +- .../lib/src/first_display/driver.ts | 15 +- .../lib/src/first_display/render_bridge.ts | 133 ++++- .../lib/src/integrations/aps/module.ts | 19 + .../lib/src/integrations/aps/render.ts | 177 +++++- .../lib/src/integrations/gpt/module.ts | 95 ++- .../src/integrations/render_runtime/module.ts | 135 ++++- .../lib/src/services/puc_bridge.ts | 4 +- .../lib/src/services/render.ts | 272 ++++++++- .../lib/src/services/slots.ts | 161 ++++- .../lib/src/services/targeting.ts | 91 +++ .../lib/src/shared/first_display_contracts.ts | 73 ++- .../first_display/adm_render_bridge.test.ts | 15 + .../lib/test/first_display/agent.test.ts | 9 + .../lib/test/first_display/contracts.test.ts | 12 +- .../lib/test/first_display/driver.test.ts | 20 +- .../test/first_display/gpt_adapter.test.ts | 565 +++++++++++++++++- .../lib/test/first_display/handoff.test.ts | 4 + .../test/first_display/render_bridge.test.ts | 82 +++ .../lib/test/first_display/takeover.test.ts | 3 + .../lib/test/integrations/aps/module.test.ts | 11 +- .../lib/test/integrations/gpt/module.test.ts | 182 +++++- .../render_runtime/module.test.ts | 249 +++++++- .../test/kernel/integration_registry.test.ts | 1 + .../lib/test/kernel/lifecycle_module.test.ts | 1 + .../lib/test/kernel/runtime.test.ts | 1 + .../lib/test/services/puc_bridge.test.ts | 18 +- .../lib/test/services/render.test.ts | 265 +++++++- .../lib/test/services/slots.test.ts | 136 ++++- .../lib/test/services/targeting.test.ts | 57 ++ 35 files changed, 3143 insertions(+), 191 deletions(-) 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 683ba8d7e..df77f648c 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts @@ -297,6 +297,7 @@ export async function installGptStub(page: Page): Promise { pairedHistoryWrappers: boolean; }; refreshCount(): number; + publisherRefresh(slotId: string): void; renderUniversalCreative(slotId: string, adId: string): void; slot(id: string, adUnitPath?: string): StubSlot; targeting(slotId: string, key: string): readonly string[]; @@ -340,6 +341,21 @@ export async function installGptStub(page: Page): Promise { refreshCount() { return refreshCalls.length; }, + publisherRefresh(slotId: string) { + const slot = slots.get(slotId); + if (!slot || !physicalSlots.has(slot)) { + throw new Error(`missing fictional publisher slot: ${slotId}`); + } + pubadsService.refresh([slot]); + emissions.push({ + name: "slotRequested", + listeners: listeners.get("slotRequested")?.size ?? 0, + physical: physicalSlots.has(slot), + }); + emit("slotRequested", slot, { + responseIdentifier: "fictional-publisher-refresh", + }); + }, targeting(slotId: string, key: string) { return slots.get(slotId)?.getTargeting(key) ?? []; }, @@ -412,12 +428,12 @@ export async function installGptStub(page: Page): Promise { window.history.replaceState !== references.replaceState; return { diagnosticsSafe: - 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, + 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, pairedHistoryWrappers: pushStateChanged && replaceStateChanged, }; }, diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts index 878bdf3c2..182efd617 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts @@ -388,4 +388,44 @@ test.describe("APS/PUC lifecycle over the hard-cutover runtime", () => { topCreativeFrames: 1, }); }); + + test("retires the exact APS overlay before a publisher refresh returns", async ({ + page, + }) => { + await openLifecyclePage(page); + await startFictionalPuc(page); + await emitNonemptyGam(page); + await expectAcceptedLifecycle(page); + + expect( + await page.evaluate((slot) => { + const stub = ( + window as unknown as { + __gptDiagnosticsStub: { + publisherRefresh(slotId: string): void; + universalCreativeSnapshot(): Readonly<{ + lifecycle: readonly string[]; + }>; + }; + } + ).__gptDiagnosticsStub; + stub.publisherRefresh(slot); + const root = document.getElementById(slot); + return { + lifecycle: stub.universalCreativeSnapshot().lifecycle, + position: root?.style.getPropertyValue("position"), + pucFrames: + root?.querySelectorAll("iframe[data-fictional-puc]").length ?? -1, + topCreativeFrames: + root?.querySelectorAll(':scope > iframe[title="Ad content"]') + .length ?? -1, + }; + }, SLOT), + ).toEqual({ + lifecycle: ["accepted"], + position: "", + pucFrames: 1, + topCreativeFrames: 0, + }); + }); }); diff --git a/crates/trusted-server-js/lib/src/composition/browser_test.ts b/crates/trusted-server-js/lib/src/composition/browser_test.ts index c4daea959..18488c056 100644 --- a/crates/trusted-server-js/lib/src/composition/browser_test.ts +++ b/crates/trusted-server-js/lib/src/composition/browser_test.ts @@ -130,6 +130,9 @@ import { } from '../services/projections'; import { createReservationService, type ReservationService } from '../services/reservations'; import { + bindCommittedArtifactGuard, + bindCommittedArtifactRetirement, + createArtifactHostPositionLeaseRegistry, createCommittedArtifactStore, createBootstrapNonceRegistry, createRenderAttempt, @@ -137,6 +140,7 @@ import { createSlotOperation, renderDirectAdmAttempt, type RenderAttempt, + type ArtifactHostPositionLeaseRegistry, type CommittedArtifactStore, type BootstrapNonceRegistry, type RendererNonceRegistry, @@ -204,6 +208,7 @@ function installBrowserTestRuntimeScript(runtimeDocument: Document): void { export interface BrowserServices { readonly artifacts: CommittedArtifactStore; + readonly hostPositions: ArtifactHostPositionLeaseRegistry; readonly auctionBatches: AuctionBatchService; readonly bootstrapNonces: BootstrapNonceRegistry; readonly pucBridge: PucBridge; @@ -1557,10 +1562,15 @@ export function createTestBrowserRuntimeComposition( ? undefined : createBrowserSlotReconciliationBoundary(document, MutationObserver); const artifacts = createCommittedArtifactStore(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); const slotService = createSlotService({ - disposeCommittedArtifact: (navigationGeneration, registeredSlotId) => { + bindCommittedArtifactRetirement, + disposeCommittedArtifact: (navigationGeneration, registeredSlotId, expectedArtifact) => { const artifact = artifacts.current(registeredSlotId); - if (artifact?.navigationGeneration === navigationGeneration) { + if ( + artifact === expectedArtifact && + artifact.navigationGeneration === navigationGeneration + ) { artifacts.release(artifact); } }, @@ -1602,6 +1612,7 @@ export function createTestBrowserRuntimeComposition( try { return renderDirectApsAttempt({ attempt, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces, container, messaging: composition.adapters.messaging, @@ -1672,6 +1683,7 @@ export function createTestBrowserRuntimeComposition( artifacts, auctionBatches: batchCoordinator, bootstrapNonces, + hostPositions, reservations: reservationService, rendererNonces, renderDirectAdm, @@ -1799,7 +1811,9 @@ export function createTestBrowserRuntimeComposition( mountAps: (input) => renderPucApsAttempt({ ...input, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces: prepared.services.bootstrapNonces, + hostPositions: prepared.services.hostPositions, messaging: composition.adapters.messaging, nonces: prepared.services.rendererNonces, publisherOrigin: prepared.publisherOrigin, diff --git a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts index ba6086198..efa5f4116 100644 --- a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts @@ -51,6 +51,16 @@ export interface FirstDisplayGptBoundCycleV1 { readonly traceToken: string; } +export interface FirstDisplayTargetingOwnershipV1 { + readonly installed: string; + readonly key: string; + readonly prior: readonly string[]; +} + +export interface FirstDisplayGptHandoffCycleV1 extends FirstDisplayGptBoundCycleV1 { + readonly targetingOwnership: readonly FirstDisplayTargetingOwnershipV1[]; +} + export interface FirstDisplayGptDiagnosticCycleV1 { readonly nextCycleOrdinal: number; readonly quarantines: readonly string[]; @@ -78,14 +88,17 @@ export interface FirstDisplayGoogletagBatchCallbacks { cycle: FirstDisplayGptBoundCycleV1, result: FirstDisplayGptRenderResult ) => void; + /** Retire an accepted TS artifact when its exact parser-time physical binding is lost. */ + readonly onRetire?: (cycle: FirstDisplayGptBoundCycleV1) => void; } export interface FirstDisplayGoogletagBatch { readonly start: (callbacks: FirstDisplayGoogletagBatchCallbacks) => boolean; /** Stop provisional GPT ingress after every physical cycle is terminal. */ - readonly closeIngress: () => boolean; + /** Destroy noncommitted TS slots before closing publisher-mutation observation. */ + readonly closeIngress: (committedSlotIds: readonly string[]) => boolean; /** Capture exact terminal physical identities without changing disposal ownership. */ - readonly captureHandoff: () => readonly FirstDisplayGptBoundCycleV1[] | undefined; + readonly captureHandoff: () => readonly FirstDisplayGptHandoffCycleV1[] | undefined; readonly captureDiagnosticsHandoff: () => FirstDisplayGptDiagnosticsHandoffV1 | undefined; /** Exempt exactly the accepted slot identities from provisional destruction/restoration. */ readonly detachCommittedSlots: (slotIds: readonly string[]) => boolean; @@ -141,6 +154,22 @@ interface TargetingRestorer { valid: boolean; } +interface TargetingWrite { + consumed: boolean; + readonly operation: 'clearTargeting' | 'setTargeting'; + readonly slot: object; +} + +interface TargetingObserverRestorer { + readonly isCurrent: () => boolean; + readonly restore: () => boolean; +} + +interface TargetingObserver { + readonly isCurrent: () => boolean; + readonly restore: () => boolean; +} + function externalObject(value: unknown): ExternalObject | undefined { return (typeof value === 'object' && value !== null) || typeof value === 'function' ? (value as ExternalObject) @@ -235,8 +264,12 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private readonly createdSlots = new Set(); private readonly diagnosticFacts: Readonly[] = []; private readonly diagnosticListeners = new Map void>(); - private readonly targetingObservers = new Map void>(); + private readonly targetingObservers = new Map(); private readonly targetingRestorers: TargetingRestorer[] = []; + private readonly sealedTargetingOwnership = new Map< + object, + readonly FirstDisplayTargetingOwnershipV1[] + >(); private readonly publisherCallRestorers: Array<() => void> = []; private readonly timers = new Set(); private binding: ExternalObject | undefined; @@ -250,13 +283,14 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private started = false; private disposed = false; private ingressClosed = false; + private ingressClosing = false; private committedSlotsDetached = false; private readonly detachedSlots = new Set(); private firstAction = false; private diagnosticFactOverflow = 0; private diagnosticFactDrops = 0; private nextTraceTokenOrdinal = 1; - private targetingWriteDepth = 0; + private readonly targetingWrites: TargetingWrite[] = []; public constructor(private readonly options: FirstDisplayGoogletagBatchOptions) {} @@ -304,36 +338,84 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { return true; } - public closeIngress(): boolean { + public closeIngress(committedSlotIds: readonly string[]): boolean { if ( this.disposed || this.ingressClosed || + this.ingressClosing || !this.started || - [...this.cycles.values()].some((cycle) => !cycle.settled) + [...this.cycles.values()].some((cycle) => !cycle.settled) || + !Array.isArray(committedSlotIds) ) { return false; } + const committed = new Set(committedSlotIds); + if (committed.size !== committedSlotIds.length) return false; + const retainedSlots = new Set(); + for (const slotId of committed) { + const matches = [...this.cycles.values()].filter((cycle) => cycle.slotId === slotId); + if (matches.length !== 1 || !matches[0]?.settled) return false; + retainedSlots.add(matches[0].physicalSlot); + } + const destroyableSlots = [...this.createdSlots].filter((slot) => !retainedSlots.has(slot)); + this.ingressClosing = true; + if (destroyableSlots.length > 0) { + try { + if (!this.binding || call(this.binding, 'destroySlots', [[...destroyableSlots]]) !== true) { + this.ingressClosing = false; + return false; + } + } catch { + this.ingressClosing = false; + return false; + } + for (const slot of destroyableSlots) this.createdSlots.delete(slot); + } + this.invalidateStaleTargetingObservers(); + const retainedTargetingRestorers = this.targetingRestorers.filter((restoration) => + retainedSlots.has(restoration.slot) + ); + for (const restoration of [...this.targetingRestorers].reverse()) { + if (retainedSlots.has(restoration.slot)) continue; + try { + this.restorePublisherTargeting(restoration); + } catch { + // Publisher mutations win while retained-slot observation is still authoritative. + } + } + this.targetingRestorers.splice( + 0, + this.targetingRestorers.length, + ...retainedTargetingRestorers + ); this.ingressClosed = true; + this.ingressClosing = false; this.removePendingCommand(); for (const handle of [...this.timers]) this.clearOwnedTimer(handle); this.removeListener(); this.restorePublisherCalls(); - for (const restoreObserver of this.targetingObservers.values()) { - try { - restoreObserver(); - } catch { - // The closed generation cannot regain authority through a publisher replacement. - } + const targetingCandidates = this.captureRetainedTargeting(retainedSlots); + this.restoreTargetingObservers(); + for (const [slot, restorations] of targetingCandidates) { + this.sealedTargetingOwnership.set( + slot, + Object.freeze( + restorations + .filter(({ valid }) => valid) + .map(({ installed, key, prior }) => Object.freeze({ installed, key, prior })) + ) + ); } - this.targetingObservers.clear(); return true; } - public captureHandoff(): readonly FirstDisplayGptBoundCycleV1[] | undefined { + public captureHandoff(): readonly FirstDisplayGptHandoffCycleV1[] | undefined { if (this.disposed || !this.ingressClosed) return undefined; return Object.freeze( - [...this.cycles.values()].map((cycle) => - Object.freeze({ + [...this.cycles.values()].map((cycle) => { + const targetingOwnership = + this.sealedTargetingOwnership.get(cycle.physicalSlot) ?? Object.freeze([]); + return Object.freeze({ bid: cycle.bid, element: cycle.element, isCurrent: cycle.isCurrent, @@ -341,9 +423,10 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { physicalSlot: cycle.physicalSlot, placement: cycle.placement, slotId: cycle.slotId, + targetingOwnership: Object.freeze(targetingOwnership), traceToken: cycle.traceToken, - }) - ) + }); + }) ); } @@ -402,6 +485,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { for (const handle of [...this.timers]) this.clearOwnedTimer(handle); this.removeListener(); this.restorePublisherCalls(); + this.invalidateStaleTargetingObservers(); for (const restoration of this.targetingRestorers.reverse()) { if (this.detachedSlots.has(restoration.slot)) continue; try { @@ -411,14 +495,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } this.targetingRestorers.length = 0; - for (const restoreObserver of this.targetingObservers.values()) { - try { - restoreObserver(); - } catch { - // Publisher replacement wins over observer restoration. - } - } - this.targetingObservers.clear(); + this.restoreTargetingObservers(); const destroyableSlots = [...this.createdSlots].filter((slot) => !this.detachedSlots.has(slot)); if (this.binding && destroyableSlots.length > 0) { try { @@ -429,6 +506,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } this.createdSlots.clear(); this.cycles.clear(); + this.sealedTargetingOwnership.clear(); if (this.detachedSlots.size === 0) this.restoreCreatedBinding(); else this.createdBinding = undefined; this.detachedSlots.clear(); @@ -484,7 +562,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { const service = externalObject(call(binding, 'pubads', [])); if (!service) throw new TypeError('tsjs'); this.service = service; - this.observePublisherCalls(binding, service); + this.observePublisherCalls(binding, service, callbacks); this.installListeners(service, callbacks); const existing = call(service, 'getSlots', []); if (!Array.isArray(existing)) throw new TypeError('tsjs'); @@ -624,11 +702,13 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { const slot = physicalSlot(member(event, 'slot')); const cycle = slot ? this.cycles.get(slot) : undefined; if (!cycle) { - if (slot) this.invalidateCyclesForElement(this.readPhysicalElementId(slot), slot); + if (slot) { + this.invalidateCyclesForElement(this.readPhysicalElementId(slot), slot, callbacks); + } return; } if (cycle.settled) { - cycle.bindingState.current = false; + this.retireCycle(cycle, callbacks); this.captureDiagnosticFact('slotRequested', cycle, event); return; } @@ -744,7 +824,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private observePublisherTargeting(slot: object): void { if (this.targetingObservers.has(slot)) return; - const restorers: Array<() => void> = []; + const restorers: TargetingObserverRestorer[] = []; try { for (const key of ['setTargeting', 'clearTargeting'] as const) { const original = member(slot, key); @@ -752,10 +832,11 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { const prior = Object.getOwnPropertyDescriptor(slot, key); const invalidateTargeting = (targetingKey: string | undefined): void => this.invalidateTargeting(slot, targetingKey); - const isTrustedServerWrite = (): boolean => this.targetingWriteDepth > 0; + const consumeTrustedServerWrite = (): boolean => this.consumeTargetingWrite(slot, key); const ownerMutation = (): void => this.notifyNativeMutation(); const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { - if (!isTrustedServerWrite() && this === slot) { + const trustedServerWrite = this === slot && consumeTrustedServerWrite(); + if (!trustedServerWrite && this === slot) { const targetingKey = typeof arguments_[0] === 'string' ? arguments_[0] : undefined; invalidateTargeting(targetingKey); const result = Reflect.apply(original, this, arguments_); @@ -774,23 +855,49 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { ) { throw new TypeError('tsjs'); } - restorers.push(() => { - const current = Object.getOwnPropertyDescriptor(slot, key); - if (!current || !('value' in current) || current.value !== wrapper) return; - if (prior) Reflect.defineProperty(slot, key, prior); - else Reflect.deleteProperty(slot, key); + const isCurrent = (): boolean => { + try { + const current = Object.getOwnPropertyDescriptor(slot, key); + return !!current && 'value' in current && current.value === wrapper; + } catch { + return false; + } + }; + restorers.push({ + isCurrent, + restore: (): boolean => { + if (!isCurrent()) return false; + try { + return prior + ? Reflect.defineProperty(slot, key, prior) + : Reflect.deleteProperty(slot, key); + } catch { + return false; + } + }, }); } } catch (error) { - for (const restore of restorers.reverse()) restore(); + for (const restorer of restorers.reverse()) restorer.restore(); throw error; } - this.targetingObservers.set(slot, () => { - for (const restore of restorers.reverse()) restore(); + this.targetingObservers.set(slot, { + isCurrent: (): boolean => restorers.every(({ isCurrent }) => isCurrent()), + restore: (): boolean => { + let exact = restorers.every(({ isCurrent }) => isCurrent()); + for (const restorer of restorers.reverse()) { + if (!restorer.restore()) exact = false; + } + return exact; + }, }); } - private observePublisherCalls(binding: ExternalObject, service: ExternalObject): void { + private observePublisherCalls( + binding: ExternalObject, + service: ExternalObject, + callbacks: FirstDisplayGoogletagBatchCallbacks + ): void { const installed: Array<() => void> = []; try { for (const [receiver, key] of [ @@ -803,12 +910,19 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (typeof original !== 'function') continue; const prior = Object.getOwnPropertyDescriptor(receiver, key); const notify = (): void => this.notifyNativeMutation(); - const invalidate = (arguments_: readonly unknown[], result: unknown): void => - this.invalidateCyclesForPublisherCall(key, arguments_, result); + const snapshotDestroy = (arguments_: readonly unknown[]): readonly ActiveCycle[] => + key === 'destroySlots' ? this.snapshotDestroyedCycles(arguments_) : Object.freeze([]); + const invalidate = ( + arguments_: readonly unknown[], + result: unknown, + destroyed: readonly ActiveCycle[] + ): void => + this.invalidateCyclesForPublisherCall(key, arguments_, result, destroyed, callbacks); const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { + const destroyed = this === receiver ? snapshotDestroy(arguments_) : Object.freeze([]); const result = Reflect.apply(original, this, arguments_); if (this === receiver) { - invalidate(arguments_, result); + invalidate(arguments_, result, destroyed); notify(); } return result; @@ -848,11 +962,54 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } - private invalidateCyclesForElement(elementId: string | undefined, replacement: object): void { + private retireCycle(cycle: ActiveCycle, callbacks: FirstDisplayGoogletagBatchCallbacks): boolean { + if (!cycle.bindingState.current) return false; + cycle.bindingState.current = false; + try { + callbacks.onRetire?.( + Object.freeze({ + bid: cycle.bid, + element: cycle.element, + isCurrent: cycle.isCurrent, + ownership: cycle.ownership, + physicalSlot: cycle.physicalSlot, + placement: cycle.placement, + slotId: cycle.slotId, + traceToken: cycle.traceToken, + }) + ); + } catch { + // Binding invalidation remains authoritative when retirement observation fails. + } + return true; + } + + private snapshotDestroyedCycles(arguments_: readonly unknown[]): readonly ActiveCycle[] { + try { + const targets = arguments_[0]; + if (targets === undefined) return Object.freeze([...this.cycles.values()]); + if (!Array.isArray(targets)) return Object.freeze([]); + const cycles = new Set(); + for (let index = 0; index < targets.length; index += 1) { + const target = physicalSlot(targets[index]); + const cycle = target ? this.cycles.get(target) : undefined; + if (cycle) cycles.add(cycle); + } + return Object.freeze([...cycles]); + } catch { + return Object.freeze([]); + } + } + + private invalidateCyclesForElement( + elementId: string | undefined, + replacement: object, + callbacks: FirstDisplayGoogletagBatchCallbacks + ): void { if (!elementId) return; for (const cycle of this.cycles.values()) { if (cycle.physicalSlot !== replacement && cycle.elementId === elementId) { - cycle.bindingState.current = false; + this.retireCycle(cycle, callbacks); } } } @@ -860,28 +1017,20 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private invalidateCyclesForPublisherCall( key: string, arguments_: readonly unknown[], - result: unknown + result: unknown, + destroyed: readonly ActiveCycle[], + callbacks: FirstDisplayGoogletagBatchCallbacks ): void { try { if (key === 'destroySlots' && result === true) { - const targets = arguments_[0]; - if (targets === undefined) { - for (const cycle of this.cycles.values()) cycle.bindingState.current = false; - return; - } - if (!Array.isArray(targets)) return; - for (let index = 0; index < targets.length; index += 1) { - const target = physicalSlot(targets[index]); - const cycle = target ? this.cycles.get(target) : undefined; - if (cycle) cycle.bindingState.current = false; - } + for (const cycle of destroyed) this.retireCycle(cycle, callbacks); return; } if (key === 'defineSlot') { const replacement = physicalSlot(result); const elementId = arguments_[2]; if (replacement && typeof elementId === 'string') { - this.invalidateCyclesForElement(elementId, replacement); + this.invalidateCyclesForElement(elementId, replacement, callbacks); } } } catch { @@ -1110,9 +1259,25 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } private restorePublisherTargeting(restoration: TargetingRestorer): void { + if (!restoration.valid) return; + const observer = this.targetingObservers.get(restoration.slot); + if (observer && !observer.isCurrent()) { + this.invalidateTargeting(restoration.slot, undefined); + return; + } if (!restoration.valid) return; const current = call(restoration.slot, 'getTargeting', [restoration.key]); - if (!Array.isArray(current) || current.length !== 1 || current[0] !== restoration.installed) { + if (!restoration.valid) return; + if (observer && !observer.isCurrent()) { + this.invalidateTargeting(restoration.slot, undefined); + return; + } + if ( + !restoration.valid || + !Array.isArray(current) || + current.length !== 1 || + current[0] !== restoration.installed + ) { return; } if (restoration.prior.length === 0) { @@ -1127,12 +1292,73 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { operation: 'clearTargeting' | 'setTargeting', arguments_: readonly unknown[] ): unknown { - this.targetingWriteDepth += 1; + const write: TargetingWrite = { consumed: false, operation, slot }; + this.targetingWrites.push(write); try { return call(slot, operation, arguments_); } finally { - this.targetingWriteDepth -= 1; + const index = this.targetingWrites.lastIndexOf(write); + if (index >= 0) this.targetingWrites.splice(index, 1); + } + } + + private consumeTargetingWrite( + slot: object, + operation: 'clearTargeting' | 'setTargeting' + ): boolean { + const write = this.targetingWrites[this.targetingWrites.length - 1]; + if (!write || write.consumed || write.slot !== slot || write.operation !== operation) { + return false; + } + write.consumed = true; + return true; + } + + private invalidateStaleTargetingObservers(): void { + for (const [slot, observer] of this.targetingObservers) { + if (!observer.isCurrent()) this.invalidateTargeting(slot, undefined); + } + } + + private restoreTargetingObservers(): void { + for (const [slot, observer] of this.targetingObservers) { + if (!observer.restore()) this.invalidateTargeting(slot, undefined); + } + this.targetingObservers.clear(); + } + + private captureRetainedTargeting( + retainedSlots: ReadonlySet + ): Map { + const captured = new Map(); + for (const slot of retainedSlots) { + const observer = this.targetingObservers.get(slot); + if (observer && !observer.isCurrent()) this.invalidateTargeting(slot, undefined); + const restorations: TargetingRestorer[] = []; + for (const restoration of this.targetingRestorers) { + if (!restoration.valid || restoration.slot !== slot) continue; + let current: unknown; + try { + current = call(slot, 'getTargeting', [restoration.key]); + } catch { + continue; + } + if (observer && !observer.isCurrent()) { + this.invalidateTargeting(slot, undefined); + break; + } + if ( + restoration.valid && + Array.isArray(current) && + current.length === 1 && + current[0] === restoration.installed + ) { + restorations.push(restoration); + } + } + captured.set(slot, restorations); } + return captured; } private failRows( @@ -1196,7 +1422,7 @@ export function createFirstDisplayGoogletagBatch( const owner = new FirstDisplayGoogletagBatchOwner(options); return Object.freeze({ start: (callbacks: FirstDisplayGoogletagBatchCallbacks) => owner.start(callbacks), - closeIngress: () => owner.closeIngress(), + closeIngress: (committedSlotIds: readonly string[]) => owner.closeIngress(committedSlotIds), captureHandoff: () => owner.captureHandoff(), captureDiagnosticsHandoff: () => owner.captureDiagnosticsHandoff(), detachCommittedSlots: (slotIds: readonly string[]) => owner.detachCommittedSlots(slotIds), diff --git a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts index 1660fac23..19cc2d064 100644 --- a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts @@ -67,7 +67,10 @@ export function createFirstDisplayAdmRenderBridge( options: FirstDisplayAdmRenderBridgeOptionsV1 ): FirstDisplayRenderBridgeV1 { const attempts = new Map(); - const committedFrames = new Map>(); + const committedFrames = new Map< + string, + Readonly<{ frame: HTMLIFrameElement; host: HTMLElement; token: string }> + >(); const timers = new Set(); let nextReservationOrdinal = 1; let lastNow = Number.NEGATIVE_INFINITY; @@ -130,7 +133,11 @@ export function createFirstDisplayAdmRenderBridge( if (result === 'accepted' && frame?.isConnected) { committedFrames.set( attempt.cycle.slotId, - Object.freeze({ frame, token: attempt.cycle.bid.rendererReservationId }) + Object.freeze({ + frame, + host: attempt.cycle.element, + token: attempt.cycle.bid.rendererReservationId, + }) ); } else if (frame) { try { @@ -150,6 +157,54 @@ export function createFirstDisplayAdmRenderBridge( return true; }; const fail = (attempt: AdmAttempt, reason: string): boolean => settle(attempt, 'failed', reason); + const retireCommitted = (cycle: FirstDisplayGptBoundCycleV1): boolean => { + if (disposed || committedArtifactsDetached) return false; + const entry = committedFrames.get(cycle.slotId); + if (!entry || entry.token !== cycle.bid.rendererReservationId) return false; + committedFrames.delete(cycle.slotId); + try { + entry.frame.onload = null; + entry.frame.onerror = null; + entry.frame.remove(); + } catch { + // The exact TS frame loses authority even when hostile DOM cleanup fails. + } + notifyNativeMutation(); + return true; + }; + const sweepCommittedArtifacts = (): number => { + if (disposed || committedArtifactsDetached) return 0; + let retired = 0; + for (const [slotId, entry] of [...committedFrames.entries()]) { + const current = (() => { + try { + return ( + entry.frame.ownerDocument === options.document && + entry.host.ownerDocument === options.document && + entry.frame.parentNode === entry.host && + entry.frame.isConnected && + entry.host.isConnected && + entry.host.id.length > 0 && + options.document.getElementById(entry.host.id) === entry.host + ); + } catch { + return false; + } + })(); + if (committedFrames.get(slotId) !== entry || current) continue; + committedFrames.delete(slotId); + try { + entry.frame.onload = null; + entry.frame.onerror = null; + entry.frame.remove(); + } catch { + // The exact TS frame loses authority even when hostile DOM cleanup fails. + } + retired += 1; + } + if (retired > 0) notifyNativeMutation(); + return retired; + }; return Object.freeze({ bind: (cycle: FirstDisplayGptBoundCycleV1, onTerminal: AdmAttempt['onTerminal']): boolean => { @@ -222,6 +277,8 @@ export function createFirstDisplayAdmRenderBridge( attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') ); }, + retire: retireCommitted, + sweepCommittedArtifacts, sealTsAdmission: (): void => { if (disposed || sealed || [...attempts.values()].some(({ active }) => active)) { throw new TypeError('tsjs'); @@ -236,6 +293,7 @@ export function createFirstDisplayAdmRenderBridge( }, captureHandoff: () => { if (disposed || !ingressClosed || handoffCaptured) return undefined; + sweepCommittedArtifacts(); const observedAt = readNow(); if (observedAt === undefined) return undefined; handoffCaptured = true; @@ -243,6 +301,8 @@ export function createFirstDisplayAdmRenderBridge( artifacts: Object.freeze( [...committedFrames.entries()].map(([slotId, { frame, token }]) => Object.freeze({ + hostPosition: null, + hostPositionPriority: null, identity: frame, kind: 'gpt_adm' as const, owner: 'trusted_server' as const, @@ -272,6 +332,7 @@ export function createFirstDisplayAdmRenderBridge( if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { return false; } + if (sweepCommittedArtifacts() > 0) return false; committedArtifactsDetached = true; return true; }, @@ -285,6 +346,8 @@ export function createFirstDisplayAdmRenderBridge( if (!committedArtifactsDetached) { for (const { frame } of committedFrames.values()) { try { + frame.onload = null; + frame.onerror = null; frame.remove(); } catch { // A terminal owner cannot regain authority through a retained node. diff --git a/crates/trusted-server-js/lib/src/first_display/agent.ts b/crates/trusted-server-js/lib/src/first_display/agent.ts index 2bbbeeade..75d395801 100644 --- a/crates/trusted-server-js/lib/src/first_display/agent.ts +++ b/crates/trusted-server-js/lib/src/first_display/agent.ts @@ -26,8 +26,8 @@ import { import type { FirstDisplayGoogletagBatchInput, - FirstDisplayGptBoundCycleV1, FirstDisplayGptDiagnosticCycleV1, + FirstDisplayGptHandoffCycleV1, } from './adapters/googletag'; import { createFirstDisplayAdmRenderBridge } from './adm_render_bridge'; import { createFirstDisplayProjectedDriver, type FirstDisplayRenderBridgeV1 } from './driver'; @@ -81,18 +81,21 @@ export interface FirstDisplayDriver { readonly closeIngress: () => boolean; readonly captureHandoff: () => FirstDisplayDriverHandoffV1 | undefined; readonly detachCommittedArtifacts: () => boolean; + readonly sweepCommittedArtifacts: () => number; readonly dispose: () => void; } export interface FirstDisplayDriverHandoffV1 { readonly artifacts: readonly Readonly<{ + hostPosition: string | null; + hostPositionPriority: string | null; identity: object; kind: 'gpt_adm' | 'aps'; owner: 'trusted_server' | 'publisher'; slotId: string; token: string; }>[]; - readonly cycles: readonly FirstDisplayGptBoundCycleV1[]; + readonly cycles: readonly FirstDisplayGptHandoffCycleV1[]; readonly diagnosticCycles: readonly Readonly[]; readonly clockEpochMs: number; readonly gptDiagnostics: Readonly; @@ -701,6 +704,12 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { targeting: Object.keys(targeting) .sort() .map((key) => [key, targeting[key]]), + targetingOwnership: + cycle?.targetingOwnership.map((ownership) => ({ + installed: ownership.installed, + key: ownership.key, + prior: [...ownership.prior], + })) ?? [], committedArtifact, gptToken: cycleTokenBySlot.get(placement.slot) ?? null, }; @@ -747,6 +756,8 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { throw new TypeError('tsjs'); } return { + hostPosition: capturedArtifact.hostPosition, + hostPositionPriority: capturedArtifact.hostPositionPriority, slotId: slot.id, kind: slot.committedArtifact, owner: capturedArtifact.owner, @@ -879,6 +890,14 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { } private observeDomMutations(records: readonly MutationRecord[]): void { + if (records.length > 0) { + try { + this.options.driver.sweepCommittedArtifacts(); + } catch { + this.fail('bundle_partial'); + return; + } + } for (const record of records) { if (this.isOwnedRuntimeInsertion(record)) continue; if (!this.observeNativeMutation()) return; diff --git a/crates/trusted-server-js/lib/src/first_display/driver.ts b/crates/trusted-server-js/lib/src/first_display/driver.ts index bcf9b1463..d1d0a57d6 100644 --- a/crates/trusted-server-js/lib/src/first_display/driver.ts +++ b/crates/trusted-server-js/lib/src/first_display/driver.ts @@ -20,6 +20,8 @@ export interface FirstDisplayRenderBridgeV1 { result: FirstDisplayGptRenderResult ) => boolean; readonly recordFailure: (cycle: FirstDisplayGptBoundCycleV1) => boolean; + readonly retire: (cycle: FirstDisplayGptBoundCycleV1) => boolean; + readonly sweepCommittedArtifacts: () => number; readonly sealTsAdmission: () => void; readonly closeIngress: () => boolean; readonly captureHandoff: () => FirstDisplayRenderHandoffV1 | undefined; @@ -28,6 +30,8 @@ export interface FirstDisplayRenderBridgeV1 { } export interface FirstDisplayRenderHandoffArtifactV1 { + readonly hostPosition: string | null; + readonly hostPositionPriority: string | null; readonly identity: object; readonly kind: 'gpt_adm' | 'aps'; readonly owner: 'trusted_server' | 'publisher'; @@ -182,6 +186,10 @@ export function createFirstDisplayProjectedDriver( settle(cycle.slotId, 'failed', 'gpt_request_failed'); } }, + onRetire: (cycle): void => { + const exact = bound.get(cycle.slotId); + if (exact && sameCycle(exact, cycle)) options.renderer.retire(exact); + }, }); if (accepted !== true) throw new TypeError('tsjs'); }, @@ -192,9 +200,14 @@ export function createFirstDisplayProjectedDriver( sealed = true; options.renderer.sealTsAdmission(); }, + sweepCommittedArtifacts: (): number => + disposed ? 0 : options.renderer.sweepCommittedArtifacts(), closeIngress: (): boolean => { if (disposed || !sealed || ingressClosed) return false; - if (gptBatch && !gptBatch.closeIngress()) return false; + const acceptedIds = [...settledResults.entries()] + .filter(([, result]) => result === 'accepted') + .map(([slotId]) => slotId); + if (gptBatch && !gptBatch.closeIngress(acceptedIds)) return false; if (!options.renderer.closeIngress()) return false; ingressClosed = true; return true; diff --git a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts index 03c88e783..559a0eb6f 100644 --- a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts @@ -586,6 +586,13 @@ export function createFirstDisplayRenderBridge( string, Readonly<{ frame: HTMLIFrameElement; + frameAttributes: string; + frameContentWindow: Window; + frameSource: string; + frameSourceDocument: string; + host: HTMLElement; + hostPosition: string | null; + hostPositionPriority: string | null; kind: 'gpt_adm' | 'aps'; owner: 'trusted_server'; token: string; @@ -638,6 +645,103 @@ export function createFirstDisplayRenderBridge( } }; + const disposeCommittedFrame = ( + entry: Readonly<{ + frame: HTMLIFrameElement; + host: HTMLElement; + hostPosition: string | null; + hostPositionPriority: string | null; + }> + ): void => { + try { + entry.frame.onload = null; + entry.frame.onerror = null; + entry.frame.remove(); + } catch { + // The exact TS node loses authority even when hostile DOM cleanup fails. + } + if (entry.hostPosition === null) return; + try { + const style = entry.host.style; + if ( + style.getPropertyValue('position') !== 'relative' || + style.getPropertyPriority('position') !== '' + ) { + return; + } + if (entry.hostPosition === '') style.removeProperty('position'); + else style.setProperty('position', entry.hostPosition, entry.hostPositionPriority ?? ''); + } catch { + // Compare-owned restoration cannot overwrite publisher style changes. + } + }; + + const snapshotFrameAttributes = (frame: HTMLIFrameElement): string | undefined => { + try { + return JSON.stringify( + [...frame.attributes] + .map((attribute) => [attribute.name, attribute.value] as const) + .sort(([left], [right]) => left.localeCompare(right)) + ); + } catch { + return undefined; + } + }; + + const retireCommitted = (cycle: FirstDisplayGptBoundCycleV1): boolean => { + if (disposed || committedArtifactsDetached) return false; + const entry = committedFrames.get(cycle.slotId); + if (!entry || entry.token !== cycle.bid.rendererReservationId) return false; + committedFrames.delete(cycle.slotId); + disposeCommittedFrame(entry); + notifyNativeMutation(); + return true; + }; + + const committedFrameCurrent = (entry: { + readonly frame: HTMLIFrameElement; + readonly frameAttributes: string; + readonly frameContentWindow: Window; + readonly frameSource: string; + readonly frameSourceDocument: string; + readonly host: HTMLElement; + readonly hostPosition: string | null; + }): boolean => { + try { + return ( + entry.frame.ownerDocument === options.document && + entry.host.ownerDocument === options.document && + entry.frame.parentNode === entry.host && + entry.frame.isConnected && + entry.frame.contentWindow === entry.frameContentWindow && + entry.frame.src === entry.frameSource && + entry.frame.srcdoc === entry.frameSourceDocument && + snapshotFrameAttributes(entry.frame) === entry.frameAttributes && + entry.host.isConnected && + entry.host.id.length > 0 && + options.document.getElementById(entry.host.id) === entry.host && + (entry.hostPosition === null || + (entry.host.style.getPropertyValue('position') === 'relative' && + entry.host.style.getPropertyPriority('position') === '')) + ); + } catch { + return false; + } + }; + + const sweepCommittedArtifacts = (): number => { + if (disposed || committedArtifactsDetached) return 0; + let retired = 0; + for (const [slotId, entry] of [...committedFrames.entries()]) { + if (committedFrames.get(slotId) !== entry || committedFrameCurrent(entry)) continue; + committedFrames.delete(slotId); + disposeCommittedFrame(entry); + retired += 1; + } + if (retired > 0) notifyNativeMutation(); + return retired; + }; + const clearOwnedTimer = (handle: unknown): void => { if (handle === undefined || !timers.delete(handle)) return; try { @@ -792,11 +896,24 @@ export function createFirstDisplayRenderBridge( } const committedFrame = result === 'accepted' ? attempt.directFrame : undefined; releaseAttempt(attempt, result !== 'accepted'); - if (committedFrame?.isConnected) { + const frameAttributes = committedFrame ? snapshotFrameAttributes(committedFrame) : undefined; + const frameContentWindow = committedFrame?.contentWindow; + if (committedFrame?.isConnected && frameAttributes !== undefined && frameContentWindow) { committedFrames.set( attempt.cycle.slotId, Object.freeze({ frame: committedFrame, + frameAttributes, + frameContentWindow, + frameSource: committedFrame.src, + frameSourceDocument: committedFrame.srcdoc, + host: attempt.cycle.element, + hostPosition: + attempt.overlay && attempt.hostPositionOwned ? attempt.previousHostPosition : null, + hostPositionPriority: + attempt.overlay && attempt.hostPositionOwned + ? attempt.previousHostPositionPriority + : null, kind: attempt.cycle.bid.renderSource.type === 'adm' ? 'gpt_adm' : 'aps', owner: 'trusted_server', token: attempt.reservationId, @@ -1647,6 +1764,8 @@ export function createFirstDisplayRenderBridge( attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') ); }, + retire: retireCommitted, + sweepCommittedArtifacts, sealTsAdmission: (): void => { if (disposed || [...attempts.values()].some((attempt) => attempt.active)) { throw new TypeError('tsjs'); @@ -1673,6 +1792,7 @@ export function createFirstDisplayRenderBridge( }, captureHandoff: () => { if (disposed || !ingressClosed || handoffCaptured) return undefined; + sweepCommittedArtifacts(); const observedAt = readNow(); if (observedAt === undefined) return undefined; const reservationTombstones = [...reservations.entries()] @@ -1703,6 +1823,8 @@ export function createFirstDisplayRenderBridge( ); const artifacts = [...committedFrames.entries()].map(([slotId, entry]) => Object.freeze({ + hostPosition: entry.hostPosition, + hostPositionPriority: entry.hostPositionPriority, identity: entry.frame, kind: entry.kind, owner: entry.owner, @@ -1723,6 +1845,7 @@ export function createFirstDisplayRenderBridge( if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { return false; } + if (sweepCommittedArtifacts() > 0) return false; committedArtifactsDetached = true; return true; }, @@ -1742,13 +1865,7 @@ export function createFirstDisplayRenderBridge( } for (const handle of [...timers]) clearOwnedTimer(handle); if (!committedArtifactsDetached) { - for (const { frame } of committedFrames.values()) { - try { - frame.remove(); - } catch { - // The committed owner is terminal and cannot be restored by a hostile DOM node. - } - } + for (const entry of committedFrames.values()) disposeCommittedFrame(entry); } committedFrames.clear(); attempts.clear(); diff --git a/crates/trusted-server-js/lib/src/integrations/aps/module.ts b/crates/trusted-server-js/lib/src/integrations/aps/module.ts index dfaaeefb5..821168fb1 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/module.ts @@ -6,7 +6,9 @@ import type { IntegrationRegistration, } from '../../kernel/integration_registry'; import type { + ArtifactHostPositionLeaseRegistry, BootstrapNonceRegistry, + CommittedRenderArtifact, RendererNonceRegistry, RenderAttempt, } from '../../services/render'; @@ -21,7 +23,12 @@ import { } from './render'; interface RenderCapability { + readonly bindArtifactGuard: ( + artifact: CommittedRenderArtifact, + current: () => boolean + ) => boolean; readonly bootstrapNonces: BootstrapNonceRegistry; + readonly hostPositions: ArtifactHostPositionLeaseRegistry; readonly publisherOrigin: string; readonly rendererNonces: RendererNonceRegistry; readonly registerRenderer: ( @@ -61,8 +68,17 @@ export function createApsIntegrationRegistration(releaseId: string): Integration const render = capability(context.interfaces, 'render.v1'); const messages = capability(context.interfaces, 'messages.v1'); if ( + typeof render.bindArtifactGuard !== 'function' || typeof render.registerRenderer !== 'function' || typeof render.publisherOrigin !== 'string' || + typeof render.hostPositions !== 'object' || + render.hostPositions === null || + !Object.isFrozen(render.hostPositions) || + typeof render.hostPositions.bindOwned !== 'function' || + typeof render.hostPositions.inherit !== 'function' || + typeof render.hostPositions.claim !== 'function' || + typeof render.hostPositions.current !== 'function' || + typeof render.hostPositions.release !== 'function' || typeof messages.messaging !== 'object' || messages.messaging === null || typeof messages.registerApsValidation !== 'function' @@ -76,6 +92,7 @@ export function createApsIntegrationRegistration(releaseId: string): Integration active && renderDirectApsAttempt({ attempt, + bindArtifactGuard: render.bindArtifactGuard, bootstrapNonces: render.bootstrapNonces, container, messaging: messages.messaging, @@ -86,7 +103,9 @@ export function createApsIntegrationRegistration(releaseId: string): Integration active && renderPucApsAttempt({ ...input, + bindArtifactGuard: render.bindArtifactGuard, bootstrapNonces: render.bootstrapNonces, + hostPositions: render.hostPositions, messaging: messages.messaging, nonces: render.rendererNonces, publisherOrigin: render.publisherOrigin, 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 ef2eec771..732c3512f 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -2,12 +2,14 @@ import type { ApsRendererV1 } from '../../core/types'; import { validateApsRenderer } from '../../core/contracts/aps_renderer'; import type { MessagingAdapter, MessagingPort } from '../../adapters/messaging'; import type { + ArtifactHostPositionLeaseRegistry, BootstrapNonceRegistry, CommittedRenderArtifact, RenderAttempt, RenderFailureReason, RendererNonceRegistry, } from '../../services/render'; +import type { ApsSlotMountBinding } from '../../services/slots'; import { APS_PERMANENT_SANDBOX, generateApsDataDocumentsV1 } from './documents'; @@ -31,6 +33,9 @@ const directIframePrototype = directDomAvailable ? HTMLIFrameElement.prototype : const documentCreateElementIntrinsic = directDomAvailable ? Document.prototype.createElement : undefined; +const documentGetElementByIdIntrinsic = directDomAvailable + ? Document.prototype.getElementById + : undefined; const nodeAppendChildIntrinsic = directDomAvailable ? Node.prototype.appendChild : undefined; const nodeRemoveChildIntrinsic = directDomAvailable ? Node.prototype.removeChild : undefined; const elementRemoveIntrinsic = directDomAvailable ? Element.prototype.remove : undefined; @@ -65,6 +70,9 @@ const elementNamespaceGetter = directDomAvailable const elementChildrenGetter = directDomAvailable ? Object.getOwnPropertyDescriptor(Element.prototype, 'children')?.get : undefined; +const elementIdGetter = directDomAvailable + ? Object.getOwnPropertyDescriptor(Element.prototype, 'id')?.get + : undefined; const htmlCollectionLengthGetter = directDomAvailable ? Object.getOwnPropertyDescriptor(HTMLCollection.prototype, 'length')?.get : undefined; @@ -100,6 +108,10 @@ export function prepareApsRenderSource( export interface DirectApsAttemptOptions { readonly attempt: RenderAttempt; + readonly bindArtifactGuard: ( + artifact: CommittedRenderArtifact, + current: () => boolean + ) => boolean; readonly bootstrapNonces: BootstrapNonceRegistry; readonly container: HTMLElement; readonly messaging: MessagingAdapter; @@ -109,6 +121,8 @@ export interface DirectApsAttemptOptions { export interface PucApsAttemptOptions extends DirectApsAttemptOptions { readonly baseArtifact: CommittedRenderArtifact; + readonly bindArtifact: ApsSlotMountBinding['bindArtifact']; + readonly hostPositions: ArtifactHostPositionLeaseRegistry; readonly isBindingCurrent: () => boolean; readonly onArtifactTransferred: () => void; } @@ -225,6 +239,7 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole !directRenderDocument || !directIframePrototype || typeof documentCreateElementIntrinsic !== 'function' || + typeof documentGetElementByIdIntrinsic !== 'function' || typeof nodeAppendChildIntrinsic !== 'function' || typeof nodeRemoveChildIntrinsic !== 'function' || typeof elementRemoveIntrinsic !== 'function' || @@ -239,6 +254,7 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole typeof elementLocalNameGetter !== 'function' || typeof elementNamespaceGetter !== 'function' || typeof elementChildrenGetter !== 'function' || + typeof elementIdGetter !== 'function' || typeof htmlCollectionLengthGetter !== 'function' || typeof iframeContentWindowGetter !== 'function' || typeof iframeSourceGetter !== 'function' @@ -247,6 +263,7 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole } let attempt: RenderAttempt; + let bindArtifactGuard: DirectApsAttemptOptions['bindArtifactGuard']; let bootstrapNonces: BootstrapNonceRegistry; let rendererNonces: RendererNonceRegistry; let messaging: MessagingAdapter; @@ -260,10 +277,14 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole let ownerDocument: Document; let mode: ApsTopPageAttemptOptions['mode']; let baseArtifact: CommittedRenderArtifact | undefined; + let bindArtifact: ApsSlotMountBinding['bindArtifact'] | undefined; + let hostPositions: ArtifactHostPositionLeaseRegistry | undefined; let isBindingCurrent: () => boolean; let onArtifactTransferred: () => void; + let expectedContainerId: string | undefined; try { attempt = options.attempt; + bindArtifactGuard = options.bindArtifactGuard; bootstrapNonces = options.bootstrapNonces; rendererNonces = options.nonces; messaging = options.messaging; @@ -277,9 +298,15 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole ownerDocument = Reflect.apply(nodeOwnerDocumentGetter, container, []) as Document; mode = options.mode; baseArtifact = options.mode === 'puc_overlay' ? options.baseArtifact : undefined; + bindArtifact = options.mode === 'puc_overlay' ? options.bindArtifact : undefined; + hostPositions = options.mode === 'puc_overlay' ? options.hostPositions : undefined; isBindingCurrent = options.mode === 'puc_overlay' ? options.isBindingCurrent : () => true; onArtifactTransferred = options.mode === 'puc_overlay' ? options.onArtifactTransferred : () => undefined; + expectedContainerId = + options.mode === 'puc_overlay' + ? (Reflect.apply(elementIdGetter, container, []) as string) + : undefined; } catch { return false; } @@ -294,7 +321,12 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole } const renderer = prepareApsRenderSource(sourceCandidate, publisherOrigin); const rendererUrl = resolveApsRendererV1Url(publisherOrigin); - if (!exactDocumentOrigin || !renderer || !rendererUrl) { + if ( + !exactDocumentOrigin || + !renderer || + !rendererUrl || + (mode === 'puc_overlay' && !expectedContainerId) + ) { try { attempt.fail('winner_not_renderable'); } catch { @@ -490,9 +522,7 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole let insertionPredecessors: readonly Element[] = []; let baseArtifactDisposed = false; let baseArtifactTransferred = false; - let hostPositionOwned = false; - let previousHostPosition = ''; - let previousHostPositionPriority = ''; + let hostPositionBound = false; const expectedFrameSource = rendererUrl + '#' + bootstrapNonce; const disposeBaseArtifact = (): void => { @@ -506,18 +536,8 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole }; const restoreHostPosition = (): void => { - if (!hostPositionOwned) return; - hostPositionOwned = false; try { - const style = container.style; - if ( - style.getPropertyValue('position') !== 'relative' || - style.getPropertyPriority('position') !== '' - ) { - return; - } - if (previousHostPosition === '') style.removeProperty('position'); - else style.setProperty('position', previousHostPosition, previousHostPositionPriority); + hostPositions?.release(artifact); } catch { // Compare-owned style restoration cannot expand into publisher styles. } @@ -544,8 +564,18 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole } }; + let artifactBinding: + | Readonly<{ + commit: () => boolean; + finalize: () => void; + isCurrent: () => boolean; + previousArtifact: CommittedRenderArtifact | undefined; + release: () => void; + rollback: () => void; + }> + | undefined; const artifact: CommittedRenderArtifact = freeze({ - kind: mode === 'direct' ? ('direct_iframe' as const) : ('puc' as const), + kind: 'aps_mount' as const, attemptId, slot: attemptSlot, navigationGeneration, @@ -565,10 +595,53 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole // DOM removal remains best-effort under a hostile publisher realm. } restoreHostPosition(); + artifactBinding?.release(); disposeBaseArtifact(); }, }); + const persistentFrameCurrent = (): boolean => { + try { + return ( + insertionCommitted && + !disposed && + Reflect.apply(nodeOwnerDocumentGetter, iframe, []) === ownerDocument && + Reflect.apply(nodeParentNodeGetter, iframe, []) === container && + Reflect.apply(nodeIsConnectedGetter, iframe, []) === true && + (mode !== 'puc_overlay' || + (Reflect.apply(elementIdGetter, container, []) === expectedContainerId && + Reflect.apply(documentGetElementByIdIntrinsic, ownerDocument, [expectedContainerId]) === + container)) && + Reflect.apply(iframeContentWindowGetter, iframe, []) === frameSource && + Reflect.apply(elementGetAttributeIntrinsic, iframe, ['src']) === expectedFrameSource && + Reflect.apply(iframeSourceGetter, iframe, []) === expectedFrameSource && + Reflect.apply(elementGetAttributeIntrinsic, iframe, ['sandbox']) === + APS_PERMANENT_SANDBOX && + (mode !== 'puc_overlay' || + !hostPositionBound || + hostPositions?.current(artifact) === true) && + (mode !== 'puc_overlay' || artifactBinding?.isCurrent() === true) + ); + } catch { + return false; + } + }; + if (!bindArtifactGuard(artifact, persistentFrameCurrent)) { + artifact.dispose(); + return fail('internal_error'); + } + if (mode === 'puc_overlay') { + try { + artifactBinding = bindArtifact?.(artifact); + } catch { + artifactBinding = undefined; + } + if (!artifactBinding) { + artifact.dispose(); + return fail('slot_unresolved'); + } + } + const startupFailure = (reason: RenderFailureReason): false => { if (!artifactOwnedByAttempt) artifact.dispose(); return fail(reason); @@ -615,15 +688,35 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole const view = ownerDocument.defaultView; if (!view) return false; const computed = view.getComputedStyle(container); - if (computed.position !== 'static') return true; + if (computed.position !== 'static') { + const previousArtifact = artifactBinding?.previousArtifact; + hostPositionBound = Boolean( + previousArtifact && hostPositions?.inherit(artifact, previousArtifact, container) + ); + return true; + } const style = container.style; - previousHostPosition = style.getPropertyValue('position'); - previousHostPositionPriority = style.getPropertyPriority('position'); + const previousPosition = style.getPropertyValue('position'); + const previousPriority = style.getPropertyPriority('position'); style.setProperty('position', 'relative'); - hostPositionOwned = - style.getPropertyValue('position') === 'relative' && - style.getPropertyPriority('position') === ''; - return hostPositionOwned && isBindingCurrent(); + if ( + style.getPropertyValue('position') !== 'relative' || + style.getPropertyPriority('position') !== '' + ) { + return false; + } + hostPositionBound = + hostPositions?.bindOwned(artifact, container, previousPosition, previousPriority) === true; + return hostPositionBound && isBindingCurrent(); + } catch { + return false; + } + }; + + const claimOverlayPositionLease = (): boolean => { + if (mode !== 'puc_overlay' || !hostPositionBound) return true; + try { + return isBindingCurrent() && hostPositions?.claim(artifact) === true; } catch { return false; } @@ -741,12 +834,38 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole return; } } - if ( - Reflect.apply(acceptMethod, attempt, []) === true && - !disposed && - exactFrameBinding(true) - ) { - if (mode === 'direct') commitContainer(insertionPredecessors); + let artifactAssociationCommitted = false; + if (mode === 'puc_overlay') { + try { + artifactAssociationCommitted = artifactBinding?.commit() === true; + } catch { + artifactAssociationCommitted = false; + } + if (!artifactAssociationCommitted) { + fail('slot_unresolved'); + return; + } + if (!claimOverlayPositionLease()) { + artifactBinding?.rollback(); + fail('slot_unresolved'); + return; + } + } + const accepted = (() => { + try { + return Reflect.apply(acceptMethod, attempt, []) === true; + } catch { + return false; + } + })(); + if (!accepted) { + if (artifactAssociationCommitted) artifactBinding?.rollback(); + if (!disposed) fail('internal_error'); + return; + } + if (artifactAssociationCommitted) artifactBinding?.finalize(); + if (!disposed && exactFrameBinding(true) && mode === 'direct') { + commitContainer(insertionPredecessors); } return; } 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 41f0bf9a7..215008c6b 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -30,6 +30,7 @@ import type { RuntimeCapabilityV1 } from '../../kernel/runtime'; import type { NavigationSession, RenderAttemptScope, RuntimeSession } from '../../kernel/sessions'; import type { IdentityGenerationResult } from '../../kernel/identity'; import type { + CommittedArtifactStore, CommittedRenderArtifact, RenderAttempt, RenderAttemptCreationResult, @@ -267,7 +268,13 @@ export function adoptInitialGptFactsFromHandoff( export function adoptInitialGptSlotsFromHandoff( candidate: unknown, navigationGeneration: object, - service: Pick + service: Pick< + SlotService, + 'adoptCommittedArtifact' | 'adoptGptSlot' | 'adoptRegistrationHighWater' + >, + artifactStore: Pick, + targeting: Pick, + adapter: GoogletagAdapter ): PersistentFirstDisplayAdoptionV1 | undefined { const adoption = snapshotPersistentFirstDisplayAdoptionV1(candidate); if (!adoption) return undefined; @@ -304,12 +311,14 @@ export function adoptInitialGptSlotsFromHandoff( const domId = dataField(placement, 'domId'); const gamPath = dataField(placement, 'gamPath'); const formats = frozenArray(dataField(placement, 'formats')); + const targetingOwnership = frozenArray(dataField(placement, 'targetingOwnership')); if ( typeof slotId !== 'string' || adopted.has(slotId) || (owner !== 'publisher' && owner !== 'trusted_server') || typeof identity !== 'object' || identity === null || + !targetingOwnership || (owner === 'trusted_server' && (typeof domId !== 'string' || typeof gamPath !== 'string' || !formats)) ) { @@ -329,6 +338,75 @@ export function adoptInitialGptSlotsFromHandoff( : { ownership: owner, slot: identity }; const result = service.adoptGptSlot(navigationGeneration, slotId, binding); if (!result.ok) return undefined; + const artifact = artifactStore.current(slotId); + if (!artifact) return undefined; + const releases: TargetingOwnership[] = []; + let observation: ReturnType | undefined; + const releaseTargeting = (): void => { + for (let releaseIndex = releases.length - 1; releaseIndex >= 0; releaseIndex -= 1) { + try { + releases[releaseIndex]?.release(); + } catch { + // Exact artifact retirement contains hostile GPT targeting cleanup. + } + } + releases.length = 0; + try { + observation?.dispose(); + } catch { + // Adapter observation cleanup cannot restore retired ownership. + } + observation = undefined; + }; + try { + if (targetingOwnership.length > 0) { + observation = targeting.observePublisherMutations(identity, adapter); + if (observation.status !== 'present') { + releaseTargeting(); + return undefined; + } + const boundary = synchronousTargetingBoundary(adapter, identity); + for ( + let ownershipIndex = 0; + ownershipIndex < targetingOwnership.length; + ownershipIndex += 1 + ) { + const ownership = targetingOwnership[ownershipIndex]; + const key = dataField(ownership, 'key'); + const installed = dataField(ownership, 'installed'); + const prior = frozenArray(dataField(ownership, 'prior')); + if ( + typeof key !== 'string' || + typeof installed !== 'string' || + !prior || + prior.some((value) => typeof value !== 'string') + ) { + releaseTargeting(); + return undefined; + } + const adopted = targeting.adopt( + identity, + key, + installed, + prior as readonly string[], + artifact.attemptId, + boundary + ); + if (!adopted) { + releaseTargeting(); + return undefined; + } + releases.push(adopted); + } + } + } catch { + releaseTargeting(); + return undefined; + } + if (!service.adoptCommittedArtifact(navigationGeneration, slotId, artifact, releaseTargeting)) { + releaseTargeting(); + return undefined; + } adopted.add(slotId); } return adoption; @@ -428,6 +506,10 @@ interface ProductionRenderCapability { current: (slot: string) => CommittedRenderArtifact | undefined; release: (artifact: CommittedRenderArtifact) => boolean; }>; + readonly bindArtifactRetirement: ( + artifact: CommittedRenderArtifact, + retire: () => void + ) => boolean; readonly createAttempt: ( owner: RenderAttemptScope, parentAttemptId?: string @@ -1596,6 +1678,7 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg typeof runtime.protectFirstDisplayAttemptBatch !== 'function' || typeof slotCapability.attachPhysicalService !== 'function' || typeof render.attachPucGamAttemptRegistrar !== 'function' || + typeof render.bindArtifactRetirement !== 'function' || typeof render.createAttempt !== 'function' || typeof render.createSlotOperation !== 'function' || typeof render.commitPageBids !== 'function' || @@ -1625,9 +1708,10 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg document.defaultView.MutationObserver ); const slots = createSlotService({ - disposeCommittedArtifact: (navigationGeneration, registeredSlotId) => { + bindCommittedArtifactRetirement: render.bindArtifactRetirement, + disposeCommittedArtifact: (navigationGeneration, registeredSlotId, expectedArtifact) => { const artifact = render.artifacts.current(registeredSlotId); - if (artifact?.navigationGeneration === navigationGeneration) + if (artifact === expectedArtifact && artifact.navigationGeneration === navigationGeneration) render.artifacts.release(artifact); }, googletag, @@ -1849,7 +1933,10 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg : adoptInitialGptSlotsFromHandoff( adoptionCandidate, auction.navigation.generation, - slots + slots, + render.artifacts, + targeting, + googletag ); if (adoptionCandidate !== undefined && (!diagnosticsAdoption || !adoption)) { throw new TypeError('GPT first-display adoption is invalid'); diff --git a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts index 305437e1c..ae0dbdff6 100644 --- a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts @@ -58,12 +58,16 @@ import { import { createReservationService, type ReservationService } from '../../services/reservations'; import type { PucGamAttemptInput } from '../../services/puc_bridge'; import { + bindCommittedArtifactGuard, + bindCommittedArtifactRetirement, + createArtifactHostPositionLeaseRegistry, createCommittedArtifactStore, createBootstrapNonceRegistry, createRenderAttempt, createRendererNonceRegistry, createSlotOperation, renderDirectAdmAttempt, + type ArtifactHostPositionLeaseRegistry, type CommittedArtifactStore, type CommittedRenderArtifact, type RenderAttempt, @@ -271,46 +275,148 @@ export function adoptInitialRenderArtifactsFromHandoff( candidate: unknown, navigation: NavigationSession, store: CommittedArtifactStore, + hostPositions: ArtifactHostPositionLeaseRegistry, document: Document ): AdoptedInitialRenderArtifactsV1 | undefined { const adoption = snapshotPersistentFirstDisplayAdoptionV1(candidate); if (!adoption) return undefined; const cycles = adoptionArray(adoptionField(adoption.handoff, 'cycles')); const artifacts = adoptionArray(adoptionField(adoption.handoff, 'artifacts')); - if (!cycles || !artifacts || adoption.identities.length !== cycles.length + artifacts.length) { + const handoffSlots = adoptionArray(adoptionField(adoption.handoff, 'slots')); + if ( + !cycles || + !artifacts || + !handoffSlots || + adoption.identities.length !== cycles.length + artifacts.length || + new Set(adoption.identities).size !== adoption.identities.length + ) { return undefined; } if (artifacts.length === 0) { return Object.freeze({ adoption, arm: () => undefined }); } const Frame = document.defaultView?.HTMLIFrameElement; + const Element = document.defaultView?.HTMLElement; const batch = navigation.createAuctionBatch('first-display-adoption'); - if (!Frame || !batch) return undefined; + if (!Frame || !Element || !batch) return undefined; const promoted: CommittedRenderArtifact[] = []; let armed = false; try { for (let index = 0; index < artifacts.length; index += 1) { const record = artifacts[index]; const slotId = adoptionField(record, 'slotId'); + const kind = adoptionField(record, 'kind'); + const owner = adoptionField(record, 'owner'); + const token = adoptionField(record, 'token'); + const hostPosition = adoptionField(record, 'hostPosition'); + const hostPositionPriority = adoptionField(record, 'hostPositionPriority'); const identity = adoption.identities[cycles.length + index]; - if (typeof slotId !== 'string' || !(identity instanceof Frame)) return undefined; + const matchingSlots = handoffSlots.filter((entry) => adoptionField(entry, 'id') === slotId); + const matchingCycles = cycles.filter((entry) => adoptionField(entry, 'slotId') === slotId); + const domId = adoptionField(matchingSlots[0], 'domId'); + const host = typeof domId === 'string' ? document.getElementById(domId) : null; + if ( + typeof slotId !== 'string' || + (kind !== 'aps' && kind !== 'gpt_adm') || + owner !== 'trusted_server' || + typeof token !== 'string' || + !/^r1_[A-Za-z0-9_-]{22}$/.test(token) || + (hostPosition === null && hostPositionPriority !== null) || + (hostPosition !== null && + (typeof hostPosition !== 'string' || + (hostPositionPriority !== '' && hostPositionPriority !== 'important'))) || + (kind === 'gpt_adm' && hostPosition !== null) || + matchingSlots.length !== 1 || + matchingCycles.length !== 1 || + !(host instanceof Element) || + !(identity instanceof Frame) + ) { + return undefined; + } + const previousHostPosition = hostPosition as string | null; + const previousHostPositionPriority = hostPositionPriority as '' | 'important' | null; + const frame = identity; + const expectedContentWindow = frame.contentWindow; + const expectedSourceAttribute = frame.getAttribute('src'); + const expectedSource = frame.src; + const expectedSourceDocumentAttribute = frame.getAttribute('srcdoc'); + const expectedSourceDocument = frame.srcdoc; + const expectedSandbox = frame.getAttribute('sandbox'); + const expectedStyle = frame.getAttribute('style'); + if ( + !host.isConnected || + host.ownerDocument !== document || + frame.parentNode !== host || + !frame.isConnected || + frame.ownerDocument !== document || + !expectedContentWindow || + (kind === 'aps' && + hostPosition !== null && + (host.style.getPropertyValue('position') !== 'relative' || + host.style.getPropertyPriority('position') !== '')) + ) { + return undefined; + } const attempt = batch.createRenderAttempt(slotId); if (!attempt.ok) return undefined; - const frame = identity; + let disposed = false; const artifact: CommittedRenderArtifact = Object.freeze({ - kind: 'direct_iframe' as const, + kind: kind === 'aps' ? ('aps_mount' as const) : ('direct_iframe' as const), attemptId: attempt.value.id, slot: slotId, navigationGeneration: navigation.generation, dispose: (): void => { + if (disposed) return; + disposed = true; if (!armed) return; try { frame.remove(); } catch { // A publisher DOM replacement wins over persistent artifact retirement. } + hostPositions.release(artifact); }, }); + if ( + kind === 'aps' && + previousHostPosition !== null && + !hostPositions.bindOwned( + artifact, + host, + previousHostPosition, + previousHostPositionPriority ?? '' + ) + ) { + return undefined; + } + if ( + !bindCommittedArtifactGuard(artifact, () => { + try { + return ( + !disposed && + navigation.isCurrent() && + document.getElementById(domId as string) === host && + host.isConnected && + host.ownerDocument === document && + frame.parentNode === host && + frame.isConnected && + frame.ownerDocument === document && + frame.contentWindow === expectedContentWindow && + frame.getAttribute('src') === expectedSourceAttribute && + frame.src === expectedSource && + frame.getAttribute('srcdoc') === expectedSourceDocumentAttribute && + frame.srcdoc === expectedSourceDocument && + frame.getAttribute('sandbox') === expectedSandbox && + frame.getAttribute('style') === expectedStyle && + (kind !== 'aps' || previousHostPosition === null || hostPositions.current(artifact)) + ); + } catch { + return false; + } + }) + ) { + return undefined; + } if (!store.promote(artifact, () => navigation.isCurrent())) return undefined; promoted.push(artifact); } @@ -626,6 +732,21 @@ export function createRenderRuntimeIntegrationRegistration( const artifacts = createCommittedArtifactStore(); scope.onDispose(() => artifacts.dispose()); + const hostPositions = createArtifactHostPositionLeaseRegistry(); + const ArtifactMutationObserver = view.MutationObserver; + if (typeof ArtifactMutationObserver !== 'function') { + throw new TypeError('render_runtime requires artifact DOM observation'); + } + const artifactObserver = new ArtifactMutationObserver(() => { + artifacts.sweep(); + }); + artifactObserver.observe(document, { + attributeFilter: ['id', 'sandbox', 'src', 'srcdoc', 'style'], + attributes: true, + childList: true, + subtree: true, + }); + scope.onDispose(() => artifactObserver.disconnect()); const slots = createRuntimeSlotBroker(); scope.onDispose(() => slots.dispose()); @@ -988,7 +1109,10 @@ export function createRenderRuntimeIntegrationRegistration( }; }, artifacts, + bindArtifactGuard: bindCommittedArtifactGuard, + bindArtifactRetirement: bindCommittedArtifactRetirement, bootstrapNonces, + hostPositions, createAttempt, createSlotOperation, commitPageBids: ( @@ -1085,6 +1209,7 @@ export function createRenderRuntimeIntegrationRegistration( activation.adoption, navigation, artifacts, + hostPositions, document ); if (activation.adoption !== undefined && (!adoptedState || !adoptedArtifacts)) { 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 09d39050f..078909c8d 100644 --- a/crates/trusted-server-js/lib/src/services/puc_bridge.ts +++ b/crates/trusted-server-js/lib/src/services/puc_bridge.ts @@ -9,7 +9,7 @@ import type { RenderFailureReason, RenderOutcome, } from './render'; -import type { SlotService } from './slots'; +import type { ApsSlotMountBinding, SlotService } from './slots'; import type { ReservationAttempt, ReservationRecognition, @@ -110,6 +110,7 @@ export interface PucBridgeOptions { export interface PucApsMountInput { readonly attempt: RenderAttempt; readonly baseArtifact: CommittedRenderArtifact; + readonly bindArtifact: ApsSlotMountBinding['bindArtifact']; readonly container: HTMLElement; readonly isBindingCurrent: () => boolean; readonly messaging: MessagingAdapter; @@ -1164,6 +1165,7 @@ export function createPucBridge(options: PucBridgeOptions): PucBridge { { attempt: binding.attempt as unknown as RenderAttempt, baseArtifact: binding.artifact, + bindArtifact: mountBinding.bindArtifact, container: mountBinding.element as HTMLElement, isBindingCurrent: mountBinding.isCurrent, messaging, diff --git a/crates/trusted-server-js/lib/src/services/render.ts b/crates/trusted-server-js/lib/src/services/render.ts index 6c3bbc123..de86d784c 100644 --- a/crates/trusted-server-js/lib/src/services/render.ts +++ b/crates/trusted-server-js/lib/src/services/render.ts @@ -74,6 +74,8 @@ const stringTrimIntrinsic = String.prototype.trim; const textEncoder = new TextEncoder(); const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; const artifactDisposals = new WeakMap(); +const artifactGuards = new WeakMap boolean>(); +const artifactRetirements = new WeakMap void>(); const committedArtifactStores = new WeakSet(); const renderAttempts = new WeakSet(); const ignoreAsyncDisposal = (): void => undefined; @@ -325,7 +327,7 @@ export type RenderAttemptState = RenderAttemptActiveState | 'accepted' | 'no_bid' | 'failed' | 'cancelled'; export interface CommittedRenderArtifact { - readonly kind: 'direct_iframe' | 'puc'; + readonly kind: 'aps_mount' | 'direct_iframe' | 'puc'; readonly attemptId: string; readonly slot: string; readonly navigationGeneration: object; @@ -336,10 +338,172 @@ export interface CommittedArtifactStore { readonly promote: (artifact: CommittedRenderArtifact, stillCurrent?: () => boolean) => boolean; readonly current: (slot: string) => CommittedRenderArtifact | undefined; readonly release: (artifact: CommittedRenderArtifact) => boolean; + /** Retire every committed artifact whose exact DOM/binding guard is no longer current. */ + readonly sweep: () => number; readonly disposeNavigation: (navigationGeneration: object) => void; readonly dispose: () => void; } +/** Shared exact ownership for the inline position required by absolute render overlays. */ +export interface ArtifactHostPositionLeaseRegistry { + readonly bindOwned: ( + artifact: CommittedRenderArtifact, + host: HTMLElement, + previousPosition: string, + previousPriority: string + ) => boolean; + readonly inherit: ( + artifact: CommittedRenderArtifact, + predecessor: CommittedRenderArtifact, + host: HTMLElement + ) => boolean; + readonly claim: (artifact: CommittedRenderArtifact) => boolean; + readonly current: (artifact: CommittedRenderArtifact) => boolean; + readonly release: (artifact: CommittedRenderArtifact) => void; +} + +interface ArtifactHostPositionLease { + readonly host: HTMLElement; + owner: CommittedRenderArtifact | undefined; + readonly previousPosition: string; + readonly previousPriority: string; +} + +interface ArtifactHostPositionBinding { + live: boolean; + readonly lease: ArtifactHostPositionLease; + readonly predecessor?: CommittedRenderArtifact; +} + +/** Create one render-runtime lease registry shared by adoption and later APS mounts. */ +export function createArtifactHostPositionLeaseRegistry(): ArtifactHostPositionLeaseRegistry { + const bindings = new WeakMap(); + + const styleIsOwned = (host: HTMLElement): boolean => { + try { + return ( + host.style.getPropertyValue('position') === 'relative' && + host.style.getPropertyPriority('position') === '' + ); + } catch { + return false; + } + }; + + const restore = (lease: ArtifactHostPositionLease): void => { + try { + if (!styleIsOwned(lease.host)) return; + if (lease.previousPosition === '') lease.host.style.removeProperty('position'); + else { + lease.host.style.setProperty('position', lease.previousPosition, lease.previousPriority); + } + } catch { + // Compare-owned restoration cannot overwrite a publisher style replacement. + } + }; + + const registry: ArtifactHostPositionLeaseRegistry = { + bindOwned(artifact, host, previousPosition, previousPriority): boolean { + try { + if ( + !validArtifact(artifact) || + bindings.has(artifact) || + !styleIsOwned(host) || + typeof previousPosition !== 'string' || + typeof previousPriority !== 'string' + ) { + return false; + } + const lease: ArtifactHostPositionLease = { + host, + owner: artifact, + previousPosition, + previousPriority, + }; + const binding: ArtifactHostPositionBinding = { lease, live: true }; + bindings.set(artifact, binding); + return bindings.get(artifact) === binding; + } catch { + return false; + } + }, + inherit(artifact, predecessor, host): boolean { + try { + if (!validArtifact(artifact) || bindings.has(artifact) || !styleIsOwned(host)) return false; + const previous = bindings.get(predecessor); + if ( + !previous?.live || + previous.lease.host !== host || + previous.lease.owner !== predecessor + ) { + return false; + } + const binding: ArtifactHostPositionBinding = { + lease: previous.lease, + live: true, + predecessor, + }; + bindings.set(artifact, binding); + return bindings.get(artifact) === binding; + } catch { + return false; + } + }, + claim(artifact): boolean { + try { + const binding = bindings.get(artifact); + if (!binding?.live || !styleIsOwned(binding.lease.host)) return false; + if (binding.lease.owner === artifact) return true; + const predecessor = binding.predecessor; + const previous = predecessor ? bindings.get(predecessor) : undefined; + if ( + !predecessor || + !previous?.live || + previous.lease !== binding.lease || + binding.lease.owner !== predecessor + ) { + return false; + } + binding.lease.owner = artifact; + return true; + } catch { + return false; + } + }, + current(artifact): boolean { + try { + const binding = bindings.get(artifact); + return ( + !binding || + (binding.live && binding.lease.owner === artifact && styleIsOwned(binding.lease.host)) + ); + } catch { + return false; + } + }, + release(artifact): void { + let binding: ArtifactHostPositionBinding | undefined; + try { + binding = bindings.get(artifact); + if (!binding?.live) return; + binding.live = false; + if (binding.lease.owner !== artifact) return; + const predecessor = binding.predecessor; + const previous = predecessor ? bindings.get(predecessor) : undefined; + if (predecessor && previous?.live && previous.lease === binding.lease) { + binding.lease.owner = predecessor; + return; + } + binding.lease.owner = undefined; + } catch { + return; + } + restore(binding.lease); + }, + }; + return frozen(registry); +} + export interface RenderDeadline { readonly milliseconds: number; readonly reason: RenderFailureReason; @@ -633,7 +797,9 @@ function validArtifact( } const artifact = value as CommittedRenderArtifact; return ( - (artifact.kind === 'direct_iframe' || artifact.kind === 'puc') && + (artifact.kind === 'aps_mount' || + artifact.kind === 'direct_iframe' || + artifact.kind === 'puc') && validAttemptId(artifact.attemptId) && typeof artifact.slot === 'string' && artifact.slot.length > 0 && @@ -651,6 +817,56 @@ function validArtifact( } } +/** Bind one immutable artifact to one exact synchronous DOM/binding guard. */ +export function bindCommittedArtifactGuard( + artifact: CommittedRenderArtifact, + current: () => boolean +): boolean { + try { + if ( + !validArtifact(artifact) || + typeof current !== 'function' || + weakMapHas(artifactGuards, artifact) + ) { + return false; + } + weakMapSet(artifactGuards, artifact, current); + return weakMapGet(artifactGuards, artifact) === current; + } catch { + return false; + } +} + +/** Bind one exact metadata-release callback to a committed artifact's disposal latch. */ +export function bindCommittedArtifactRetirement( + artifact: CommittedRenderArtifact, + retire: () => void +): boolean { + try { + if ( + !validArtifact(artifact) || + typeof retire !== 'function' || + weakMapHas(artifactRetirements, artifact) || + artifactDisposalStarted(artifact) + ) { + return false; + } + weakMapSet(artifactRetirements, artifact, retire); + return weakMapGet(artifactRetirements, artifact) === retire; + } catch { + return false; + } +} + +function guardedArtifactCurrent(artifact: CommittedRenderArtifact): boolean { + try { + const guard = weakMapGet(artifactGuards, artifact); + return !guard || Reflect.apply(guard, undefined, []) === true; + } catch { + return false; + } +} + function disposeArtifact(artifact: CommittedRenderArtifact | undefined): boolean { if (!artifact) return true; try { @@ -661,6 +877,16 @@ function disposeArtifact(artifact: CommittedRenderArtifact | undefined): boolean } catch { return false; } + let retirementSucceeded = true; + try { + const retire = weakMapGet(artifactRetirements, artifact); + if (retire) { + weakMapDelete(artifactRetirements, artifact); + Reflect.apply(retire, undefined, []); + } + } catch { + retirementSucceeded = false; + } try { const result = Reflect.apply(artifact.dispose, artifact, []) as unknown; if ((typeof result === 'object' || typeof result === 'function') && result !== null) { @@ -687,7 +913,7 @@ function disposeArtifact(artifact: CommittedRenderArtifact | undefined): boolean } if (result !== undefined) return false; weakMapSet(artifactDisposals, artifact, true); - return true; + return retirementSucceeded; } catch { return false; } @@ -766,6 +992,7 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { mutating || !validArtifact(artifact) || artifactDisposalStarted(artifact) || + !guardedArtifactCurrent(artifact) || weakSetHas(disposedNavigations, artifact.navigationGeneration) || !permitsPromotion(stillCurrent) ) { @@ -780,7 +1007,8 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { disposeRequested || weakSetHas(disposedNavigations, artifact.navigationGeneration) || setHas(pendingNavigationDisposals, artifact.navigationGeneration) || - !permitsPromotion(stillCurrent) + !permitsPromotion(stillCurrent) || + !guardedArtifactCurrent(artifact) ) { return false; } @@ -794,6 +1022,7 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { weakSetHas(disposedNavigations, artifact.navigationGeneration) || setHas(pendingNavigationDisposals, artifact.navigationGeneration) || !permitsPromotion(stillCurrent) || + !guardedArtifactCurrent(artifact) || artifactDisposalStarted(artifact) ) { if (existing && mapGet(entries, artifact.slot) === existing) { @@ -842,6 +1071,34 @@ export function createCommittedArtifactStore(): CommittedArtifactStore { } } }, + sweep(): number { + let retired = 0; + let ownsMutation = false; + try { + if (disposed || mutating) return 0; + mutating = true; + ownsMutation = true; + const snapshot = mapEntrySnapshot(entries); + for (let index = 0; index < snapshot.length; index += 1) { + const entry = snapshot[index]; + if (!entry) continue; + const slot = entry[0]; + const artifact = entry[1]; + if (mapGet(entries, slot) !== artifact || guardedArtifactCurrent(artifact)) continue; + mapDelete(entries, slot); + disposeArtifact(artifact); + retired += 1; + } + return retired; + } catch { + return retired; + } finally { + if (ownsMutation) { + mutating = false; + drainDeferredDisposal(); + } + } + }, disposeNavigation(navigationGeneration): void { let ownsMutation = false; try { @@ -1383,7 +1640,12 @@ export function createRenderAttempt(options: RenderAttemptOptions): RenderAttemp const sourceType = admittedRenderSource?.type; const sourceMatches = next === 'waiting_for_document' ? sourceType === 'aps' : sourceType === 'adm'; - const artifactKind = state === 'rendering_direct' ? 'direct_iframe' : 'puc'; + const artifactKind = + next === 'waiting_for_document' + ? 'aps_mount' + : state === 'rendering_direct' + ? 'direct_iframe' + : 'puc'; if ( pendingArtifact !== undefined || !validArtifact(artifact, slot, navigationGeneration, id) || diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 42f86c917..63e23e66c 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -25,6 +25,7 @@ import type { ProjectionSlotRegistration, ProjectionSlotRegistry, } from './projections'; +import type { CommittedRenderArtifact } from './render'; /** Shared maximum across server-projected and programmatically admitted slots. */ export const MAX_ACTIVE_SLOT_RECORDS = 256; @@ -146,6 +147,16 @@ export interface ApsSlotMountBinding { readonly element: object; readonly physicalSlot: object; readonly isCurrent: () => boolean; + readonly bindArtifact: (artifact: CommittedRenderArtifact) => + | Readonly<{ + commit: () => boolean; + finalize: () => void; + isCurrent: () => boolean; + previousArtifact: CommittedRenderArtifact | undefined; + release: () => void; + rollback: () => void; + }> + | undefined; } /** Runtime-owned slot registry and physical-cycle boundary. */ @@ -161,6 +172,12 @@ export interface SlotService { navigationGeneration: object, nextOrdinal: number ) => boolean; + readonly adoptCommittedArtifact: ( + navigationGeneration: object, + registeredSlotId: string, + artifact: CommittedRenderArtifact, + onRetire?: () => void + ) => boolean; readonly dispose: () => void; readonly handleGptEvent: ( type: GptEventType, @@ -215,9 +232,14 @@ export interface SlotService { } export interface SlotServiceOptions { + readonly bindCommittedArtifactRetirement?: ( + artifact: CommittedRenderArtifact, + retire: () => void + ) => boolean; readonly disposeCommittedArtifact?: ( navigationGeneration: object, - registeredSlotId: string + registeredSlotId: string, + artifact: CommittedRenderArtifact ) => void; readonly googletag: GoogletagAdapter; readonly now?: () => number; @@ -270,7 +292,7 @@ interface PhysicalCycle { interface PhysicalSlot { activeCycle: PhysicalCycle | undefined; apsMountClaim: Readonly<{ cycle: PhysicalCycle; token: object }> | undefined; - artifactRetirementAttempted: boolean; + committedArtifact: CommittedRenderArtifact | undefined; bindingEpoch: object; definition: GoogletagReplacementDefinition | undefined; domElement: object | undefined; @@ -736,6 +758,7 @@ export function createBrowserSlotReconciliationBoundary( /** Construct the document-lifetime slot registry and physical GPT cycle service. */ export function createSlotService(options: SlotServiceOptions): SlotService { + const bindCommittedArtifactRetirement = options.bindCommittedArtifactRetirement; const navigationStates = new Map(); const registeredSlots = new Map(); const adUnitCodes = new Map>(); @@ -1030,10 +1053,15 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; const retireCommittedArtifact = (record: InternalSlotRecord, physical: PhysicalSlot): void => { - if (physical.artifactRetirementAttempted) return; - physical.artifactRetirementAttempted = true; + const artifact = physical.committedArtifact; + if (!artifact) return; + physical.committedArtifact = undefined; try { - disposeCommittedArtifact?.(record.state.owner.generation, record.view.registeredSlotId); + disposeCommittedArtifact?.( + record.state.owner.generation, + record.view.registeredSlotId, + artifact + ); } catch { // Physical retirement remains authoritative when artifact cleanup throws. } @@ -1061,7 +1089,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const physical: PhysicalSlot = { activeCycle: undefined, apsMountClaim: undefined, - artifactRetirementAttempted: false, + committedArtifact: undefined, bindingEpoch: Object.freeze({}), definition, domElement, @@ -1131,7 +1159,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const orphan: PhysicalSlot = { activeCycle: undefined, apsMountClaim: undefined, - artifactRetirementAttempted: true, + committedArtifact: undefined, bindingEpoch: Object.freeze({}), definition: source.definition, domElement: undefined, @@ -1506,10 +1534,11 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } return physical.activeCycle?.traceHandle; } - if (physical.state !== 'live' || physical.activeCycle) return; const record = physical.record; const intent = record?.activeIntent; if ( + physical.state === 'live' && + !physical.activeCycle && intent && !intent.terminal && intent.state === 'active' && @@ -1529,7 +1558,9 @@ export function createSlotService(options: SlotServiceOptions): SlotService { ); return cycle.traceHandle; } + if (record) retireCommittedArtifact(record, physical); if (intent && !intent.terminal) settle(intent, failed('cycle_unattributable')); + if (physical.state !== 'live' || physical.activeCycle) return; return openPhysicalCycle(physical, undefined, 'publisher').traceHandle; } @@ -2379,7 +2410,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { const physical: PhysicalSlot = { activeCycle: undefined, apsMountClaim: undefined, - artifactRetirementAttempted: false, + committedArtifact: undefined, bindingEpoch: Object.freeze({}), definition, domElement, @@ -2907,6 +2938,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { }; const applyPublisherIntent = (physical: PhysicalSlot): void => { + if (physical.record) retireCommittedArtifact(physical.record, physical); if (physical.record?.activeIntent) { settle(physical.record.activeIntent, failed('cycle_unattributable')); } @@ -3353,7 +3385,69 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (physical.apsMountClaim?.token === token) physical.apsMountClaim = undefined; return undefined; } + const bindArtifact = ( + artifact: CommittedRenderArtifact + ): + | Readonly<{ + commit: () => boolean; + finalize: () => void; + isCurrent: () => boolean; + previousArtifact: CommittedRenderArtifact | undefined; + release: () => void; + rollback: () => void; + }> + | undefined => { + try { + if ( + !current() || + !Object.isFrozen(artifact) || + artifact.kind !== 'aps_mount' || + artifact.slot !== registeredSlotId || + artifact.navigationGeneration !== navigationGeneration + ) { + return undefined; + } + const previous = physical.committedArtifact; + let phase: 'prepared' | 'committed' | 'finalized' | 'released' | 'rolled_back' = + 'prepared'; + return Object.freeze({ + commit: (): boolean => { + if (phase !== 'prepared' || !current() || physical.committedArtifact !== previous) { + return false; + } + physical.committedArtifact = artifact; + phase = 'committed'; + return true; + }, + finalize: (): void => { + if (phase === 'committed') phase = 'finalized'; + }, + isCurrent: (): boolean => + (phase === 'committed' || phase === 'finalized') && + physical.committedArtifact === artifact && + current(), + previousArtifact: previous, + release: (): void => { + if (phase === 'released' || phase === 'rolled_back') return; + if (physical.committedArtifact === artifact) { + physical.committedArtifact = phase === 'committed' ? previous : undefined; + } + phase = 'released'; + }, + rollback: (): void => { + if (phase !== 'committed' && phase !== 'released') return; + if (phase === 'committed' && physical.committedArtifact === artifact) { + physical.committedArtifact = previous; + } + phase = 'rolled_back'; + }, + }); + } catch { + return undefined; + } + }; return Object.freeze({ + bindArtifact, bindingEpoch, cycle: cycle.traceHandle, element: resolution.element, @@ -3365,12 +3459,61 @@ export function createSlotService(options: SlotServiceOptions): SlotService { } }; + const adoptCommittedArtifact = ( + navigationGeneration: object, + registeredSlotId: string, + artifact: CommittedRenderArtifact, + onRetire?: () => void + ): boolean => { + try { + const state = mapValue(navigationStates, navigationGeneration); + const record = state ? mapValue(state.records, registeredSlotId) : undefined; + const physical = record?.physical; + if ( + !state || + state.disposed || + !state.owner.isCurrent() || + !record || + !physical || + physical.record !== record || + physical.state !== 'live' || + physical.committedArtifact !== undefined || + !Object.isFrozen(artifact) || + (artifact.kind !== 'aps_mount' && artifact.kind !== 'direct_iframe') || + artifact.slot !== registeredSlotId || + artifact.navigationGeneration !== navigationGeneration || + (onRetire !== undefined && typeof onRetire !== 'function') + ) { + return false; + } + physical.committedArtifact = artifact; + if ( + !bindCommittedArtifactRetirement || + !bindCommittedArtifactRetirement(artifact, () => { + if (physical.committedArtifact === artifact) physical.committedArtifact = undefined; + try { + onRetire?.(); + } catch { + // Exact physical retirement remains authoritative over targeting cleanup failure. + } + }) + ) { + physical.committedArtifact = undefined; + return false; + } + return physical.committedArtifact === artifact; + } catch { + return false; + } + }; + const service: SlotService = Object.freeze({ activate: (): void => { if (reconciliationActive) return; activateReconciliation(); }, activateReconciliation, + adoptCommittedArtifact, start: (): GoogletagOperation => { if (activation) return activation; let subscriptions: BindingSubscriptionAdmission | undefined; diff --git a/crates/trusted-server-js/lib/src/services/targeting.ts b/crates/trusted-server-js/lib/src/services/targeting.ts index a00daa8f6..6107d6405 100644 --- a/crates/trusted-server-js/lib/src/services/targeting.ts +++ b/crates/trusted-server-js/lib/src/services/targeting.ts @@ -19,6 +19,14 @@ export interface TargetingInventorySnapshot { /** Owner-aware targeting operations exposed to render services. */ export interface TargetingService { + readonly adopt: ( + slot: object, + key: string, + installed: string, + predecessor: readonly string[], + ownerId: string, + targeting: TargetingBoundary + ) => TargetingOwnership | undefined; readonly dispose: () => void; readonly disposeOwner: (ownerId: string) => void; readonly invalidatePublisherMutation: (slot: object, key?: string) => void; @@ -138,6 +146,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 publisherMutationTokensBySlot = new WeakMap(); const observationsBySlot = new WeakMap(); const liveFrames = new Set(); const observationReleases = new Set<() => void>(); @@ -343,6 +352,7 @@ export function createTargetingService(): TargetingService { }; const invalidatePublisherMutation = (slot: object, key?: string): void => { + setWeakMapValue(publisherMutationTokensBySlot, slot, {}); const slotChains = weakMapValue(chainsBySlot, slot); if (!slotChains) return; if (key !== undefined) { @@ -365,6 +375,86 @@ export function createTargetingService(): TargetingService { for (const [entryKey, chain] of entries) invalidateChain(slot, slotChains, entryKey, chain); }; + const adopt = ( + slot: object, + key: string, + installed: string, + predecessor: readonly string[], + ownerId: string, + targeting: TargetingBoundary + ): TargetingOwnership | undefined => { + if (disposed) return undefined; + if ((typeof slot !== 'object' || slot === null) && typeof slot !== 'function') { + throw new TypeError('GPT slot object required'); + } + if (key.length === 0 || installed.length === 0 || ownerId.length === 0) { + throw new TypeError('Targeting key, value, and owner are required'); + } + const predecessorValues = copyValues(predecessor); + const mutationToken = weakMapValue(publisherMutationTokensBySlot, slot); + const actual = copyValues(targeting.getTargeting(key)); + if (weakMapValue(publisherMutationTokensBySlot, slot) !== mutationToken) return undefined; + if (!exactInstalledValue(actual, installed)) return undefined; + const observation = weakMapValue(observationsBySlot, slot); + if (observation && !observationIsCurrent(observation)) { + invalidatePublisherMutation(slot); + return undefined; + } + if (weakMapValue(publisherMutationTokensBySlot, slot) !== mutationToken) return undefined; + let slotChains = weakMapValue(chainsBySlot, slot); + if (slotChains && mapValue(slotChains, key)) return undefined; + if (!slotChains) slotChains = new Map(); + const chain: TargetingChain = { frames: [] }; + const frame: TargetingFrame = { + alive: true, + kind: 'frame', + predecessor: { kind: 'publisher', values: predecessorValues }, + boundary: targeting, + installed, + key, + observation, + ownerId, + slot, + }; + const wasNewSlot = weakMapValue(chainsBySlot, slot) === undefined; + let publishedWeakMap = false; + let publishedChain = false; + let publishedFrame = false; + try { + if (wasNewSlot) { + setWeakMapValue(chainsBySlot, slot, slotChains); + if (weakMapValue(chainsBySlot, slot) !== slotChains) throw new Error('journal publication'); + publishedWeakMap = true; + slotCount += 1; + } + setMapValue(slotChains, key, chain); + if (mapValue(slotChains, key) !== chain) throw new Error('journal publication'); + publishedChain = true; + chain.frames[0] = frame; + publishedFrame = true; + addLiveFrame(frame); + frameCount += 1; + } catch (error) { + if (publishedFrame) chain.frames.length = 0; + frame.alive = false; + if (deleteLiveFrame(frame) && frameCount > 0) frameCount -= 1; + if (publishedChain || mapValue(slotChains, key) === chain) deleteMapValue(slotChains, key); + if (weakMapValue(chainsBySlot, slot) === slotChains && mapSize(slotChains) === 0) { + deleteWeakMapValue(chainsBySlot, slot); + if (publishedWeakMap && slotCount > 0) slotCount -= 1; + } + throw error; + } + let released = false; + return Object.freeze({ + ownerId, + release: (): void => { + if (released) return; + released = release(frame); + }, + }); + }; + const own = ( slot: object, key: string, @@ -476,6 +566,7 @@ export function createTargetingService(): TargetingService { }; return Object.freeze({ + adopt, dispose: (): void => { if (disposed) return; disposed = true; diff --git a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts index 25f659679..4f091b494 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts @@ -296,6 +296,38 @@ function snapshotStringPairs( return Object.freeze(result); } +function snapshotTargetingOwnership( + value: unknown +): readonly Readonly<{ installed: string; key: string; prior: readonly string[] }>[] | undefined { + const entries = exactArray(value, MAX_TARGETING); + if (!entries) return undefined; + const seen = new Set(); + const result: Array> = []; + for (const entry of entries) { + const fields = exactRecord(entry, ['installed', 'key', 'prior']); + const prior = fields ? exactArray(fields.prior, MAX_TARGETING) : undefined; + if ( + !fields || + !boundedString(fields.key, MAX_PROPERTY_BYTES) || + !boundedString(fields.installed, MAX_STRING_BYTES) || + !prior || + prior.some((value) => !boundedString(value, MAX_STRING_BYTES, true)) || + seen.has(fields.key) + ) { + return undefined; + } + seen.add(fields.key); + result.push( + Object.freeze({ + installed: fields.installed, + key: fields.key, + prior: Object.freeze([...prior] as string[]), + }) + ); + } + return Object.freeze(result); +} + function snapshotSlot(value: unknown): Readonly> | undefined { const fields = exactRecord(value, [ 'id', @@ -306,6 +338,7 @@ function snapshotSlot(value: unknown): Readonly> | undef 'owner', 'outcome', 'targeting', + 'targetingOwnership', 'committedArtifact', 'gptToken', ]); @@ -329,6 +362,7 @@ function snapshotSlot(value: unknown): Readonly> | undef formats.push(Object.freeze([dimensions[0], dimensions[1]])); } const targeting = snapshotStringPairs(fields.targeting, MAX_TARGETING); + const targetingOwnership = snapshotTargetingOwnership(fields.targetingOwnership); if ( !boundedString(fields.id) || !aliases || @@ -337,6 +371,7 @@ function snapshotSlot(value: unknown): Readonly> | undef (fields.owner !== 'trusted_server' && fields.owner !== 'publisher') || !['accepted', 'no_bid', 'failed', 'cancelled'].includes(fields.outcome as string) || !targeting || + !targetingOwnership || !['none', 'gpt_adm', 'aps'].includes(fields.committedArtifact as string) || (fields.gptToken !== null && (!boundedString(fields.gptToken) || !/^gt1_[1-9a-z][0-9a-z]{0,6}$/.test(fields.gptToken))) @@ -352,6 +387,7 @@ function snapshotSlot(value: unknown): Readonly> | undef owner: fields.owner, outcome: fields.outcome, targeting, + targetingOwnership, committedArtifact: fields.committedArtifact, gptToken: fields.gptToken, }); @@ -393,14 +429,27 @@ function snapshotTombstone(value: unknown): Readonly> | } function snapshotArtifact(value: unknown): Readonly> | undefined { - const fields = exactRecord(value, ['slotId', 'kind', 'owner', 'token']); + const fields = exactRecord(value, [ + 'slotId', + 'kind', + 'owner', + 'token', + 'hostPosition', + 'hostPositionPriority', + ]); if ( !fields || !boundedString(fields.slotId) || (fields.kind !== 'gpt_adm' && fields.kind !== 'aps') || (fields.owner !== 'trusted_server' && fields.owner !== 'publisher') || typeof fields.token !== 'string' || - !/^r1_[A-Za-z0-9_-]{22}$/.test(fields.token) + !/^r1_[A-Za-z0-9_-]{22}$/.test(fields.token) || + (fields.hostPosition !== null && !boundedString(fields.hostPosition, MAX_STRING_BYTES, true)) || + (fields.hostPositionPriority !== null && + fields.hostPositionPriority !== '' && + fields.hostPositionPriority !== 'important') || + (fields.hostPosition === null) !== (fields.hostPositionPriority === null) || + (fields.kind === 'gpt_adm' && fields.hostPosition !== null) ) { return undefined; } @@ -982,8 +1031,19 @@ export function snapshotFirstDisplayHandoffV1( slots.some((slot) => { const artifact = artifactBySlot.get(slot.id as string); const cycle = cycleBySlot.get(slot.id as string); + const targetingOwnership = slot.targetingOwnership as readonly Readonly<{ + installed: string; + key: string; + prior: readonly string[]; + }>[]; if (slot.outcome !== 'accepted') { - return slot.committedArtifact !== 'none' || slot.gptToken !== null || artifact || cycle; + return ( + slot.committedArtifact !== 'none' || + slot.gptToken !== null || + targetingOwnership.length !== 0 || + artifact || + cycle + ); } const targeting = slot.targeting as readonly (readonly [string, string])[]; const reservation = targeting.find(([key]) => key === 'hb_adid')?.[1]; @@ -994,7 +1054,12 @@ export function snapshotFirstDisplayHandoffV1( artifact.kind !== slot.committedArtifact || artifact.token !== reservation || !cycle || - cycle.token !== slot.gptToken + cycle.token !== slot.gptToken || + (slot.owner !== 'publisher' && targetingOwnership.length !== 0) || + targetingOwnership.some( + (ownership) => + targeting.find(([key]) => key === ownership.key)?.[1] !== ownership.installed + ) ); }) || cycles.some((cycle) => { diff --git a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts index 1496b3a0e..7f16bd08c 100644 --- a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts @@ -114,6 +114,8 @@ describe('ADM-only first-display render bridge', () => { expect(h.bridge.closeIngress()).toBe(true); expect(h.bridge.captureHandoff()?.artifacts).toEqual([ { + hostPosition: null, + hostPositionPriority: null, identity: frame, kind: 'gpt_adm', owner: 'trusted_server', @@ -126,6 +128,19 @@ describe('ADM-only first-display render bridge', () => { expect(frame?.isConnected).toBe(true); }); + it('retires one accepted ADM frame without repeating terminal settlement', () => { + const h = harness(); + expect(h.bridge.recordGam(h.cycle, 'gam_empty')).toBe(true); + const frame = h.element.querySelector('iframe'); + frame?.dispatchEvent(new h.dom.window.Event('load')); + expect(h.terminal).toHaveBeenCalledOnce(); + + expect(h.bridge.retire(h.cycle)).toBe(true); + expect(h.bridge.retire(h.cycle)).toBe(false); + expect(frame?.isConnected).toBe(false); + expect(h.terminal).toHaveBeenCalledOnce(); + }); + it('fails the owned frame at the bounded load deadline and rejects APS input', () => { const h = harness(); expect(h.bridge.recordGam(h.cycle, 'gam_empty')).toBe(true); diff --git a/crates/trusted-server-js/lib/test/first_display/agent.test.ts b/crates/trusted-server-js/lib/test/first_display/agent.test.ts index 12f21a467..43cf2619b 100644 --- a/crates/trusted-server-js/lib/test/first_display/agent.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/agent.test.ts @@ -183,6 +183,7 @@ function driver(events: string[]): FirstDisplayDriver & { }), closeIngress: () => true, detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, start: ( _outcomes: readonly FirstDisplayBatchOutcomeV1[], onFirstAction: () => boolean, @@ -214,6 +215,7 @@ describe('bounded first-display agent', () => { captureHandoff: () => undefined, closeIngress: () => true, detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, start: (outcomes: readonly FirstDisplayBatchOutcomeV1[]) => { received.push([...outcomes]); }, @@ -364,6 +366,7 @@ describe('bounded first-display agent', () => { captureHandoff: () => undefined, closeIngress: () => true, detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, start: (_outcomes: readonly FirstDisplayBatchOutcomeV1[], onFirstAction: () => boolean) => { events.push('driver:prepared'); action = onFirstAction; @@ -399,6 +402,7 @@ describe('bounded first-display agent', () => { captureHandoff: () => undefined, closeIngress: () => true, detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, start: (_outcomes: readonly FirstDisplayBatchOutcomeV1[], onFirstAction: () => boolean) => { lateAction = onFirstAction; }, @@ -523,6 +527,7 @@ describe('bounded first-display agent', () => { captureHandoff: () => undefined, closeIngress: () => true, detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, start: () => { throw new Error('boom'); }, @@ -599,6 +604,8 @@ describe('bounded first-display agent', () => { return Object.freeze({ artifacts: Object.freeze([ Object.freeze({ + hostPosition: null, + hostPositionPriority: null, identity: artifact, kind: 'gpt_adm' as const, owner: 'trusted_server' as const, @@ -615,6 +622,7 @@ describe('bounded first-display agent', () => { physicalSlot, placement: projectedBatch.projection.slots[0]!, slotId: 'slot-0', + targetingOwnership: Object.freeze([]), traceToken: 'gt1_1', }), ]), @@ -652,6 +660,7 @@ describe('bounded first-display agent', () => { events.push('detach'); return true; }, + sweepCommittedArtifacts: () => 0, dispose: () => events.push('dispose'), }); const agent = createFirstDisplayAgent({ diff --git a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts index 15715947b..abfe5edf9 100644 --- a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts @@ -46,6 +46,7 @@ function handoff(overrides: Record = {}): Record = {}): Record { domId: `div-${index}`, outcome: 'no_bid', committedArtifact: 'none', + targetingOwnership: [], gptToken: null, })); const attempts = Array.from({ length: count }, (_, index) => ({ @@ -456,6 +465,7 @@ describe('first-display immutable contracts', () => { domId: `div-${slotIndex}`, outcome: 'no_bid', committedArtifact: 'none', + targetingOwnership: [], gptToken: null, targeting: Array.from({ length: 32 }, (_, targetingIndex) => [`k${targetingIndex}`, '']), })); diff --git a/crates/trusted-server-js/lib/test/first_display/driver.test.ts b/crates/trusted-server-js/lib/test/first_display/driver.test.ts index 62bd6a054..9306d6fcc 100644 --- a/crates/trusted-server-js/lib/test/first_display/driver.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/driver.test.ts @@ -89,7 +89,10 @@ function harness() { dropCount: 0, })), captureHandoff: vi.fn(() => [cycleOwner.value].filter(Boolean)), - closeIngress: vi.fn(() => true), + closeIngress: vi.fn(() => { + events.push('gpt:close'); + return true; + }), detachCommittedSlots: vi.fn(() => true), dispose: vi.fn(), }; @@ -109,6 +112,8 @@ function harness() { return true; }), recordFailure: vi.fn(() => true), + retire: vi.fn(() => true), + sweepCommittedArtifacts: vi.fn(() => 0), captureHandoff: vi.fn(() => ({ artifacts: [], clockEpochMs: 0, @@ -116,7 +121,10 @@ function harness() { nextTicketOrdinal: 1, tombstones: [], })), - closeIngress: vi.fn(() => true), + closeIngress: vi.fn(() => { + events.push('render:close'); + return true; + }), detachCommittedArtifacts: vi.fn(() => true), sealTsAdmission: vi.fn(() => events.push('render:seal')), dispose: vi.fn(() => events.push('render:dispose')), @@ -176,6 +184,10 @@ describe('projected first-display driver', () => { h.getRenderTerminal()('failed', 'internal_error'); expect(h.events).toEqual(['render:bind', 'action', 'render:gam:nonempty_gam']); expect(h.terminals).toEqual([['slot-1', 'accepted', null]]); + + h.getGptCallbacks().onRetire?.(h.cycle); + h.getGptCallbacks().onRetire?.(Object.freeze({ ...h.cycle, physicalSlot: {} })); + expect(h.renderer.retire).toHaveBeenCalledExactlyOnceWith(h.cycle); }); it('fails an empty or mismatched physical GPT cycle without guessing', () => { @@ -219,6 +231,8 @@ describe('projected first-display driver', () => { h.driver.sealTsAdmission(); expect(h.driver.closeIngress()).toBe(true); + expect(h.gptBatch.closeIngress).toHaveBeenCalledExactlyOnceWith(['slot-1']); + expect(h.events.slice(-2)).toEqual(['gpt:close', 'render:close']); expect(h.driver.captureHandoff()).toEqual({ artifacts: [], clockEpochMs: 0, @@ -275,6 +289,8 @@ describe('projected first-display driver', () => { bind: () => true, recordGam: () => true, recordFailure: () => true, + retire: () => true, + sweepCommittedArtifacts: () => 0, captureHandoff: () => ({ artifacts: [], clockEpochMs: 0, diff --git a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts index 30ec37539..928386276 100644 --- a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts @@ -4,6 +4,8 @@ import { JSDOM } from 'jsdom'; import { createFirstDisplayGoogletagBatch } from '../../src/first_display/adapters/googletag'; import { snapshotFirstDisplayBatchV1 } from '../../src/first_display/leaf/projection'; +const RESERVATION_ID = `r1_${'a'.repeat(22)}`; + function fixture() { return Object.freeze({ version: 1, @@ -35,7 +37,7 @@ function fixture() { cpm: 1.25, currency: 'USD', targeting: Object.freeze({ hb_pb: '1.25' }), - rendererReservationId: `r1_${'a'.repeat(22)}`, + rendererReservationId: RESERVATION_ID, renderSource: Object.freeze({ type: 'adm', version: 1, @@ -49,6 +51,77 @@ function fixture() { }); } +function twoSlotFixture() { + const secondReservationId = `r1_${'b'.repeat(22)}`; + return Object.freeze({ + version: 1, + projectionDigest: 'b'.repeat(64), + projection: Object.freeze({ + version: 1, + auction: Object.freeze({ + version: 1, + auctionId: 'initial', + results: Object.freeze([ + Object.freeze({ slot: 'slot-1', outcome: 'winner', candidateId: 'candidate001' }), + Object.freeze({ slot: 'slot-2', outcome: 'winner', candidateId: 'candidate002' }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'slot-1', + gamUnitPath: '/123/example-one', + divId: 'slot-1', + formats: Object.freeze([Object.freeze([300, 250])]), + targeting: Object.freeze({ placement: 'article' }), + }), + Object.freeze({ + slot: 'slot-2', + gamUnitPath: '/123/example-two', + divId: 'slot-2', + formats: Object.freeze([Object.freeze([728, 90])]), + targeting: Object.freeze({ placement: 'sidebar' }), + }), + ]), + bids: Object.freeze([ + Object.freeze({ + candidateId: 'candidate001', + slot: 'slot-1', + provider: 'example', + upstreamBidId: 'upstream-1', + cpm: 1.25, + currency: 'USD', + targeting: Object.freeze({ hb_pb: '1.25' }), + rendererReservationId: RESERVATION_ID, + renderSource: Object.freeze({ + type: 'adm', + version: 1, + adm: '
example one
', + width: 300, + height: 250, + }), + }), + Object.freeze({ + candidateId: 'candidate002', + slot: 'slot-2', + provider: 'example', + upstreamBidId: 'upstream-2', + cpm: 2.5, + currency: 'USD', + targeting: Object.freeze({ hb_pb: '2.50' }), + rendererReservationId: secondReservationId, + renderSource: Object.freeze({ + type: 'adm', + version: 1, + adm: '
example two
', + width: 728, + height: 90, + }), + }), + ]), + }), + }); +} + function protocol() { return Object.freeze({ deadlines: Object.freeze({ @@ -89,6 +162,7 @@ describe('first-display GPT adapter', () => { }); const listeners = new Map void>(); const mutations = vi.fn(() => true); + const retired = vi.fn(); const slot = { clearTargeting: vi.fn(() => undefined), getSlotElementId: () => 'slot-1', @@ -126,6 +200,7 @@ describe('first-display GPT adapter', () => { onFailure: () => undefined, onFirstAction: () => true, onRenderEnded: () => undefined, + onRetire: retired, }); listeners.get('slotRequested')?.({ slot }); listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); @@ -141,8 +216,9 @@ describe('first-display GPT adapter', () => { isEmpty: false, }); expect(mutations).toHaveBeenCalledTimes(5); + expect(retired).toHaveBeenCalledOnce(); - expect(adapter.closeIngress()).toBe(true); + expect(adapter.closeIngress(['slot-1'])).toBe(true); expect(adapter.captureDiagnosticsHandoff()?.cycles).toEqual([ expect.objectContaining({ nextCycleOrdinal: 3, @@ -280,7 +356,13 @@ describe('first-display GPT adapter', () => { removeEventListener: () => undefined, }; const defineSlot = vi.fn().mockReturnValueOnce(slot).mockReturnValueOnce(replacement); - const destroySlots = vi.fn((_slots?: readonly object[]) => true); + let destroyMode: 'throw' | 'false' | 'true' = 'throw'; + const destroySlots = vi.fn((slots?: readonly object[]) => { + if (destroyMode === 'throw') throw new Error('fictional destroy failure'); + if (destroyMode === 'false') return false; + if (Array.isArray(slots)) (slots as object[]).length = 0; + return true; + }); const binding = { cmd: { push: (command: () => void) => command() }, defineSlot, @@ -295,6 +377,7 @@ describe('first-display GPT adapter', () => { }); const batch = snapshotFirstDisplayBatchV1(fixture())!; let bound: { readonly isCurrent: () => boolean } | undefined; + const retired = vi.fn(); const adapter = createFirstDisplayGoogletagBatch({ browser: dom.window as unknown as Window, clearTimer: () => undefined, @@ -310,6 +393,7 @@ describe('first-display GPT adapter', () => { onFailure: () => undefined, onFirstAction: () => true, onRenderEnded: () => undefined, + onRetire: retired, }); expect(bound?.isCurrent()).toBe(true); @@ -317,10 +401,21 @@ describe('first-display GPT adapter', () => { listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); expect(bound?.isCurrent()).toBe(true); - expect(binding.destroySlots([slot])).toBe(true); + expect(() => binding.destroySlots([slot])).toThrow('fictional destroy failure'); + expect(bound?.isCurrent()).toBe(true); + expect(retired).not.toHaveBeenCalled(); + destroyMode = 'false'; + expect(binding.destroySlots([slot])).toBe(false); + expect(bound?.isCurrent()).toBe(true); + expect(retired).not.toHaveBeenCalled(); + destroyMode = 'true'; + const targets = [slot]; + expect(binding.destroySlots(targets)).toBe(true); + expect(targets).toEqual([]); expect(binding.defineSlot('/publisher/replacement', [[300, 250]], 'slot-1')).toBe(replacement); binding.display('slot-1'); expect(bound?.isCurrent()).toBe(false); + expect(retired).toHaveBeenCalledOnce(); }); it('cancels a pending command and compare-restores an adapter-created GPT queue', () => { @@ -413,13 +508,471 @@ describe('first-display GPT adapter', () => { expect(refresh).toHaveBeenCalledWith([slot], { changeCorrelator: false }); expect(firstAction).toHaveBeenCalledOnce(); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); slot.setTargeting('placement', 'article'); + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(adapter.captureHandoff()?.[0]?.targetingOwnership).toEqual([ + { installed: RESERVATION_ID, key: 'hb_adid', prior: [] }, + { installed: '1.25', key: 'hb_pb', prior: ['publisher-original'] }, + ]); adapter.dispose(); expect(targeting.get('placement')).toEqual(['article']); expect(targeting.get('hb_pb')).toEqual(['publisher-original']); expect(targeting.has('hb_adid')).toBe(false); }); + it('rejects targeting handoff after a publisher replaces an observed method', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + const replacement = vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }); + Object.defineProperty(slot, 'setTargeting', { + configurable: true, + value: replacement, + writable: true, + }); + slot.setTargeting('hb_pb', '1.25'); + + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(adapter.captureHandoff()?.[0]?.targetingOwnership).toEqual([]); + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(replacement).toHaveBeenCalledWith('hb_pb', '1.25'); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + }); + + it.each(['close', 'dispose'] as const)( + 'does not restore stale publisher targeting through a replacement during %s cleanup', + (cleanup) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + const replacement = vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }); + Object.defineProperty(slot, 'setTargeting', { + configurable: true, + value: replacement, + writable: true, + }); + slot.setTargeting('hb_pb', '1.25'); + + if (cleanup === 'close') { + expect(adapter.closeIngress([])).toBe(true); + adapter.dispose(); + } else { + adapter.dispose(); + } + expect(replacement).toHaveBeenCalledOnce(); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + } + ); + + it.each(['close', 'dispose'] as const)( + 'honors a same-value publisher write from getTargeting during %s cleanup', + (cleanup) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + let cleanupPhase = false; + let reentrantCleanupCalls = 0; + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: vi.fn((key: string) => { + if (cleanupPhase && key === 'hb_pb' && reentrantCleanupCalls === 0) { + reentrantCleanupCalls += 1; + slot.setTargeting('hb_pb', '1.25'); + } + return targeting.get(key) ?? []; + }), + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + cleanupPhase = true; + if (cleanup === 'close') { + expect(adapter.closeIngress([])).toBe(true); + adapter.dispose(); + } else { + adapter.dispose(); + } + expect(reentrantCleanupCalls).toBe(1); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + } + ); + + it('seals retained targeting while same-value publisher writes are still observed', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + let sealing = false; + let reentrantSealingCalls = 0; + const slot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: vi.fn((key: string) => { + if (sealing && key === 'hb_pb' && reentrantSealingCalls === 0) { + reentrantSealingCalls += 1; + slot.setTargeting('hb_pb', '1.25'); + } + return targeting.get(key) ?? []; + }), + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return slot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + + sealing = true; + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect( + adapter.captureHandoff()?.[0]?.targetingOwnership.some(({ key }) => key === 'hb_pb') + ).toBe(false); + expect(reentrantSealingCalls).toBe(1); + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + }); + + it('cleans failed TS slots while publisher targeting observation is still continuous', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const targeting = new Map([['hb_pb', ['publisher-original']]]); + const publisherSlot = { + clearTargeting: vi.fn((key: string) => targeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + targeting.set(key, Array.isArray(value) ? [...value] : [value]); + return publisherSlot; + }), + }; + const failedSlot = { + addService: () => failedSlot, + getSlotElementId: () => 'slot-2', + setTargeting: () => failedSlot, + }; + const listeners = new Map void>(); + const destroySlots = vi.fn((slots: object[]) => { + expect(slots).toEqual([failedSlot]); + slots.length = 0; + publisherSlot.setTargeting('hb_pb', '1.25'); + return true; + }); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [publisherSlot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => failedSlot, + destroySlots, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(twoSlotFixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + for (const slot of [publisherSlot, failedSlot]) { + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + } + + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(destroySlots).toHaveBeenCalledOnce(); + expect( + adapter.captureHandoff()?.[0]?.targetingOwnership.some(({ key }) => key === 'hb_pb') + ).toBe(false); + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(destroySlots).toHaveBeenCalledOnce(); + expect(targeting.get('hb_pb')).toEqual(['1.25']); + }); + + it('cleans failed publisher targeting before handoff with exact nested-write attribution', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const acceptedTargeting = new Map([['hb_pb', ['accepted-original']]]); + const failedTargeting = new Map([['hb_pb', ['failed-original']]]); + let cleanupPhase = false; + let reentrantCleanupCalls = 0; + const acceptedSlot = { + clearTargeting: vi.fn((key: string) => acceptedTargeting.delete(key)), + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => acceptedTargeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + acceptedTargeting.set(key, Array.isArray(value) ? [...value] : [value]); + return acceptedSlot; + }), + }; + const reenterAcceptedSlot = () => { + if (!cleanupPhase || reentrantCleanupCalls > 0) return; + reentrantCleanupCalls += 1; + acceptedSlot.setTargeting('hb_pb', '1.25'); + }; + const failedSlot = { + clearTargeting: vi.fn((key: string) => { + failedTargeting.delete(key); + reenterAcceptedSlot(); + }), + getSlotElementId: () => 'slot-2', + getTargeting: (key: string) => failedTargeting.get(key) ?? [], + setTargeting: vi.fn((key: string, value: string | string[]) => { + failedTargeting.set(key, Array.isArray(value) ? [...value] : [value]); + reenterAcceptedSlot(); + return failedSlot; + }), + }; + const listeners = new Map void>(); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [acceptedSlot, failedSlot], + refresh: () => undefined, + removeEventListener: () => undefined, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display: () => undefined, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }, + }); + const batch = snapshotFirstDisplayBatchV1(twoSlotFixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + clearTimer: () => undefined, + }); + adapter.start({ + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + for (const slot of [acceptedSlot, failedSlot]) { + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + } + + cleanupPhase = true; + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(failedTargeting.get('hb_pb')).toEqual(['failed-original']); + expect(reentrantCleanupCalls).toBe(1); + expect( + adapter.captureHandoff()?.[0]?.targetingOwnership.some(({ key }) => key === 'hb_pb') + ).toBe(false); + const cleanupWrites = + failedSlot.clearTargeting.mock.calls.length + failedSlot.setTargeting.mock.calls.length; + + expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); + adapter.dispose(); + expect(reentrantCleanupCalls).toBe(1); + expect( + failedSlot.clearTargeting.mock.calls.length + failedSlot.setTargeting.mock.calls.length + ).toBe(cleanupWrites); + expect(acceptedTargeting.get('hb_pb')).toEqual(['1.25']); + }); + it('marks refresh as the first request when initial load is disabled', () => { const dom = new JSDOM('
', { url: 'https://publisher.example/', @@ -659,7 +1212,7 @@ describe('first-display GPT adapter', () => { listeners.get('slotRequested')?.({ slot }); listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); - expect(adapter.closeIngress()).toBe(true); + expect(adapter.closeIngress(['slot-1'])).toBe(true); expect(adapter.captureHandoff()).toEqual([ expect.objectContaining({ slotId: 'slot-1', physicalSlot: slot }), ]); @@ -729,7 +1282,7 @@ describe('first-display GPT adapter', () => { listeners.get('slotOnload')?.({ slot }); listeners.get('impressionViewable')?.({ slot }); listeners.get('slotVisibilityChanged')?.({ slot, inViewPercentage: 42 }); - expect(adapter.closeIngress()).toBe(true); + expect(adapter.closeIngress(['slot-1'])).toBe(true); const diagnostics = adapter.captureDiagnosticsHandoff(); expect([...listeners.keys()]).toEqual([ diff --git a/crates/trusted-server-js/lib/test/first_display/handoff.test.ts b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts index e5a43075f..9c6dc7975 100644 --- a/crates/trusted-server-js/lib/test/first_display/handoff.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts @@ -52,6 +52,7 @@ function acceptedHandoff(revision: number): Record { owner: 'trusted_server', outcome: 'accepted', targeting: [['hb_adid', RESERVATION_ID]], + targetingOwnership: [], committedArtifact: 'gpt_adm', gptToken: 'gt1_1', }, @@ -67,6 +68,8 @@ function acceptedHandoff(revision: number): Record { ], artifacts: [ { + hostPosition: null, + hostPositionPriority: null, slotId: 'slot-1', kind: 'gpt_adm', owner: 'trusted_server', @@ -243,6 +246,7 @@ describe('first-display final handoff owner', () => { owner: 'trusted_server', outcome: 'failed', targeting: [], + targetingOwnership: [], committedArtifact: 'none', gptToken: null, }, diff --git a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts index e5df5578a..307cd4dd8 100644 --- a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts @@ -339,6 +339,23 @@ function startApsDocument(h: ReturnType) { return { documentPort, frame, nonce }; } +function acceptPucAps(h: ReturnType): HTMLIFrameElement { + registerOwner(h); + const { documentPort, frame, nonce } = startApsDocument(h); + documentPort.dispatch({ + message: 'TS APS Document Accepted', + version: 1, + nonce, + }); + documentPort.dispatch({ + message: 'TS APS Render Completed', + version: 1, + nonce, + }); + expect(h.terminals).toEqual(['accepted']); + return frame; +} + describe('bounded first-display render bridge', () => { it('observes admitted bridge activity and terminal tombstone expiry', () => { const mutations = vi.fn(() => true); @@ -594,8 +611,71 @@ describe('bounded first-display render bridge', () => { lifecycleTicket: ticket, outcome: 'accepted', }); + expect(h.bridge.retire(h.cycle)).toBe(true); + expect(h.bridge.retire(h.cycle)).toBe(false); + expect(frame.isConnected).toBe(false); + expect(h.element.style.getPropertyValue('position')).toBe(''); + expect(h.element.querySelector('span')?.textContent).toBe('publisher GAM content'); + expect(h.terminals).toEqual(['accepted']); }); + it.each(['removed', 'reparented', 'host_replaced'] as const)( + 'retires an accepted APS overlay before handoff when its DOM is %s', + (mutation) => { + const h = harness('aps'); + h.element.innerHTML = 'publisher'; + const frame = acceptPucAps(h); + const movedHost = h.dom.window.document.createElement('div'); + movedHost.id = 'moved-host'; + h.dom.window.document.body.appendChild(movedHost); + + if (mutation === 'removed') frame.remove(); + if (mutation === 'reparented') movedHost.appendChild(frame); + if (mutation === 'host_replaced') { + const replacement = h.element.cloneNode(false); + h.element.replaceWith(replacement); + } + + expect(h.bridge.sweepCommittedArtifacts()).toBe(1); + expect(h.bridge.sweepCommittedArtifacts()).toBe(0); + expect(frame.isConnected).toBe(false); + expect(h.element.style.getPropertyValue('position')).toBe(''); + expect(h.element.querySelector('span')?.textContent).toBe('publisher'); + expect(h.terminals).toEqual(['accepted']); + + h.bridge.sealTsAdmission(); + expect(h.bridge.closeIngress()).toBe(true); + expect(h.bridge.captureHandoff()?.artifacts).toEqual([]); + expect(h.bridge.detachCommittedArtifacts()).toBe(true); + } + ); + + it.each(['src', 'srcdoc', 'sandbox', 'frame_style', 'host_position'] as const)( + 'retires an accepted APS overlay before handoff when its %s integrity changes', + (mutation) => { + const h = harness('aps'); + h.element.innerHTML = 'publisher'; + const frame = acceptPucAps(h); + + if (mutation === 'src') frame.setAttribute('src', 'https://publisher.example/replaced'); + if (mutation === 'srcdoc') frame.srcdoc = 'Replacement'; + if (mutation === 'sandbox') frame.setAttribute('sandbox', 'allow-scripts'); + if (mutation === 'frame_style') frame.style.setProperty('visibility', 'hidden'); + if (mutation === 'host_position') h.element.style.setProperty('position', 'absolute'); + + expect(h.bridge.sweepCommittedArtifacts()).toBe(1); + expect(h.bridge.sweepCommittedArtifacts()).toBe(0); + expect(frame.isConnected).toBe(false); + expect(h.element.querySelector('span')?.textContent).toBe('publisher'); + expect(h.terminals).toEqual(['accepted']); + + h.bridge.sealTsAdmission(); + expect(h.bridge.closeIngress()).toBe(true); + expect(h.bridge.captureHandoff()?.artifacts).toEqual([]); + expect(h.bridge.detachCommittedArtifacts()).toBe(true); + } + ); + it('renders an attributable empty-GAM ADM fallback directly into the bound element', () => { const h = harness('adm'); expect(h.bridge.recordGam(h.cycle, 'gam_empty')).toBe(true); @@ -839,6 +919,8 @@ describe('bounded first-display render bridge', () => { expect(h.bridge.captureHandoff()).toEqual({ artifacts: [ { + hostPosition: null, + hostPositionPriority: null, identity: frame, kind: 'gpt_adm', owner: 'trusted_server', diff --git a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts index 1e368abd3..41c8d4287 100644 --- a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts @@ -31,6 +31,7 @@ function handoff(revision = 0): Record { owner: 'trusted_server', outcome: 'accepted', targeting: [['hb_adid', RESERVATION_ID]], + targetingOwnership: [], committedArtifact: 'gpt_adm', gptToken: 'gt1_1', }, @@ -47,6 +48,8 @@ function handoff(revision = 0): Record { tombstones: [], artifacts: [ { + hostPosition: null, + hostPositionPriority: null, slotId: 'slot-1', kind: 'gpt_adm', owner: 'trusted_server', diff --git a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts index e7c079a3e..a1772cf23 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts @@ -6,7 +6,10 @@ import type { IntegrationPrepareContext, PreparedIntegration, } from '../../../src/kernel/integration_registry'; -import type { RenderAttempt } from '../../../src/services/render'; +import { + createArtifactHostPositionLeaseRegistry, + type RenderAttempt, +} from '../../../src/services/render'; import type { PucApsMountInput } from '../../../src/services/puc_bridge'; const RELEASE_ID = 'a'.repeat(64); @@ -25,6 +28,9 @@ describe('APS provider', () => { registeredValidation = undefined; }); const render = Object.freeze({ + bindArtifactGuard: vi.fn(() => true), + bootstrapNonces: Object.freeze({}), + hostPositions: createArtifactHostPositionLeaseRegistry(), publisherOrigin: window.location.origin, rendererNonces: Object.freeze({}), registerRenderer: vi.fn( @@ -90,6 +96,9 @@ describe('APS provider', () => { const activationRelease: Array<() => void> = []; const releaseValidation = vi.fn(); const render = Object.freeze({ + bindArtifactGuard: vi.fn(() => true), + bootstrapNonces: Object.freeze({}), + hostPositions: createArtifactHostPositionLeaseRegistry(), publisherOrigin: window.location.origin, rendererNonces: Object.freeze({}), registerRenderer: vi.fn(() => { 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 171a9a908..bb125c70c 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 @@ -33,9 +33,9 @@ import { type CommittedRenderArtifact, type RenderAttempt, } from '../../../src/services/render'; +import { createTargetingService } from '../../../src/services/targeting'; 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)}`; @@ -616,6 +616,16 @@ describe('transactional GPT integration module', () => { const physicalSlot = {}; const frame = {}; const navigationGeneration = {}; + const committedArtifact: CommittedRenderArtifact = Object.freeze({ + attemptId: `a1_${'d'.repeat(22)}`, + dispose: vi.fn(), + kind: 'puc', + navigationGeneration, + slot: 'slot-1', + }); + const artifactStore = createCommittedArtifactStore(); + expect(artifactStore.promote(committedArtifact)).toBe(true); + const adoptCommittedArtifact = vi.fn(() => true); const adoptGptSlot = vi.fn(() => Object.freeze({ ok: true as const })); const adoptRegistrationHighWater = vi.fn(() => true); const adoption = Object.freeze({ @@ -630,6 +640,7 @@ describe('transactional GPT integration module', () => { domId: 'div-1', gamPath: '/123/slot-1', formats: Object.freeze([Object.freeze([300, 250])]), + targetingOwnership: Object.freeze([]), }), ]), cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), @@ -639,10 +650,21 @@ describe('transactional GPT integration module', () => { }); expect( - adoptInitialGptSlotsFromHandoff(adoption, navigationGeneration, { - adoptGptSlot, - adoptRegistrationHighWater, - }) + adoptInitialGptSlotsFromHandoff( + adoption, + navigationGeneration, + { + adoptCommittedArtifact, + adoptGptSlot, + adoptRegistrationHighWater, + }, + artifactStore, + { + adopt: vi.fn(), + observePublisherMutations: vi.fn(), + }, + {} as never + ) ).toBe(adoption); expect(adoptRegistrationHighWater).toHaveBeenCalledExactlyOnceWith(navigationGeneration, 2); expect(adoptGptSlot).toHaveBeenCalledExactlyOnceWith(navigationGeneration, 'slot-1', { @@ -654,8 +676,138 @@ describe('transactional GPT integration module', () => { ownership: 'trusted_server', slot: physicalSlot, }); + expect(adoptCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigationGeneration, + 'slot-1', + committedArtifact, + expect.any(Function) + ); }); + it.each(['unchanged', 'same_publisher_write', 'different_publisher_write'] as const)( + 'transfers first-display targeting ownership and preserves %s semantics', + (mutation) => { + const physicalSlot = {}; + const frame = {}; + const navigationGeneration = {}; + const values = new Map([['hb_adid', ['trusted']]]); + const setTargeting = vi.fn((slot: object, key: string, value: string | readonly string[]) => { + expect(slot).toBe(physicalSlot); + values.set(key, Object.freeze(typeof value === 'string' ? [value] : [...value])); + }); + const clearTargeting = vi.fn((_slot: object, key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }); + let publisherObserver: + Readonly<{ beforePublisherMutation: (slot: object, key?: string) => void }> | undefined; + const releaseObservation = Object.assign(vi.fn(), { isCurrent: () => true }); + const facade = { + clearTargeting, + getTargeting: (_slot: object, key: string) => Object.freeze([...(values.get(key) ?? [])]), + observeTargeting: ( + slot: object, + observer: Readonly<{ beforePublisherMutation: (slot: object, key?: string) => void }> + ) => { + expect(slot).toBe(physicalSlot); + publisherObserver = observer; + return releaseObservation; + }, + setTargeting, + }; + const adapter = { + run: (command: (gpt: typeof facade) => unknown) => { + const result = command(facade); + return Object.freeze({ + status: 'present' as const, + result: Promise.resolve(result), + dispose: vi.fn(), + }); + }, + } as never; + const artifact: CommittedRenderArtifact = Object.freeze({ + attemptId: `a1_${'e'.repeat(22)}`, + dispose: vi.fn(), + kind: 'aps_mount', + navigationGeneration, + slot: 'slot-1', + }); + const artifactStore = createCommittedArtifactStore(); + expect(artifactStore.promote(artifact)).toBe(true); + let retireTargeting: (() => void) | undefined; + const service = { + adoptCommittedArtifact: vi.fn( + ( + _generation: object, + _slotId: string, + _artifact: CommittedRenderArtifact, + onRetire?: () => void + ) => { + retireTargeting = onRetire; + return true; + } + ), + adoptGptSlot: vi.fn(() => Object.freeze({ ok: true as const })), + adoptRegistrationHighWater: vi.fn(() => true), + }; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + highWater: Object.freeze({ nextSlotRegistrationOrdinal: 2 }), + slots: Object.freeze([ + Object.freeze({ + id: 'slot-1', + owner: 'publisher' as const, + targetingOwnership: Object.freeze([ + Object.freeze({ + installed: 'trusted', + key: 'hb_adid', + prior: Object.freeze(['publisher-original']), + }), + ]), + }), + ]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + const targeting = createTargetingService(); + + expect( + adoptInitialGptSlotsFromHandoff( + adoption, + navigationGeneration, + service, + artifactStore, + targeting, + adapter + ) + ).toBe(adoption); + expect(setTargeting).not.toHaveBeenCalled(); + + if (mutation !== 'unchanged') { + publisherObserver?.beforePublisherMutation(physicalSlot, 'hb_adid'); + values.set( + 'hb_adid', + Object.freeze([mutation === 'same_publisher_write' ? 'trusted' : 'publisher-new']) + ); + } + retireTargeting?.(); + + expect(values.get('hb_adid')).toEqual([ + mutation === 'unchanged' + ? 'publisher-original' + : mutation === 'same_publisher_write' + ? 'trusted' + : 'publisher-new', + ]); + expect(setTargeting).toHaveBeenCalledTimes(mutation === 'unchanged' ? 1 : 0); + expect(releaseObservation).toHaveBeenCalledOnce(); + } + ); + it.each([ { diagnosticsActive: false, adoptInitialDisplay: false, parserStateValid: true }, { diagnosticsActive: true, adoptInitialDisplay: false, parserStateValid: true }, @@ -897,6 +1049,7 @@ describe('transactional GPT integration module', () => { Object.freeze(['hb_adid', RESERVATION_ID] as const), ]), committedArtifact: 'gpt_adm' as const, + targetingOwnership: Object.freeze([]), gptToken: 'gt1_1', }), ]), @@ -912,9 +1065,11 @@ describe('transactional GPT integration module', () => { tombstones: Object.freeze([]), artifacts: Object.freeze([ Object.freeze({ + hostPosition: null, + hostPositionPriority: null, slotId: 'takeover-slot', kind: 'gpt_adm' as const, - owner: 'publisher' as const, + owner: 'trusted_server' as const, token: RESERVATION_ID, }), ]), @@ -1059,7 +1214,7 @@ describe('transactional GPT integration module', () => { }); await vi.waitFor(() => expect(display).toHaveBeenCalledOnce()); expect(protect).not.toHaveBeenCalled(); - expect(activeMutationObservers.size).toBe(2); + expect(activeMutationObservers.size).toBe(3); const firstPhysicalSlot = definedSlots[0]; expect(firstPhysicalSlot).toBeDefined(); takeoverElement.remove(); @@ -1068,10 +1223,13 @@ describe('transactional GPT integration module', () => { document.body.appendChild(replacementElement); await Promise.resolve(); await vi.advanceTimersByTimeAsync(250); - expect(destroySlots).toHaveBeenCalledWith([firstPhysicalSlot]); + expect(destroySlots).toHaveBeenCalledOnce(); + const destroyedPhysicalSlot = destroySlots.mock.calls[0]?.[0]?.[0]; + expect(destroyedPhysicalSlot).toBeDefined(); + expect(destroyedPhysicalSlot).not.toBe(publisherSlot); expect(definedSlots).toHaveLength(1); - expect(definedSlots[0]).not.toBe(firstPhysicalSlot); - expect(activeMutationObservers.size).toBe(2); + expect(definedSlots).not.toContain(destroyedPhysicalSlot); + expect(activeMutationObservers.size).toBe(3); expect(vi.getTimerCount()).toBe(0); vi.useRealTimers(); expect([...providerFacades.keys()]).toEqual([ @@ -1152,7 +1310,7 @@ describe('transactional GPT integration module', () => { expect(refresh).not.toHaveBeenCalled(); const later = gpt.activateLaterLifecycle(); expect(Object.isFrozen(later)).toBe(true); - expect(activeMutationObservers.size).toBe(2); + expect(activeMutationObservers.size).toBe(3); expect(() => gpt.activateLaterLifecycle()).toThrow('unavailable'); const navigationResult = await later.navigate('/spa-production?route=one'); expect(navigationResult).toEqual({ @@ -1261,7 +1419,7 @@ describe('transactional GPT integration module', () => { ((await currentNavigation) as { navigationGeneration: object }).navigationGeneration ); later.release(); - expect(activeMutationObservers.size).toBe(1); + expect(activeMutationObservers.size).toBe(2); await later.navigate('/disposed-owner'); expect(fetchPageBids).toHaveBeenCalledTimes(4); fetchPageBids.mockRestore(); diff --git a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts index 7e7979539..03ce66bfb 100644 --- a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts @@ -15,7 +15,12 @@ import { createRenderRuntimeIntegrationRegistration, } from '../../../src/integrations/render_runtime/module'; import { log } from '../../../src/core/log'; -import { createCommittedArtifactStore, type RenderAttempt } from '../../../src/services/render'; +import { + createArtifactHostPositionLeaseRegistry, + createCommittedArtifactStore, + type CommittedRenderArtifact, + type RenderAttempt, +} from '../../../src/services/render'; import type { NavigationSession } from '../../../src/kernel/sessions'; const RELEASE_ID = 'a'.repeat(64); @@ -126,17 +131,31 @@ describe('render_runtime provider', () => { }); it('adopts transferred DOM artifacts without removing them on rollback, then arms commit ownership', () => { + const host = document.createElement('div'); + host.id = 'div-1'; const frame = document.createElement('iframe'); - document.body.append(frame); + frame.srcdoc = 'Fictional creative'; + host.append(frame); + document.body.append(host); + const physicalSlot = Object.freeze({}); const adoption = Object.freeze({ version: 1 as const, adoptInitialDisplay: true as const, handoff: Object.freeze({ - slots: Object.freeze([Object.freeze({ id: 'slot-1' })]), - cycles: Object.freeze([]), - artifacts: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + kind: 'gpt_adm' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), }), - identities: Object.freeze([frame]), + identities: Object.freeze([physicalSlot, frame]), }); const navigationGeneration = {}; const batch = Object.freeze({ @@ -159,7 +178,13 @@ describe('render_runtime provider', () => { const rollbackStore = createCommittedArtifactStore(); expect( - adoptInitialRenderArtifactsFromHandoff(adoption, navigation, rollbackStore, document) + adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + rollbackStore, + createArtifactHostPositionLeaseRegistry(), + document + ) ).toBeDefined(); rollbackStore.dispose(); expect(frame.isConnected).toBe(true); @@ -169,6 +194,7 @@ describe('render_runtime provider', () => { adoption, navigation, committedStore, + createArtifactHostPositionLeaseRegistry(), document ); expect(committed?.adoption).toBe(adoption); @@ -178,6 +204,215 @@ describe('render_runtime provider', () => { expect(batch.dispose).toHaveBeenCalledTimes(2); }); + it.each(['reparented', 'frame_style'] as const)( + 'retires an adopted APS mount on %s loss and compare-restores only owned style', + (mutation) => { + const host = document.createElement('div'); + host.id = 'div-1'; + host.style.setProperty('position', 'relative'); + const frame = document.createElement('iframe'); + frame.setAttribute('sandbox', 'allow-scripts'); + frame.src = 'https://example.com/renderer'; + host.append(frame); + document.body.append(host); + const physicalSlot = Object.freeze({}); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: vi.fn(() => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }) + ), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: vi.fn(() => batch), + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: '', + hostPositionPriority: '', + kind: 'aps' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + const store = createCommittedArtifactStore(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + createArtifactHostPositionLeaseRegistry(), + document + ); + expect(committed).toBeDefined(); + committed?.arm(); + + if (mutation === 'reparented') { + const publisherContainer = document.createElement('div'); + document.body.append(publisherContainer); + publisherContainer.append(frame); + } else { + frame.style.setProperty('visibility', 'hidden'); + } + + expect(store.sweep()).toBe(1); + expect(frame.isConnected).toBe(false); + expect(host.style.getPropertyValue('position')).toBe(''); + expect(store.sweep()).toBe(0); + } + ); + + it('transfers an adopted APS host-position lease through persistent replacement', () => { + const host = document.createElement('div'); + host.id = 'div-1'; + host.style.setProperty('position', 'relative'); + const frame = document.createElement('iframe'); + frame.src = 'https://example.com/renderer'; + host.append(frame); + document.body.append(host); + const physicalSlot = Object.freeze({}); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: vi.fn(() => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }) + ), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: vi.fn(() => batch), + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: '', + hostPositionPriority: '', + kind: 'aps' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([physicalSlot, frame]), + }); + const store = createCommittedArtifactStore(); + const positions = createArtifactHostPositionLeaseRegistry(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + positions, + document + ); + expect(committed).toBeDefined(); + committed?.arm(); + const predecessor = store.current('slot-1'); + if (!predecessor) throw new Error('should adopt the first-display APS artifact'); + let replacementDisposed = false; + const replacement: CommittedRenderArtifact = Object.freeze({ + attemptId: `a1_${'B'.repeat(22)}`, + kind: 'aps_mount' as const, + navigationGeneration, + slot: 'slot-1', + dispose: () => { + if (replacementDisposed) return; + replacementDisposed = true; + positions.release(replacement); + }, + }); + + expect(positions.inherit(replacement, predecessor, host)).toBe(true); + expect(positions.claim(replacement)).toBe(true); + expect(store.promote(replacement)).toBe(true); + expect(frame.isConnected).toBe(false); + expect(host.style.getPropertyValue('position')).toBe('relative'); + + expect(store.release(replacement)).toBe(true); + expect(host.style.getPropertyValue('position')).toBe(''); + }); + + it('does not restore an adopted APS host style after a publisher replaces it', () => { + const host = document.createElement('div'); + host.id = 'div-1'; + host.style.setProperty('position', 'relative'); + const frame = document.createElement('iframe'); + host.append(frame); + document.body.append(host); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: () => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: () => batch, + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: '', + hostPositionPriority: '', + kind: 'aps' as const, + owner: 'trusted_server' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([Object.freeze({}), frame]), + }); + const store = createCommittedArtifactStore(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + createArtifactHostPositionLeaseRegistry(), + document + ); + expect(committed).toBeDefined(); + committed?.arm(); + + host.style.setProperty('position', 'absolute', 'important'); + expect(store.sweep()).toBe(1); + expect(frame.isConnected).toBe(false); + expect(host.style.getPropertyValue('position')).toBe('absolute'); + expect(host.style.getPropertyPriority('position')).toBe('important'); + }); + it('rolls back prepared resources without unbound disposer failures', () => { const release: Array<() => void> = []; const warn = vi.spyOn(log, 'warn').mockImplementation(() => undefined); diff --git a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts index ab897d6d1..7ce3028c1 100644 --- a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts @@ -146,6 +146,7 @@ describe('integration manifest and registration admission', () => { owner: 'trusted_server', outcome: 'failed', targeting: [], + targetingOwnership: [], committedArtifact: 'none', gptToken: null, }, 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 index 337eb8fac..8177f77dc 100644 --- a/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts @@ -134,6 +134,7 @@ describe('shared integration lifecycle module', () => { owner: 'trusted_server', outcome: 'failed', targeting: [], + targetingOwnership: [], committedArtifact: 'none', gptToken: null, }, 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 00f25aa30..882518012 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -194,6 +194,7 @@ function takeoverHandoff() { owner: 'trusted_server', outcome: 'failed', targeting: [], + targetingOwnership: [], committedArtifact: 'none', gptToken: null, }, 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 285c7f7e8..8bda7e0d3 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 @@ -8,7 +8,12 @@ import { PUC_DYNAMIC_OWNER, type PucBridgeOptions, } from '../../src/services/puc_bridge'; -import type { RenderFailureReason, RenderOutcome } from '../../src/services/render'; +import { + bindCommittedArtifactGuard, + createArtifactHostPositionLeaseRegistry, + type RenderFailureReason, + type RenderOutcome, +} from '../../src/services/render'; import type { ReservationClaimResult, ReservationRecognition, @@ -2755,6 +2760,15 @@ describe('Universal Creative bridge dispatcher', () => { const isCurrent = vi.fn(() => true); const resolveApsMountBinding = vi.fn(() => Object.freeze({ + bindArtifact: () => + Object.freeze({ + commit: () => true, + finalize: () => undefined, + isCurrent: () => true, + previousArtifact: undefined, + release: () => undefined, + rollback: () => undefined, + }), bindingEpoch: Object.freeze({ binding: 1 }), cycle: Object.freeze({ isRetired: () => false }), element: topSlot, @@ -2779,7 +2793,9 @@ describe('Universal Creative bridge dispatcher', () => { mountAps: (input) => renderPucApsAttempt({ ...input, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces: bootstraps, + hostPositions: createArtifactHostPositionLeaseRegistry(), nonces: renderers, publisherOrigin: window.location.origin, }), 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 b4fbd6ed9..89b608fa8 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -16,6 +16,9 @@ import { resolveApsRendererV1Url, } from '../../src/integrations/aps/render'; import { + bindCommittedArtifactGuard, + bindCommittedArtifactRetirement, + createArtifactHostPositionLeaseRegistry, createCommittedArtifactStore, createBootstrapNonceRegistry, createRenderAttempt, @@ -318,6 +321,17 @@ function artifact( }); } +function apsArtifactBinding() { + return Object.freeze({ + commit: vi.fn(() => true), + finalize: vi.fn(), + isCurrent: vi.fn(() => true), + previousArtifact: undefined, + release: vi.fn(), + rollback: vi.fn(), + }); +} + function attempt( scope = owner(), options: Partial[0]> = {} @@ -1195,6 +1209,7 @@ describe('direct APS attempt rendering', () => { expect( renderDirectApsAttempt({ attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces: bootstraps, container: document.getElementById('fictional-slot')!, messaging, @@ -1313,6 +1328,7 @@ describe('direct APS attempt rendering', () => { const container = document.getElementById('fictional-slot')!; const mounted = renderDirectApsAttempt({ attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces: bootstraps, container, messaging, @@ -1653,6 +1669,7 @@ describe('direct APS attempt rendering', () => { expect( renderDirectApsAttempt({ attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, bootstrapNonces: bootstraps, container, messaging: createBrowserMessagingAdapter(target), @@ -1692,7 +1709,9 @@ describe('direct APS attempt rendering', () => { it('keeps a PUC APS overlay hidden until completion and preserves the slot host', () => { document.body.innerHTML = '
GAM content
'; const scope = owner(); - const render = attempt(scope); + const artifactStore = createCommittedArtifactStore(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); + const render = attempt(scope, { artifacts: artifactStore }); expect(render.beginGamClaim()).toBe(true); expect(render.admitClaimedWinner(claimed(render, scope, DIRECT_APS_SOURCE))).toBe(true); expect(render.ownerClaimed()).toBe(true); @@ -1721,14 +1740,18 @@ describe('direct APS attempt rendering', () => { const rendererPort = browserMessagePort(); const isBindingCurrent = vi.fn(() => true); const onArtifactTransferred = vi.fn(); + const artifactBinding = apsArtifactBinding(); try { expect( renderPucApsAttempt({ attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, baseArtifact, + bindArtifact: () => artifactBinding, bootstrapNonces: bootstraps, container, + hostPositions, isBindingCurrent, messaging, nonces: renderers, @@ -1783,9 +1806,22 @@ describe('direct APS attempt rendering', () => { }); expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + expect(artifactBinding.commit).toHaveBeenCalledOnce(); + expect(artifactBinding.finalize).toHaveBeenCalledOnce(); + expect(artifactBinding.release).not.toHaveBeenCalled(); + expect(artifactBinding.rollback).not.toHaveBeenCalled(); expect(frame.style.visibility).toBe('visible'); expect(container.querySelector('span')?.textContent).toBe('GAM content'); expect(baseArtifact.dispose).not.toHaveBeenCalled(); + + container.style.setProperty('position', 'absolute', 'important'); + expect(artifactStore.sweep()).toBe(1); + expect(frame.isConnected).toBe(false); + expect(container.querySelector('span')?.textContent).toBe('GAM content'); + expect(container.style.getPropertyValue('position')).toBe('absolute'); + expect(container.style.getPropertyPriority('position')).toBe('important'); + expect(baseArtifact.dispose).toHaveBeenCalledOnce(); + expect(artifactBinding.release).toHaveBeenCalledOnce(); } finally { bootstraps.dispose(); renderers.dispose(); @@ -1793,6 +1829,150 @@ describe('direct APS attempt rendering', () => { } }); + it('transfers one host-position lease across consecutive accepted PUC overlays', () => { + document.body.innerHTML = '
publisher
'; + const generation = Object.freeze({}); + const artifactStore = createCommittedArtifactStore(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); + const container = document.getElementById('fictional-slot')!; + let physicalArtifact: CommittedRenderArtifact | undefined; + const resources: Array<{ dispose: () => void }> = []; + + const bindArtifact = (candidate: CommittedRenderArtifact) => { + const previousArtifact = physicalArtifact; + let phase: 'prepared' | 'committed' | 'finalized' | 'released' | 'rolled_back' = 'prepared'; + return Object.freeze({ + commit: (): boolean => { + if (phase !== 'prepared' || physicalArtifact !== previousArtifact) return false; + physicalArtifact = candidate; + phase = 'committed'; + return true; + }, + finalize: (): void => { + if (phase === 'committed') phase = 'finalized'; + }, + isCurrent: (): boolean => + (phase === 'committed' || phase === 'finalized') && physicalArtifact === candidate, + previousArtifact, + release: (): void => { + if (phase === 'released' || phase === 'rolled_back') return; + if (physicalArtifact === candidate) { + physicalArtifact = phase === 'committed' ? previousArtifact : undefined; + } + phase = 'released'; + }, + rollback: (): void => { + if (phase === 'committed' && physicalArtifact === candidate) { + physicalArtifact = previousArtifact; + } + phase = 'rolled_back'; + }, + }); + }; + + const mountAndComplete = (index: number): CommittedRenderArtifact => { + const scope = owner(indexedAttemptId(index), 'fictional-slot', generation); + const render = attempt(scope, { artifacts: artifactStore }); + expect(render.beginGamClaim()).toBe(true); + expect(render.admitClaimedWinner(claimed(render, scope, DIRECT_APS_SOURCE))).toBe(true); + expect(render.ownerClaimed()).toBe(true); + expect(render.ownerRegistered()).toBe(true); + const bootstraps = createBootstrapNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedBootstrapNonce(index) }), + }); + const renderers = createRendererNonceRegistry({ + mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(index) }), + }); + resources.push(bootstraps, renderers); + let captureListener: ((event: MessageEvent) => void) | undefined; + const rendererPort = browserMessagePort(); + expect( + renderPucApsAttempt({ + attempt: render, + baseArtifact: artifact(scope, 'puc'), + bindArtifact, + bindArtifactGuard: bindCommittedArtifactGuard, + bootstrapNonces: bootstraps, + container, + hostPositions, + isBindingCurrent: () => true, + messaging: createBrowserMessagingAdapter({ + addEventListener: ( + _type: 'message', + listener: (event: MessageEvent) => void, + _capture: true + ) => { + captureListener = listener; + }, + removeEventListener: vi.fn(), + }), + nonces: renderers, + onArtifactTransferred: vi.fn(), + publisherOrigin: window.location.origin, + }) + ).toBe(true); + const frames = [...container.querySelectorAll('iframe')]; + const frame = frames[frames.length - 1]!; + const source = frame.contentWindow!; + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: indexedBootstrapNonce(index), + }), + origin: 'null', + ports: [], + source, + } as unknown as MessageEvent); + captureListener?.({ + data: JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce: indexedBootstrapNonce(index), + rendererNonce: indexedRendererNonce(index), + }), + origin: 'null', + ports: [rendererPort], + source, + } as unknown as MessageEvent); + rendererPort.emit({ + message: 'TS APS Document Accepted', + version: 1, + nonce: indexedRendererNonce(index), + }); + rendererPort.emit({ + message: 'TS APS Render Completed', + version: 1, + nonce: indexedRendererNonce(index), + }); + expect(render.snapshot().outcome).toEqual({ outcome: 'accepted' }); + const committed = artifactStore.current('fictional-slot'); + expect(committed).toBe(physicalArtifact); + if (!committed) throw new Error('should promote the APS overlay'); + return committed; + }; + + try { + const first = mountAndComplete(51); + expect(container.style.position).toBe('relative'); + const second = mountAndComplete(52); + expect(second).not.toBe(first); + expect(first.dispose).toBeTypeOf('function'); + expect(container.style.position).toBe('relative'); + expect(container.querySelectorAll('iframe')).toHaveLength(1); + expect(container.querySelector('span')?.textContent).toBe('publisher'); + + expect(artifactStore.release(second)).toBe(true); + expect(container.style.position).toBe(''); + expect(container.querySelectorAll('iframe')).toHaveLength(0); + expect(container.querySelector('span')?.textContent).toBe('publisher'); + } finally { + for (const resource of resources) resource.dispose(); + artifactStore.dispose(); + document.body.innerHTML = ''; + } + }); + it('removes only the failed PUC overlay and compare-restores its host position', () => { document.body.innerHTML = '
publisher
'; const scope = owner(); @@ -1809,14 +1989,19 @@ describe('direct APS attempt rendering', () => { mintNonce: () => Object.freeze({ ok: true as const, value: indexedRendererNonce(42) }), }); const container = document.getElementById('fictional-slot')!; + const artifactBinding = apsArtifactBinding(); + const hostPositions = createArtifactHostPositionLeaseRegistry(); try { expect( renderPucApsAttempt({ attempt: render, + bindArtifactGuard: bindCommittedArtifactGuard, baseArtifact, + bindArtifact: () => artifactBinding, bootstrapNonces: bootstraps, container, + hostPositions, isBindingCurrent: () => true, messaging: createBrowserMessagingAdapter({ addEventListener: vi.fn(), @@ -1833,6 +2018,9 @@ describe('direct APS attempt rendering', () => { expect(container.querySelector('span')?.textContent).toBe('publisher'); expect(container.style.position).toBe(''); expect(baseArtifact.dispose).toHaveBeenCalledOnce(); + expect(artifactBinding.commit).not.toHaveBeenCalled(); + expect(artifactBinding.finalize).not.toHaveBeenCalled(); + expect(artifactBinding.release).toHaveBeenCalledOnce(); } finally { bootstraps.dispose(); renderers.dispose(); @@ -2243,7 +2431,7 @@ describe('direct ADM attempt rendering', () => { describe('RenderAttempt state machine', () => { it('implements the exact PUC APS state table and makes invalid/replay transitions inert', () => { const scope = owner(); - const candidate = artifact(scope, 'puc'); + const candidate = artifact(scope, 'aps_mount'); const render = attempt(scope); const observed: RenderAttemptState[] = []; @@ -2313,7 +2501,7 @@ describe('RenderAttempt state machine', () => { expect(directAps.beginDirect()).toBe(true); expect(directAps.beginAdm(artifact(directApsOwner))).toBe(false); expect(directAps.beginApsDocument(artifact(directApsOwner, 'puc'))).toBe(false); - expect(directAps.beginApsDocument(artifact(directApsOwner))).toBe(true); + expect(directAps.beginApsDocument(artifact(directApsOwner, 'aps_mount'))).toBe(true); const directAdmOwner = owner(ATTEMPT_TWO); const directAdm = attempt(directAdmOwner); @@ -2331,7 +2519,7 @@ describe('RenderAttempt state machine', () => { expect(pucAps.ownerRegistered()).toBe(true); expect(pucAps.beginAdm(artifact(pucApsOwner, 'puc'))).toBe(false); expect(pucAps.beginApsDocument(artifact(pucApsOwner))).toBe(false); - expect(pucAps.beginApsDocument(artifact(pucApsOwner, 'puc'))).toBe(true); + expect(pucAps.beginApsDocument(artifact(pucApsOwner, 'aps_mount'))).toBe(true); }); it('admits a claimed winner only through the exact one-shot source/context claim', () => { @@ -2474,12 +2662,12 @@ describe('RenderAttempt state machine', () => { case 'waiting_for_document': render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); render.beginDirect(); - render.beginApsDocument(artifact(scope)); + render.beginApsDocument(artifact(scope, 'aps_mount')); break; case 'waiting_for_aps_completion': render.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); render.beginDirect(); - render.beginApsDocument(artifact(scope)); + render.beginApsDocument(artifact(scope, 'aps_mount')); render.apsDocumentAccepted(); break; case 'waiting_for_adm': @@ -2507,7 +2695,6 @@ describe('RenderAttempt state machine', () => { }; const invoke = (render: RenderAttempt, transition: Transition): boolean => { - const kind = render.snapshot().state === 'waiting_for_insertion' ? 'puc' : 'direct_iframe'; switch (transition) { case 'admit_direct': return render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT); @@ -2522,9 +2709,14 @@ describe('RenderAttempt state machine', () => { case 'begin_direct': return render.beginDirect(); case 'begin_aps_document': - return render.beginApsDocument(artifact(render, kind)); + return render.beginApsDocument(artifact(render, 'aps_mount')); case 'begin_adm': - return render.beginAdm(artifact(render, kind)); + return render.beginAdm( + artifact( + render, + render.snapshot().state === 'waiting_for_insertion' ? 'puc' : 'direct_iframe' + ) + ); case 'aps_document_accepted': return render.apsDocumentAccepted(); case 'accept': @@ -2562,7 +2754,7 @@ describe('RenderAttempt state machine', () => { expect(render.winnerContext).toBe(WINNER_CONTEXT); expect(render.admitDirectWinner(ADM_SOURCE, WINNER_CONTEXT)).toBe(false); - const candidate = artifact(scope); + const candidate = artifact(scope, 'aps_mount'); expect(render.beginDirect()).toBe(true); expect(render.beginApsDocument(candidate)).toBe(true); expect(render.apsDocumentAccepted()).toBe(true); @@ -2654,7 +2846,7 @@ describe('RenderAttempt state machine', () => { const documentAttempt = attempt(documentOwner); documentAttempt.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); documentAttempt.beginDirect(); - documentAttempt.beginApsDocument(artifact(documentOwner)); + documentAttempt.beginApsDocument(artifact(documentOwner, 'aps_mount')); vi.advanceTimersByTime(3_000); expect(documentAttempt.snapshot().outcome).toEqual({ outcome: 'failed', @@ -2665,7 +2857,7 @@ describe('RenderAttempt state machine', () => { const completion = attempt(completionOwner); completion.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); completion.beginDirect(); - completion.beginApsDocument(artifact(completionOwner)); + completion.beginApsDocument(artifact(completionOwner, 'aps_mount')); completion.apsDocumentAccepted(); vi.advanceTimersByTime(10_000); expect(completion.snapshot().outcome).toEqual({ @@ -2691,7 +2883,7 @@ describe('RenderAttempt state machine', () => { transitionReference.current = transitionAttempt; transitionAttempt.admitDirectWinner(APS_SOURCE, WINNER_CONTEXT); transitionAttempt.beginDirect(); - transitionAttempt.beginApsDocument(artifact(transitionOwner)); + transitionAttempt.beginApsDocument(artifact(transitionOwner, 'aps_mount')); clearReenters = true; expect(transitionAttempt.apsDocumentAccepted()).toBe(true); @@ -2887,6 +3079,27 @@ describe('RenderAttempt state machine', () => { }); describe('committed artifact ownership', () => { + it('retires a lost guarded artifact synchronously and exactly once', () => { + const store = createCommittedArtifactStore(); + const candidate = artifact(owner()); + const retireAssociation = vi.fn(); + let current = true; + expect(bindCommittedArtifactGuard(candidate, () => current)).toBe(true); + expect(bindCommittedArtifactRetirement(candidate, retireAssociation)).toBe(true); + expect(bindCommittedArtifactRetirement(candidate, vi.fn())).toBe(false); + expect(store.promote(candidate)).toBe(true); + + expect(store.sweep()).toBe(0); + current = false; + expect(store.sweep()).toBe(1); + expect(store.current(candidate.slot)).toBeUndefined(); + expect(retireAssociation).toHaveBeenCalledOnce(); + expect(candidate.dispose).toHaveBeenCalledOnce(); + expect(store.sweep()).toBe(0); + expect(candidate.dispose).toHaveBeenCalledOnce(); + expect(retireAssociation).toHaveBeenCalledOnce(); + }); + it('promotes before attempt disposal, preserves accepted DOM, and disposes the prior artifact before replacement', () => { const store = createCommittedArtifactStore(); const generation = Object.freeze({}); @@ -3124,6 +3337,30 @@ describe('committed artifact ownership', () => { expect(store.current('fictional-slot')).toBe(current); }); + it('does not publish a candidate whose exact association is lost during prior disposal', () => { + const store = createCommittedArtifactStore(); + const generation = Object.freeze({}); + let associationCurrent = true; + const currentOwner = owner(ATTEMPT_ONE, 'fictional-slot', generation); + const current = Object.freeze({ + kind: 'aps_mount' as const, + attemptId: currentOwner.id, + slot: currentOwner.slot, + navigationGeneration: generation, + dispose: vi.fn(() => { + associationCurrent = false; + }), + }); + const candidate = artifact(owner(ATTEMPT_TWO, 'fictional-slot', generation), 'aps_mount'); + expect(bindCommittedArtifactGuard(candidate, () => associationCurrent)).toBe(true); + expect(store.promote(current)).toBe(true); + + expect(store.promote(candidate)).toBe(false); + expect(current.dispose).toHaveBeenCalledOnce(); + expect(store.current('fictional-slot')).toBeUndefined(); + expect(candidate.dispose).not.toHaveBeenCalled(); + }); + it('never publishes after its navigation generation or whole store is disposed', () => { const generation = Object.freeze({}); const navigationStore = createCommittedArtifactStore(); @@ -3262,7 +3499,7 @@ describe('RenderAttempt diagnostics producer', () => { const renderAttempt = attempt(owner(), { publishDiagnostics }); expect(renderAttempt.admitDirectWinner(DIRECT_APS_SOURCE, WINNER_CONTEXT)).toBe(true); expect(renderAttempt.beginDirect()).toBe(true); - const committed = artifact(renderAttempt); + const committed = artifact(renderAttempt, 'aps_mount'); expect(renderAttempt.beginApsDocument(committed)).toBe(true); expect(renderAttempt.apsDocumentAccepted()).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 aa6f4d321..b7c3c6628 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -21,11 +21,26 @@ import { type SlotRegistration, type SlotService, } from '../../src/services/slots'; +import { + bindCommittedArtifactRetirement, + createCommittedArtifactStore, + type CommittedRenderArtifact, +} from '../../src/services/render'; function createNavigation(): NavigationSession { return createRuntimeWithNavigation().navigation; } +function committedArtifact(navigationGeneration: object): CommittedRenderArtifact { + return Object.freeze({ + attemptId: `a1_${'c'.repeat(22)}`, + dispose: vi.fn(), + kind: 'aps_mount' as const, + navigationGeneration, + slot: 'slot', + }); +} + function createRuntimeWithNavigation() { const runtime = createRuntimeSession({ createIdentityIssuer: () => @@ -1023,13 +1038,19 @@ describe('navigation-owned DOM reconciliation', () => { afterEach(() => vi.useRealTimers()); async function completedApsMountCycle( - ownership: 'publisher' | 'trusted_server' = 'trusted_server' + ownership: 'publisher' | 'trusted_server' = 'trusted_server', + disposeCommittedArtifact = vi.fn() ) { const gpt = createGptHarness(); const dom = createReconciliationBoundary(); const element = Object.freeze({ element: 'top-slot' }); dom.put('slot-div', element); - const service = createSlotService({ googletag: gpt.adapter, reconciliation: dom.boundary }); + const service = createSlotService({ + bindCommittedArtifactRetirement, + disposeCommittedArtifact, + googletag: gpt.adapter, + reconciliation: dom.boundary, + }); const navigation = createNavigation(); let slot: object; if (ownership === 'trusted_server') { @@ -1068,7 +1089,7 @@ describe('navigation-owned DOM reconciliation', () => { slot, }); await expect(request.result).resolves.toMatchObject({ status: 'rendered' }); - return { dom, element, intentId, navigation, service, slot }; + return { disposeCommittedArtifact, dom, element, intentId, navigation, service, slot }; } it('claims one exact completed TS cycle and invalidates it on DOM replacement', async () => { @@ -1094,6 +1115,70 @@ describe('navigation-owned DOM reconciliation', () => { ).toBeUndefined(); }); + it('retires a committed overlay for a publisher cycle but retains it for an exact TS replacement', async () => { + const publisher = await completedApsMountCycle('publisher'); + const publisherBinding = publisher.service.resolveApsMountBinding( + publisher.navigation.generation, + 'slot', + publisher.intentId + )!; + const publisherArtifact = committedArtifact(publisher.navigation.generation); + expect(publisherBinding.bindArtifact(publisherArtifact)?.commit()).toBe(true); + + publisher.service.handleGptEvent('slotRequested', { slot: publisher.slot }); + expect(publisher.disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + publisher.navigation.generation, + 'slot', + publisherArtifact + ); + + const trusted = await completedApsMountCycle(); + const trustedBinding = trusted.service.resolveApsMountBinding( + trusted.navigation.generation, + 'slot', + trusted.intentId + )!; + const trustedArtifact = committedArtifact(trusted.navigation.generation); + expect(trustedBinding.bindArtifact(trustedArtifact)?.commit()).toBe(true); + const replacement = trusted.service.request({ + expectedSlot: trusted.slot, + intentId: 'exact-ts-replacement', + navigationGeneration: trusted.navigation.generation, + operation: 'refresh', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + + trusted.service.handleGptEvent('slotRequested', { slot: trusted.slot }); + expect(trusted.disposeCommittedArtifact).not.toHaveBeenCalled(); + replacement.dispose(); + expect(trusted.disposeCommittedArtifact).not.toHaveBeenCalled(); + }); + + it('restores the prior physical artifact when a provisional replacement is released', async () => { + const h = await completedApsMountCycle(); + const prior = committedArtifact(h.navigation.generation); + expect(h.service.adoptCommittedArtifact(h.navigation.generation, 'slot', prior)).toBe(true); + const binding = h.service.resolveApsMountBinding(h.navigation.generation, 'slot', h.intentId)!; + const replacement = Object.freeze({ + ...committedArtifact(h.navigation.generation), + attemptId: `a1_${'d'.repeat(22)}`, + }); + const admission = binding.bindArtifact(replacement)!; + expect(admission.commit()).toBe(true); + + admission.release(); + admission.rollback(); + h.service.handleGptEvent('slotRequested', { slot: h.slot }); + + expect(h.disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + h.navigation.generation, + 'slot', + prior + ); + }); + it('refuses missing, ambiguous, disconnected, and stale APS mount bindings', async () => { const ambiguous = await completedApsMountCycle(); ambiguous.dom.replaceAmbiguously('slot-div', [ @@ -1207,6 +1292,24 @@ describe('navigation-owned DOM reconciliation', () => { ); }); + it('clears an adopted physical artifact association when its store retires the exact artifact', () => { + const gpt = createGptHarness(); + const service = createSlotService({ + bindCommittedArtifactRetirement, + googletag: gpt.adapter, + }); + const navigation = createNavigation(); + bindTrustedSlot(service, navigation); + const store = createCommittedArtifactStore(); + const first = committedArtifact(navigation.generation); + const second = committedArtifact(navigation.generation); + expect(store.promote(first)).toBe(true); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', first)).toBe(true); + + expect(store.release(first)).toBe(true); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', second)).toBe(true); + }); + it('debounces an exact disconnected TS slot through the 249/250 ms boundary', async () => { vi.useFakeTimers(); vi.setSystemTime(0); @@ -1217,6 +1320,7 @@ describe('navigation-owned DOM reconciliation', () => { }); dom.put('slot-div', {}); const service = createSlotService({ + bindCommittedArtifactRetirement, disposeCommittedArtifact, googletag: gpt.adapter, now: () => Date.now(), @@ -1224,6 +1328,8 @@ describe('navigation-owned DOM reconciliation', () => { }); const navigation = createNavigation(); bindTrustedSlot(service, navigation); + const artifact = committedArtifact(navigation.generation); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', artifact)).toBe(true); service.activate(); dom.replace('slot-div', {}); @@ -1239,7 +1345,11 @@ describe('navigation-owned DOM reconciliation', () => { [[300, 250]], 'slot-div' ); - expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigation.generation, + 'slot', + artifact + ); const request = service.request({ intentId: 'after-rebind', @@ -1487,6 +1597,7 @@ describe('navigation-owned DOM reconciliation', () => { const disposeCommittedArtifact = vi.fn(); dom.put('slot-div', {}); const service = createSlotService({ + bindCommittedArtifactRetirement, disposeCommittedArtifact, googletag: gpt.adapter, now: () => Date.now(), @@ -1494,12 +1605,18 @@ describe('navigation-owned DOM reconciliation', () => { }); const navigation = createNavigation(); bindTrustedSlot(service, navigation); + const artifact = committedArtifact(navigation.generation); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', artifact)).toBe(true); service.activate(); dom.disconnect('slot-div'); await vi.advanceTimersByTimeAsync(5_000); - expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigation.generation, + 'slot', + artifact + ); expect(disposeCommittedArtifact.mock.invocationCallOrder[0]).toBeLessThan( gpt.destroySlots.mock.invocationCallOrder[0] as number ); @@ -3247,11 +3364,14 @@ describe('physical GPT cycles', () => { const harness = createGptHarness(); const disposeCommittedArtifact = vi.fn(); const service = createSlotService({ + bindCommittedArtifactRetirement, disposeCommittedArtifact, googletag: harness.adapter, }); const navigation = createNavigation(); const oldSlot = bindTrustedSlot(service, navigation); + const artifact = committedArtifact(navigation.generation); + expect(service.adoptCommittedArtifact(navigation.generation, 'slot', artifact)).toBe(true); const first = service.request({ intentId: 'completion-timeout', navigationGeneration: navigation.generation, @@ -3269,7 +3389,11 @@ describe('physical GPT cycles', () => { throw new Error('Expected completion-timeout replacement'); } expect(harness.destroySlots).toHaveBeenCalledExactlyOnceWith([oldSlot]); - expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith(navigation.generation, 'slot'); + expect(disposeCommittedArtifact).toHaveBeenCalledExactlyOnceWith( + navigation.generation, + 'slot', + artifact + ); service.handleGptEvent('slotRenderEnded', { isEmpty: false, 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 022abf5d3..a3dfd09f2 100644 --- a/crates/trusted-server-js/lib/test/services/targeting.test.ts +++ b/crates/trusted-server-js/lib/test/services/targeting.test.ts @@ -17,6 +17,63 @@ function createTargetingHarness(initial: Record = {}) } describe('owner-aware targeting journal', () => { + it('adopts an installed first-display value without rewriting it and restores its predecessor', () => { + const service = createTargetingService(); + const slot = {}; + const targeting = createTargetingHarness({ key: ['trusted'] }); + + const frame = service.adopt( + slot, + 'key', + 'trusted', + Object.freeze(['publisher']), + 'first-display-owner', + targeting + ); + + expect(frame).toBeDefined(); + expect(targeting.setTargeting).not.toHaveBeenCalled(); + frame?.release(); + expect(targeting.values.get('key')).toEqual(['publisher']); + }); + + it('rejects adoption when its targeting read observes a same-value publisher write', async () => { + const values = new Map([['key', ['trusted']]]); + let reenter = true; + const slot = { + clearTargeting: vi.fn((key?: string) => { + if (key === undefined) values.clear(); + else values.delete(key); + }), + getTargeting: vi.fn((key: string) => { + if (reenter) { + reenter = false; + slot.setTargeting(key, 'trusted'); + } + return 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 ownership = await adapter.run((gpt) => + service.adopt(slot, 'key', 'trusted', Object.freeze(['publisher']), 'handoff-owner', { + clearTargeting: (key) => gpt.clearTargeting(slot, key), + getTargeting: (key) => gpt.getTargeting(slot, key), + setTargeting: (key, value) => gpt.setTargeting(slot, key, value), + }) + ).result; + + expect(ownership).toBeUndefined(); + ownership?.release(); + expect(values.get('key')).toEqual(['trusted']); + expect(service.snapshotForTest()).toEqual({ frames: 0, slots: 0 }); + }); + it('restores the exact publisher predecessor after the current TS owner releases', () => { const service = createTargetingService(); const slot = {}; From dedab2a89263592325f1e2f2102cdf13e1e5799e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:59:11 -0700 Subject: [PATCH 826/844] Hard-cut APS materialization and gate page delivery --- .../tests/routes.rs | 16 +- .../tests/routes.rs | 16 +- .../trusted-server-adapter-fastly/src/app.rs | 19 +- .../tests/routes.rs | 16 +- .../src/ad_templates/expected.rs | 16 +- .../src/commands/audit/ad_templates.rs | 3 +- .../src/commands/audit/generate/mod.rs | 8 +- .../src/commands/audit/generate/slot_toml.rs | 56 +- .../tests/config_env_overlay.rs | 18 +- .../benches/html_processor_bench.rs | 2 + .../src/auction/endpoints.rs | 90 +++ .../src/creative_opportunities.rs | 50 +- .../src/integrations/aps.rs | 57 +- .../generated/aps_renderer_bootstrap_v1.html | 66 --- .../generated/aps_renderer_bootstrap_v2.html | 126 +++++ .../src/integrations/gpt.rs | 35 ++ .../src/integrations/registry.rs | 14 +- crates/trusted-server-core/src/publisher.rs | 520 +++++------------- crates/trusted-server-core/src/settings.rs | 2 +- crates/trusted-server-core/src/tsjs.rs | 25 +- .../browser/helpers/gam-test-network.ts | 2 +- .../tests/shared/aps-puc-lifecycle.spec.ts | 4 +- .../browser/tests/shared/aps-real-gam.spec.ts | 2 +- .../browser/tests/shared/aps-renderer.spec.ts | 103 ++-- .../tests/aps_runner_proxy.rs | 20 +- .../tests/environments/cloudflare.rs | 2 +- .../tests/parity.rs | 6 +- crates/trusted-server-js/build.rs | 1 - crates/trusted-server-js/lib/build-all.mjs | 17 +- .../lib/scripts/check-bundle-budgets.mjs | 8 +- .../scripts/check-hard-cutover-absence.mjs | 4 +- .../lib/src/adapters/messaging.ts | 34 +- .../lib/src/composition/browser_test.ts | 8 +- .../src/first_display/adapters/googletag.ts | 92 ++-- .../lib/src/first_display/agent.ts | 33 +- .../src/first_display/leaf/aps_protocol.ts | 83 ++- .../lib/src/first_display/render_bridge.ts | 143 +++-- .../lib/src/integrations/aps/module.ts | 4 +- .../lib/src/integrations/aps/render.ts | 37 +- .../lib/src/integrations/gpt/later.ts | 2 + .../src/integrations/render_runtime/module.ts | 2 +- .../src/kernel/contracts/message_protocol.ts | 2 +- .../lib/src/kernel/release_catalog.ts | 4 - .../lib/src/shared/aps_documents.ts | 2 + .../shared/integration_config_validators.ts | 16 +- .../lib/test/adapters/messaging.test.ts | 62 ++- .../lib/test/build/release-v1.test.mjs | 4 +- .../lib/test/composition/browser.test.ts | 7 +- .../test/composition/maximal-runtime.test.ts | 4 +- .../test/contract/aps-renderer-es5.test.mjs | 143 ++--- .../test/first_display/render_bridge.test.ts | 44 +- .../lib/test/first_display/slices.test.ts | 2 +- .../lib/test/integrations/aps/module.test.ts | 2 +- .../lib/test/integrations/gpt/later.test.ts | 25 +- .../lib/test/integrations/gpt/module.test.ts | 2 +- .../render_runtime/module.test.ts | 2 +- .../lib/test/kernel/runtime.test.ts | 4 +- .../lib/test/services/puc_bridge.test.ts | 2 +- .../lib/test/services/render.test.ts | 36 +- docs/guide/auction-orchestration.md | 2 +- docs/guide/configuration.md | 15 +- docs/guide/integrations/aps.md | 6 +- ...8-04-aps-tsjs-resilience-implementation.md | 63 ++- ...s-render-fix-and-tsjs-resilience-design.md | 106 ++-- scripts/generate-aps-renderer-contract.mjs | 150 ++++- 65 files changed, 1377 insertions(+), 1090 deletions(-) delete mode 100644 crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html create mode 100644 crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 31e38c0df..045458bcd 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -303,7 +303,7 @@ async fn tsjs_wrong_methods_are_local_no_store_404s() { async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = Request::builder() .method("GET") - .uri("/integrations/aps/renderer/v1") + .uri("/integrations/aps/renderer/v2") .header("authorization", "Bearer must-not-reach-publisher") .body(AxumBody::empty()) .expect("should build APS renderer request"); @@ -329,18 +329,18 @@ async fn aps_cutover_renderer_and_family_failures_are_local() { .expect("renderer body should be bounded"); let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); assert!(body.contains("TS APS Bootstrap Ready")); - assert!(!body.contains("/integrations/aps/runner.js")); - assert!(!body.contains("aaxResponse")); - assert!(!body.contains("creativeUrl")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); assert!(!body.contains("client.aps.amazon-adsystem.com")); for (method, path, expected) in [ ("POST", "/integrations/aps/runner.js", 405), - ("TRACE", "/integrations/aps/renderer/v1", 405), - ("CONNECT", "/integrations/aps/renderer/v1", 405), - ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("TRACE", "/integrations/aps/renderer/v2", 405), + ("CONNECT", "/integrations/aps/renderer/v2", 405), + ("PROPFIND", "/integrations/aps/renderer/v2", 405), ("GET", "/integrations/aps/renderer", 404), - ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/renderer/v1", 404), ("GET", "/integrations/aps/runner/v1.js", 404), ("GET", "/integrations/aps", 404), ] { diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 156506536..77a4b908b 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -124,7 +124,7 @@ fn routes_build_without_panic() { async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = request_builder() .method("GET") - .uri("/integrations/aps/renderer/v1") + .uri("/integrations/aps/renderer/v2") .header("authorization", "Bearer must-not-reach-publisher") .body(edgezero_core::body::Body::empty()) .expect("should build APS renderer request"); @@ -142,18 +142,18 @@ async fn aps_cutover_renderer_and_family_failures_are_local() { let body = response.into_body().into_bytes().unwrap_or_default(); let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); assert!(body.contains("TS APS Bootstrap Ready")); - assert!(!body.contains("/integrations/aps/runner.js")); - assert!(!body.contains("aaxResponse")); - assert!(!body.contains("creativeUrl")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); assert!(!body.contains("client.aps.amazon-adsystem.com")); for (method, path, expected) in [ ("POST", "/integrations/aps/runner.js", 405), - ("TRACE", "/integrations/aps/renderer/v1", 405), - ("CONNECT", "/integrations/aps/renderer/v1", 405), - ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("TRACE", "/integrations/aps/renderer/v2", 405), + ("CONNECT", "/integrations/aps/renderer/v2", 405), + ("PROPFIND", "/integrations/aps/renderer/v2", 405), ("GET", "/integrations/aps/renderer", 404), - ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/renderer/v1", 404), ("GET", "/integrations/aps/runner/v1.js", 404), ("GET", "/integrations/aps", 404), ] { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 8fe8d2067..0bf502ca2 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1372,11 +1372,8 @@ mod tests { use bytes::Bytes; use edgezero_core::app::Hooks as _; use edgezero_core::body::Body; - use edgezero_core::context::RequestContext; - use edgezero_core::env_config::EnvConfig; use edgezero_core::http::{Method, Response, StatusCode, header, request_builder}; use edgezero_core::key_value_store::NoopKvStore; - use edgezero_core::params::PathParams; use edgezero_core::router::RouterService; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Mutex; @@ -1582,7 +1579,7 @@ mod tests { #[cfg(feature = "aps-runner-proxy-integration-test")] #[test] fn aps_cutover_renderer_and_family_failures_are_local() { - let response = route_reserved(empty_request(Method::GET, "/integrations/aps/renderer/v1")); + let response = route_reserved(empty_request(Method::GET, "/integrations/aps/renderer/v2")); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::CONTENT_TYPE], @@ -1596,9 +1593,9 @@ mod tests { let body = response.into_body().into_bytes().unwrap_or_default(); let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); assert!(body.contains("TS APS Bootstrap Ready")); - assert!(!body.contains("/integrations/aps/runner.js")); - assert!(!body.contains("aaxResponse")); - assert!(!body.contains("creativeUrl")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); assert!(!body.contains("client.aps.amazon-adsystem.com")); for (method, path, expected) in [ @@ -1609,17 +1606,17 @@ mod tests { ), ( Method::TRACE, - "/integrations/aps/renderer/v1", + "/integrations/aps/renderer/v2", StatusCode::METHOD_NOT_ALLOWED, ), ( Method::CONNECT, - "/integrations/aps/renderer/v1", + "/integrations/aps/renderer/v2", StatusCode::METHOD_NOT_ALLOWED, ), ( Method::from_bytes(b"PROPFIND").expect("PROPFIND should be a valid method"), - "/integrations/aps/renderer/v1", + "/integrations/aps/renderer/v2", StatusCode::METHOD_NOT_ALLOWED, ), ( @@ -1629,7 +1626,7 @@ mod tests { ), ( Method::GET, - "/integrations/aps/renderer/v2", + "/integrations/aps/renderer/v1", StatusCode::NOT_FOUND, ), ( diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 03fa8e074..1f0693987 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -78,7 +78,7 @@ fn routes_build_without_panic() { async fn aps_cutover_renderer_and_family_failures_are_local() { let renderer = request_builder() .method("GET") - .uri("/integrations/aps/renderer/v1") + .uri("/integrations/aps/renderer/v2") .header("authorization", "Bearer must-not-reach-publisher") .body(edgezero_core::body::Body::empty()) .expect("should build APS renderer request"); @@ -96,18 +96,18 @@ async fn aps_cutover_renderer_and_family_failures_are_local() { let body = response.into_body().into_bytes().unwrap_or_default(); let body = std::str::from_utf8(&body).expect("renderer should be UTF-8"); assert!(body.contains("TS APS Bootstrap Ready")); - assert!(!body.contains("/integrations/aps/runner.js")); - assert!(!body.contains("aaxResponse")); - assert!(!body.contains("creativeUrl")); + assert!(body.contains("TS APS Bootstrap Configure")); + assert!(body.contains("/integrations/aps/runner.js")); + assert!(body.contains("data:text/html;charset=utf-8,")); assert!(!body.contains("client.aps.amazon-adsystem.com")); for (method, path, expected) in [ ("POST", "/integrations/aps/runner.js", 405), - ("TRACE", "/integrations/aps/renderer/v1", 405), - ("CONNECT", "/integrations/aps/renderer/v1", 405), - ("PROPFIND", "/integrations/aps/renderer/v1", 405), + ("TRACE", "/integrations/aps/renderer/v2", 405), + ("CONNECT", "/integrations/aps/renderer/v2", 405), + ("PROPFIND", "/integrations/aps/renderer/v2", 405), ("GET", "/integrations/aps/renderer", 404), - ("GET", "/integrations/aps/renderer/v2", 404), + ("GET", "/integrations/aps/renderer/v1", 404), ("GET", "/integrations/aps/runner/v1.js", 404), ("GET", "/integrations/aps", 404), ] { diff --git a/crates/trusted-server-cli/src/ad_templates/expected.rs b/crates/trusted-server-cli/src/ad_templates/expected.rs index 9392963ff..1f210dda6 100644 --- a/crates/trusted-server-cli/src/ad_templates/expected.rs +++ b/crates/trusted-server-cli/src/ad_templates/expected.rs @@ -152,7 +152,8 @@ mod tests { .collect::>() .join(", "); let toml = format!( - "gam_network_id = \"123\"\n\ + "enabled = true\n\ + gam_network_id = \"123\"\n\ \n\ [[slot]]\n\ id = \"atf\"\n\ @@ -202,7 +203,8 @@ mod tests { #[test] fn expected_slots_default_resolution_without_overrides() { - let toml = "gam_network_id = \"42\"\n\ + let toml = "enabled = true\n\ + gam_network_id = \"42\"\n\ \n\ [[slot]]\n\ id = \"footer\"\n\ @@ -223,7 +225,8 @@ mod tests { #[test] fn expected_slots_render_section_templates_per_path() { - let toml = "gam_network_id = \"99999\"\n\ + let toml = "enabled = true\n\ + gam_network_id = \"99999\"\n\ section_root = \"homepage\"\n\ \n\ [[slot]]\n\ @@ -256,9 +259,10 @@ mod tests { #[test] fn expected_slots_omit_dynamic_template_the_runtime_cannot_render() { // A `{section}` template that renders past GAM's 100-byte unit-path - // limit. The runtime omits this slot for the request path, so diagnostics - // must not match it against a truncated or otherwise different path. - let toml = "gam_network_id = \"99999\"\n\ + // limit. `validate_runtime` rejects this config, so the verifier reports + // the slot as unconfirmable rather than matching a truncated path. + let toml = "enabled = true\n\ + gam_network_id = \"99999\"\n\ section_root = \"homepage\"\n\ \n\ [[slot]]\n\ diff --git a/crates/trusted-server-cli/src/commands/audit/ad_templates.rs b/crates/trusted-server-cli/src/commands/audit/ad_templates.rs index cb11a0a0c..ac99f101d 100644 --- a/crates/trusted-server-cli/src/commands/audit/ad_templates.rs +++ b/crates/trusted-server-cli/src/commands/audit/ad_templates.rs @@ -625,7 +625,8 @@ mod tests { } fn news_config() -> CreativeOpportunitiesConfig { - let toml = "gam_network_id = \"123\"\n\ + let toml = "enabled = true\n\ + gam_network_id = \"123\"\n\ \n\ [[slot]]\n\ id = \"atf\"\n\ diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs index b91bc261f..33e529a5d 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs @@ -2123,7 +2123,7 @@ mod tests { let config_path = temp.path().join("trusted-server.toml"); fs::write( &config_path, - "[creative_opportunities]\ngam_network_id = \"111\"\n", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n", ) .expect("should write config"); // The requested URL redirects; slots are scraped from the final page. @@ -2543,7 +2543,7 @@ mod tests { fn update_slots_rejects_invalid_page_pattern_without_touching_config() { let temp = TempDir::new().expect("should create temp dir"); let config_path = temp.path().join("trusted-server.toml"); - let original = "[creative_opportunities]\ngam_network_id = \"111\"\n"; + let original = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n"; fs::write(&config_path, original).expect("should write config"); let collector = FakeCollector::new(collected_page_with_header_slot()); let mut out = Vec::new(); @@ -2650,7 +2650,7 @@ mod tests { let config_path = temp.path().join("trusted-server.toml"); fs::write( &config_path, - "[creative_opportunities]\ngam_network_id = \"111\"\n", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n", ) .expect("should write config"); let collector = FakeCollector::new(collected_page_with_header_slot()); @@ -2687,7 +2687,7 @@ mod tests { let config_path = temp.path().join("trusted-server.toml"); fs::write( &config_path, - "[creative_opportunities]\ngam_network_id = \"111\"\n", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n", ) .expect("should write config"); let collector = FakeCollector::new(collected_page_with_header_slot()); diff --git a/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs b/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs index b175009fd..077e85a27 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/slot_toml.rs @@ -842,7 +842,7 @@ slot_id = "sidebar" #[test] fn splice_replaces_slots_and_preserves_other_sections() { let existing = "[publisher]\ndomain = \"x\"\n\n\ - [creative_opportunities]\ngam_network_id = \"111\"\nprice_granularity = \"dense\"\n\n\ + [creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\nprice_granularity = \"dense\"\n\n\ [[creative_opportunities.slot]]\nid = \"old\"\ndiv_id = \"old\"\n\ gam_unit_path = \"/111/old\"\npage_patterns = [\"/\"]\n\ formats = [{ width = 300, height = 250 }]\n\n\ @@ -876,8 +876,11 @@ slot_id = "sidebar" } #[test] - fn splice_updates_a_quoted_section_header_structurally() { - let existing = "[\"creative_opportunities\"]\ngam_network_id = \"111\"\n"; + fn splice_rejects_quoted_section_header_instead_of_duplicating_it() { + // A quoted header is valid TOML but the line-based splice does not + // recognise it; appending a second `[creative_opportunities]` would + // produce a document that no longer parses. + let existing = "[\"creative_opportunities\"]\nenabled = true\ngam_network_id = \"111\"\n"; let updated = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect("should update quoted table structurally"); @@ -979,7 +982,7 @@ slot_id = "sidebar" #[test] fn splice_rejects_top_level_inline_creative_opportunities_table() { - let existing = "creative_opportunities = { gam_network_id = \"111\" }\n"; + let existing = "creative_opportunities = { enabled = true, gam_network_id = \"111\" }\n"; let error = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect_err("should refuse a top-level inline table"); @@ -1007,7 +1010,7 @@ slot_id = "sidebar" fn splice_inserts_section_policy_keys_a_config_does_not_have_yet() { // The whole point of `upsert`: every config predating templating lacks // these keys, so a replace-only writer could never add them. - let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n\n\ + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\n\ [auction]\nenabled = true\n"; let out = splice_creative_slots( @@ -1031,7 +1034,7 @@ slot_id = "sidebar" #[test] fn splice_replaces_section_policy_keys_that_are_already_present() { - let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n\ + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\ section_root = \"old\"\nsection_segment = 2\n"; let out = splice_creative_slots( @@ -1057,7 +1060,7 @@ slot_id = "sidebar" // `section_root`/`section_segment` are `deny_unknown_fields` additions: // writing them into a config that does not need them would make it // unloadable by an older binary for no benefit. - let existing = "[creative_opportunities]\ngam_network_id = \"111\"\n"; + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect("should splice"); @@ -1086,6 +1089,30 @@ slot_id = "sidebar" assert_eq!(creative["section_segment"].as_integer(), Some(0)); } + #[test] + fn upsert_keeps_an_inserted_key_inside_the_section_scalar_block() { + // Appending at the end of the section would land the key after a + // subtable, where TOML reads it as part of that subtable instead. + let document = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\n\ + [[creative_opportunities.slot]]\nid = \"a\"\n\ + page_patterns = [\"/\"]\nformats = [{ width = 1, height = 1 }]\n"; + + let out = upsert_key_in_section( + document, + "creative_opportunities", + "section_root", + "section_root = \"homepage\"", + ) + .expect("should insert"); + + let value = toml::from_str::(&out).expect("valid TOML"); + assert_eq!( + value["creative_opportunities"]["section_root"].as_str(), + Some("homepage"), + "the key must belong to the section, not the slot subtable" + ); + } + #[test] fn splice_refuses_fresh_section_without_a_network_id() { // Reachable whenever the scraped unit path has no all-digit leading @@ -1126,6 +1153,7 @@ slot_id = "sidebar" // Mirrors the templated operator shape: section policy scalars in the // head block and a per-slot prebid provider subtable. let existing = "[creative_opportunities]\n\ + enabled = true\n\ gam_network_id = \"111\"\n\ auction_timeout_ms = 2000\n\ section_root = \"homepage\"\n\n\ @@ -1141,6 +1169,7 @@ slot_id = "sidebar" let existing_config = existing_config( &existing .replace("[creative_opportunities]\n", "") + .replacen("enabled = true\n", "", 1) .replace("[[creative_opportunities.slot]]", "[[slot]]") .replace("[creative_opportunities.slot.", "[slot.") .replace("\n[auction]\nenabled = true\n", ""), @@ -1182,7 +1211,7 @@ slot_id = "sidebar" #[test] fn splice_preserves_crlf_line_endings() { - let existing = "[creative_opportunities]\r\ngam_network_id = \"111\"\r\n\r\n\ + let existing = "[creative_opportunities]\r\nenabled = true\r\ngam_network_id = \"111\"\r\n\r\n\ [auction]\r\nenabled = true\r\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) @@ -1372,7 +1401,7 @@ slot_id = "sidebar" fn splice_recognizes_inline_commented_section_header() { // `[creative_opportunities] # comment` is valid TOML; the splice must // update it in place instead of appending a duplicate section. - let existing = "[creative_opportunities] # ad templates\ngam_network_id = \"111\"\n\n\ + let existing = "[creative_opportunities] # ad templates\nenabled = true\ngam_network_id = \"111\"\n\n\ [auction] # flags\nenabled = true\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) @@ -1404,8 +1433,7 @@ slot_id = "sidebar" #[test] fn splice_inserts_when_no_existing_slots() { - let existing = - "[creative_opportunities]\ngam_network_id = \"111\"\n\n[auction]\nenabled = true\n"; + let existing = "[creative_opportunities]\nenabled = true\ngam_network_id = \"111\"\n\n[auction]\nenabled = true\n"; let out = splice_creative_slots(existing, &network_keys("222"), &header_rendered()) .expect("should splice"); @@ -1430,6 +1458,7 @@ slot_id = "sidebar" #[test] fn splice_replaces_inline_slot_array() { let existing = "[creative_opportunities]\n\ + enabled = true\n\ gam_network_id = \"111\"\n\ slot = [{ id = \"old\", div_id = \"old\", gam_unit_path = \"/111/old\", page_patterns = [\"/\"], formats = [{ width = 300, height = 250 }] }]\n\n\ [auction]\nenabled = true\n"; @@ -1453,6 +1482,7 @@ slot_id = "sidebar" #[test] fn splice_replaces_inline_slot_map() { let existing = "[creative_opportunities]\n\ + enabled = true\n\ gam_network_id = \"111\"\n\ slot = { \"0\" = { id = \"old\", div_id = \"old\", gam_unit_path = \"/111/old\", page_patterns = [\"/\"], formats = [{ width = 300, height = 250 }] } }\n"; @@ -1835,7 +1865,7 @@ slot_id = "sidebar" #[test] fn replace_key_handles_inline_commented_headers() { - let document = "[creative_opportunities] # managed\ngam_network_id = \"111\"\n\n\ + let document = "[creative_opportunities] # managed\nenabled = true\ngam_network_id = \"111\"\n\n\ [auction] # flags\nenabled = true\n"; let updated = replace_key_in_section( @@ -1872,7 +1902,7 @@ slot_id = "sidebar" false, ); let doc = format!( - "[creative_opportunities]\ngam_network_id = \"1\"\n{}", + "[creative_opportunities]\nenabled = true\ngam_network_id = \"1\"\n{}", render_slots(&merged) ); diff --git a/crates/trusted-server-cli/tests/config_env_overlay.rs b/crates/trusted-server-cli/tests/config_env_overlay.rs index 45eab5cc9..ee33a3927 100644 --- a/crates/trusted-server-cli/tests/config_env_overlay.rs +++ b/crates/trusted-server-cli/tests/config_env_overlay.rs @@ -47,20 +47,12 @@ fn migrated_project() -> MigratedProject { let mut document = LEGACY_CONFIG .parse::() .expect("should parse legacy integration config"); - // EdgeZero environment overlays cannot create missing TOML leaves, - // so a migrated config must carry every leaf whose environment override is - // expected to take effect. + // EdgeZero environment overlays cannot create missing TOML leaves, so a + // migrated config must carry every leaf that an environment variable can + // override. document["auction"]["rewrite_creatives"] = value(true); document["auction"]["sanitize_creatives"] = value(false); - document["creative_opportunities"]["enabled"] = value(true); - document["creative_opportunities"]["gam_network_id"] = value("123456789"); - document["auction"]["providers"]["pbs-main"] = toml_edit::table(); - document["auction"]["providers"]["pbs-main"]["protocol"] = value("openrtb-2.6"); - document["auction"]["providers"]["pbs-main"]["profile"] = value("standard"); - document["auction"]["providers"]["pbs-main"]["endpoint"] = - value("https://original.example/openrtb2/auction"); - document["auction"]["bidders"]["example-bidder"] = toml_edit::table(); - document["auction"]["bidders"]["example-bidder"]["provider"] = value("pbs-main"); + document["integrations"]["gpt"]["gam_attribution_enabled"] = value(false); fs::write(&config_path, document.to_string()).expect("should write migrated config"); fs::write(&manifest_path, MANIFEST).expect("should write test manifest"); MigratedProject { @@ -165,7 +157,7 @@ fn migrated_config_applies_boolean_environment_overrides() { assert_eq!( envelope["data"]["integrations"]["gpt"]["gam_attribution_enabled"], serde_json::Value::Bool(true), - "pushed config should contain the GAM attribution environment override" + "pushed config should contain the GAM attribution environment override: {envelope}" ); assert_eq!( envelope["data"]["creative_opportunities"]["enabled"], diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 34f0a262a..20993125e 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -17,6 +17,8 @@ fn make_config() -> HtmlProcessorConfig { max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, render_trace_overlay: false, + suppress_datadome_client_side_tag: false, + body_close: BodyCloseInjection::None, } } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index d1c0d60d7..35d73af19 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -771,6 +771,96 @@ mod tests { } } + struct CallRecordingProvider { + called: Arc>, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for CallRecordingProvider { + fn provider_name(&self) -> &'static str { + "call_recording_provider" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + *self.called.lock().expect("should lock provider call flag") = true; + Err(Report::new(TrustedServerError::Auction { + message: "call recorded".to_string(), + })) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("parse_response must not run when the launch returns an error"); + } + + fn timeout_ms(&self) -> u32 { + 100 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some("call-recording-backend".to_string()) + } + } + + #[tokio::test] + async fn disabled_creative_opportunities_do_not_disable_direct_auction() { + let mut settings = create_test_settings(); + settings.creative_opportunities = Some( + toml::from_str("enabled = false\ngam_network_id = \"12345\"\n") + .expect("should build disabled creative opportunity config"), + ); + let config = AuctionConfig { + enabled: true, + providers: vec!["call_recording_provider".to_string()], + timeout_ms: 2_000, + mediator: None, + ..Default::default() + }; + let called = Arc::new(Mutex::new(false)); + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(CallRecordingProvider { + called: Arc::clone(&called), + })); + let services = noop_services(); + let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let body = json!({ + "adUnits": [{ + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + }] + }); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&body).expect("should serialize body"), + )) + .expect("should build auction request"); + + let _ = handle_auction( + &settings, + &orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await; + + assert!( + *called.lock().expect("should lock provider call flag"), + "direct /auction must remain live when publisher template delivery is disabled" + ); + } + #[tokio::test] async fn auction_endpoint_consent_gate_returns_no_bid_without_contacting_providers() { // GDPR/unknown jurisdiction lacking effective TCF Purpose 1 must not run diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 01e9b196d..e8d92ae7a 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -1915,14 +1915,13 @@ mod tests { #[test] fn validate_runtime_allows_blank_network_id_when_no_slots_configured() { - // An empty slot list disables the feature, so the id is never rendered. - // Failing startup there would break a deploy over an unused value. + // With no slot definitions, the network id is not consumed. let mut config = make_config_with_section_template(Some("home")); config.gam_network_id = String::new(); config.slot.clear(); config .validate_runtime() - .expect("a disabled creative_opportunities stack should not fail on a blank id"); + .expect("an unused creative_opportunities network id should not fail validation"); } #[test] @@ -2340,6 +2339,51 @@ mod tests { } } + #[test] + fn disabled_creative_opportunities_still_validate_every_field() { + let invalid_slot: CreativeOpportunitiesConfig = toml::from_str( + r#" + enabled = false + gam_network_id = "99999" + + [[slot]] + id = "invalid-pattern" + page_patterns = ["["] + formats = [{ width = 300, height = 250 }] + "#, + ) + .expect("disabled slot shape should deserialize before runtime validation"); + assert!( + invalid_slot.validate_runtime().is_err(), + "disabled delivery must not bypass slot validation" + ); + + let invalid_cache: CreativeOpportunitiesConfig = toml::from_str( + r#" + enabled = false + gam_network_id = "99999" + template_cache_vary = ["not a header"] + "#, + ) + .expect("disabled cache shape should deserialize before runtime validation"); + assert!( + invalid_cache.validate_runtime().is_err(), + "disabled delivery must not bypass cache-field validation" + ); + + assert!( + toml::from_str::( + r#" + enabled = false + gam_network_id = "99999" + assembly_mode = "client_fill" + "#, + ) + .is_err(), + "disabled delivery must not bypass assembly-mode parsing" + ); + } + #[test] fn assembly_mode_deserializes_each_variant() { for (raw, expected) in [("inline", AssemblyMode::Inline), ("esi", AssemblyMode::Esi)] { diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 840035cd0..4262fb40f 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -55,7 +55,7 @@ use crate::platform::{ use crate::settings::{IntegrationConfig, Settings}; const APS_INTEGRATION_ID: &str = "aps"; -pub const APS_RENDERER_V1_ROUTE: &str = "/integrations/aps/renderer/v1"; +pub const APS_RENDERER_V2_ROUTE: &str = "/integrations/aps/renderer/v2"; pub const APS_RUNNER_ROUTE: &str = "/integrations/aps/runner.js"; pub const APS_RUNNER_UPSTREAM_URL: &str = "https://client.aps.amazon-adsystem.com/prebid-creative.js"; @@ -87,11 +87,11 @@ pub const APS_RUNNER_BLOCKING_READ_TIMEOUT: Duration = Duration::from_millis(250 pub fn is_aps_family_path(path: &str) -> bool { path == "/integrations/aps" || path.starts_with("/integrations/aps/") } -const APS_RENDERER_V1_CSP: &str = "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';"; +const APS_RENDERER_V2_CSP: &str = "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';"; -// This document is served with an immutable v1 URL. Any semantic change must -// ship at a new versioned route so cached v1 bytes retain their contract. -const APS_RENDERER_V1_DOCUMENT: &str = include_str!("generated/aps_renderer_bootstrap_v1.html"); +// This document is served with an immutable v2 URL. Any semantic change must +// ship at a new versioned route so cached v2 bytes retain their contract. +const APS_RENDERER_V2_DOCUMENT: &str = include_str!("generated/aps_renderer_bootstrap_v2.html"); /// Configuration for the APS `OpenRTB` integration. #[cfg(test)] @@ -1891,11 +1891,11 @@ impl ApsV1Integration { .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .header("x-content-type-options", "nosniff") .header("referrer-policy", "no-referrer") - .header(header::CONTENT_SECURITY_POLICY, APS_RENDERER_V1_CSP) - .body(EdgeBody::from(APS_RENDERER_V1_DOCUMENT)) + .header(header::CONTENT_SECURITY_POLICY, APS_RENDERER_V2_CSP) + .body(EdgeBody::from(APS_RENDERER_V2_DOCUMENT)) .change_context(TrustedServerError::Integration { integration: APS_INTEGRATION_ID.to_string(), - message: "Failed to build APS renderer v1 response".to_string(), + message: "Failed to build APS renderer v2 response".to_string(), }) .map(Self::mark_exact_headers) } @@ -2102,7 +2102,7 @@ impl IntegrationProxy for ApsV1Integration { return Self::local_status(StatusCode::METHOD_NOT_ALLOWED, true); } match path { - APS_RENDERER_V1_ROUTE => Self::renderer_response(), + APS_RENDERER_V2_ROUTE => Self::renderer_response(), APS_RUNNER_ROUTE => match Self::runner_response(services).await { Ok(response) => Ok(response), Err(reason) => { @@ -4017,7 +4017,7 @@ mod tests { let settings = create_test_settings(); let services = noop_services(); - for path in [APS_RENDERER_V1_ROUTE, APS_RUNNER_ROUTE] { + for path in [APS_RENDERER_V2_ROUTE, APS_RUNNER_ROUTE] { let disabled_get = http::Request::builder() .method(Method::GET) .uri(path) @@ -4075,9 +4075,9 @@ mod tests { for path in [ "/integrations/aps/renderer", - "/integrations/aps/renderer/v2", + "/integrations/aps/renderer/v1", "/integrations/aps/runner/v1.js", - "/integrations/aps/renderer/v1/extra", + "/integrations/aps/renderer/v2/extra", "/integrations/aps/not-a-route", ] { let request = http::Request::builder() @@ -4096,7 +4096,7 @@ mod tests { #[test] fn aps_family_classifier_has_an_exact_segment_boundary() { assert!(is_aps_family_path("/integrations/aps")); - assert!(is_aps_family_path("/integrations/aps/renderer/v1")); + assert!(is_aps_family_path("/integrations/aps/renderer/v2")); assert!(!is_aps_family_path("/integrations/apsx")); assert!(!is_aps_family_path("/integrations/ap")); } @@ -4106,7 +4106,7 @@ mod tests { let integration = ApsV1Integration { enabled: true }; let request = http::Request::builder() .method(Method::GET) - .uri(APS_RENDERER_V1_ROUTE) + .uri(APS_RENDERER_V2_ROUTE) .body(EdgeBody::empty()) .expect("should build versioned renderer request"); let response = futures::executor::block_on(integration.handle( @@ -4129,11 +4129,11 @@ mod tests { assert_eq!(response.headers()["referrer-policy"], "no-referrer"); assert_eq!( response.headers()[header::CONTENT_SECURITY_POLICY], - APS_RENDERER_V1_CSP + APS_RENDERER_V2_CSP ); assert!(!response.headers().contains_key("x-frame-options")); assert_eq!( - APS_RENDERER_V1_CSP, + APS_RENDERER_V2_CSP, "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';" ); assert_eq!( @@ -4147,23 +4147,20 @@ mod tests { .into_bytes_bounded(APS_RUNNER_MAX_RESPONSE_BYTES), ) .expect("renderer body should stay within the runner cap"); - assert_eq!(body.as_ref(), APS_RENDERER_V1_DOCUMENT.as_bytes()); - assert!(APS_RENDERER_V1_DOCUMENT.contains("TS APS Bootstrap Ready")); - assert!(APS_RENDERER_V1_DOCUMENT.contains("TS APS Bootstrap Navigate")); - assert!(APS_RENDERER_V1_DOCUMENT.contains("data:text/html;charset=utf-8,")); + assert_eq!(body.as_ref(), APS_RENDERER_V2_DOCUMENT.as_bytes()); + assert!(APS_RENDERER_V2_DOCUMENT.contains("TS APS Bootstrap Ready")); + assert!(APS_RENDERER_V2_DOCUMENT.contains("TS APS Bootstrap Configure")); + assert!(APS_RENDERER_V2_DOCUMENT.contains("data:text/html;charset=utf-8,")); + assert!(APS_RENDERER_V2_DOCUMENT.contains("/integrations/aps/runner.js")); for forbidden in [ - "runner", - "aaxResponse", - "accountId", - "bidId", - "creativeId", - "creativeUrl", - "createElement", - "iframe", + "client.aps.amazon-adsystem.com", + "example-account", + "bid-123", + "creative.example/render", ] { assert!( - !APS_RENDERER_V1_DOCUMENT.contains(forbidden), - "renderer bootstrap should not contain {forbidden}" + !APS_RENDERER_V2_DOCUMENT.contains(forbidden), + "renderer bootstrap should not contain a concrete descriptor or vendored runner value: {forbidden}" ); } } diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html deleted file mode 100644 index ab1f3ac91..000000000 --- a/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html +++ /dev/null @@ -1,66 +0,0 @@ - - - diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html new file mode 100644 index 000000000..6cf1e995a --- /dev/null +++ b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html @@ -0,0 +1,126 @@ + + + diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 9c6ccf2bd..21ccea703 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -394,9 +394,14 @@ pub fn register( #[serde(rename_all = "camelCase")] struct GptBrowserConfigV1 { gam_attribution_enabled: bool, + page_bids_enabled: bool, } let browser_config = GptBrowserConfigV1 { gam_attribution_enabled: integration.config.gam_attribution_enabled, + page_bids_enabled: settings + .creative_opportunities + .as_ref() + .is_some_and(|config| config.enabled), }; Ok(Some( @@ -574,6 +579,36 @@ mod tests { assert!(enabled.gam_attribution_enabled); } + #[test] + fn browser_config_projects_creative_opportunity_delivery_state() { + for enabled in [false, true] { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config("gpt", &serde_json::json!({ "enabled": true })) + .expect("should enable GPT"); + settings.creative_opportunities = Some( + toml::from_str(&format!( + "enabled = {enabled}\ngam_network_id = \"12345\"\n" + )) + .expect("should build creative opportunity config"), + ); + + let registration = register(&settings) + .expect("GPT registration should succeed") + .expect("GPT should be enabled"); + + assert_eq!( + registration.browser_config_v1, + Some(serde_json::json!({ + "gamAttributionEnabled": false, + "pageBidsEnabled": enabled, + })), + "GPT browser config must carry the exact publisher delivery switch" + ); + } + } + // -- URL detection -- #[test] diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index f699008f4..23a185031 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -2154,7 +2154,7 @@ mod tests { let request = Request::builder() .method(Method::GET) - .uri("/integrations/aps/renderer/v1") + .uri("/integrations/aps/renderer/v2") .header(HEADER_X_TS_EC.clone(), "caller-controlled") .body(EdgeBody::empty()) .expect("should build reserved APS request"); @@ -2807,7 +2807,7 @@ mod tests { } #[test] - fn tsjs_static_transport_precomputes_every_configuration_permitted_first_display_mask() { + fn tsjs_static_transport_precomputes_every_size_admitted_first_display_mask() { let mut settings = crate::test_support::tests::create_test_settings(); settings .integrations @@ -2837,14 +2837,8 @@ mod tests { let prebid = bit("prebid_initial"); assert_eq!( masks, - vec![ - fixed, - fixed | gpt, - fixed | gpt | aps, - fixed | gpt | prebid, - fixed | gpt | aps | prebid, - ], - "registry should enumerate every permitted participation combination" + vec![fixed, fixed | gpt, fixed | gpt | aps, fixed | gpt | prebid,], + "registry should enumerate every configuration-reachable mask admitted by the fixed transfer ceiling" ); for mask in masks { let selected = trusted_server_js::all_first_display_ids() diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 502a2f07d..a19588b9b 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1076,6 +1076,7 @@ fn configured_assembly_mode(settings: &Settings) -> AssemblyMode { settings .creative_opportunities .as_ref() + .filter(|config| config.enabled) .map(CreativeOpportunitiesConfig::assembly_mode) .unwrap_or_default() } @@ -2651,17 +2652,15 @@ pub(crate) fn should_run_server_side_ad_stack( is_navigation: bool, is_prefetch: bool, is_bot: bool, - has_matched_slots: bool, + has_active_matched_slots: bool, consent_allows_auction: bool, auction_enabled: bool, - ad_templates_enabled: bool, ) -> bool { - ad_templates_enabled - && is_get + is_get && is_navigation && !is_prefetch && !is_bot - && has_matched_slots + && has_active_matched_slots && consent_allows_auction && auction_enabled } @@ -3409,6 +3408,7 @@ pub async fn handle_publisher_request( settings .creative_opportunities .as_ref() + .filter(|co_config| co_config.enabled) .map_or_else(Vec::new, |co_config| { match_renderable_slots(auction.slots, co_config, &request_path) }) @@ -3429,10 +3429,6 @@ pub async fn handle_publisher_request( !matched_slots.is_empty(), consent_allows_auction, auction.orchestrator.is_enabled(), - settings - .creative_opportunities - .as_ref() - .is_some_and(|config| config.enabled), ); let should_run_auction = should_run_ad_stack; // Diagnostic: shows which gate suppresses the server-side auction. Pair with @@ -3979,21 +3975,15 @@ pub async fn handle_publisher_request( if is_html_content_type(origin_content_type) || is_not_modified { if should_run_ad_stack || assembled_response_must_be_private { enforce_synthesized_html_cache_privacy(&mut response); - } else if is_server_side_ad_eligible_navigation( - is_get, - is_navigation, - is_prefetch, - is_bot, - consent_allows_auction, - ) && (response.status() == StatusCode::OK || is_not_modified) - { - // Issue #1007 caps browser caching for structurally inactive - // server-side ad templates. The cap also applies to 304 responses - // so revalidation cannot restore the origin freshness policy. - // Request-scoped skips retain the origin policy because the same URL - // can otherwise render templates. - if !cache_control_forbids_shared_storage(response.headers()) { - apply_inactive_ad_stack_browser_cache_policy(&mut response); + } else if is_get && response.status().is_success() { + // Server-side ad templates are inactive for this response, so the + // document carries no per-navigation auction state and stays + // cacheable — unless the origin already marked it private. + if !response_cache_control_is_private_or_no_store(&response) { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); } } } @@ -5205,10 +5195,11 @@ pub async fn handle_page_bids( }) .unwrap_or_else(|| "/".to_string()); - let matched_slots = match_renderable_slots(auction.slots, co_config, &path_param); - let browser_slots = build_browser_slots_v1(&matched_slots, co_config, &path_param); - - let matched_slots = match_renderable_slots(auction.slots, co_config, &path_param); + let matched_slots = if co_config.enabled { + match_renderable_slots(auction.slots, co_config, &path_param) + } else { + Vec::new() + }; let browser_slots = build_browser_slots_v1(&matched_slots, co_config, &path_param); let request_info = crate::http_util::RequestInfo::from_request(&req, services.client_info()); @@ -8272,28 +8263,16 @@ mod tests { .expect("should parse settings with auction and creative opportunities enabled") } - fn settings_with_disabled_ad_templates() -> Settings { + fn settings_with_disabled_creative_opportunities() -> Settings { let toml = format!( "{}\n[auction]\nenabled = true\n\n\ - [creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", + [creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\nassembly_mode = \"esi\"\n", crate_test_settings_str() ); - Settings::from_toml(&toml).expect("should parse settings with disabled ad templates") - } - - fn settings_with_disabled_auction() -> Settings { - let toml = format!( - "{}\n[auction]\nenabled = false\n\n\ - [creative_opportunities]\ngam_network_id = \"12345\"\n", - crate_test_settings_str() - ); - Settings::from_toml(&toml).expect("should parse settings with disabled auction") + Settings::from_toml(&toml) + .expect("should parse settings with creative opportunities disabled") } - fn settings_without_creative_opportunities() -> Settings { - Settings::from_toml(&crate_test_settings_str()) - .expect("should parse settings without creative opportunities") - } fn settings_with_dispatching_provider() -> Settings { let toml = format!( "{}\n[auction]\nenabled = true\nproviders = [\"{UNEXPECTED_304_PROVIDER}\"]\n\n\ @@ -8700,19 +8679,20 @@ mod tests { } #[tokio::test] - async fn disabled_ad_templates_use_short_browser_cache_policy() { - // Arrange - let mut settings = settings_with_disabled_ad_templates(); - settings.proxy.allowed_domains = - vec!["*.example".to_string(), "*.example.com".to_string()]; + async fn disabled_creative_opportunities_keep_matching_navigation_inactive() { + let settings = settings_with_disabled_creative_opportunities(); + assert_eq!( + configured_assembly_mode(&settings), + AssemblyMode::Inline, + "disabled template delivery must not activate configured assembly machinery" + ); let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, "public, max-age=300"); + queue_cacheable_html_response(&stub); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); let slots = [article_slot()]; - // Act let response = run_with_slots( &settings, &services, @@ -8720,387 +8700,162 @@ mod tests { conditional_navigation_request(), ) .await; - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - let response = buffer_publisher_response_async( - response, - &Method::GET, - &settings, - ®istry, - &orchestrator, - &services, - ) - .await - .expect("should buffer disabled-template response"); - let (response_head, body) = response.into_parts(); - let body = String::from_utf8( - body.into_bytes() - .expect("should return an in-memory publisher body") - .to_vec(), - ) - .expect("should return UTF-8 publisher HTML"); + let response_head = response_head(response); - // Assert assert_eq!( stub.recorded_cache_bypass_flags(), vec![false], - "disabled server-side ad templates should not bypass the origin cache" + "disabled template delivery must not bypass the publisher cache" ); assert_eq!( response_head .headers .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), - Some("private, max-age=60"), - "disabled server-side ad templates should use the private browser cache policy" + Some("max-age=60"), + "disabled template delivery should use the inactive HTML policy" ); - assert!( - !body.contains(".adSlots=JSON.parse"), - "disabled server-side ad templates should not inject ad-slot state" + assert_eq!( + response_head.headers.get(header::ETAG), + Some(&HeaderValue::from_static(ORIGIN_ETAG)), + "disabled template delivery must preserve the origin validator" ); - for (header_name, expected) in [ - (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", - ), - ( - header::HeaderName::from_static("cdn-cache-control"), - "max-age=300", - ), - ( - header::HeaderName::from_static("cloudflare-cdn-cache-control"), - "max-age=300", - ), - ] { - assert_eq!( - response_head - .headers - .get(&header_name) - .and_then(|value| value.to_str().ok()), - Some(expected), - "disabled server-side ad templates should preserve {header_name}" - ); - } } #[tokio::test] - async fn disabled_auction_uses_private_browser_cache_policy() { - // Arrange - let settings = settings_with_disabled_auction(); + async fn inactive_html_failure_preserves_origin_cache_policy() { + let settings = settings_with_enabled_auction_and_creative_opportunities(); let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, "no-cache"); + stub.push_response_with_headers( + 500, + b"origin failure".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", "public, max-age=300"), + ("etag", ORIGIN_ETAG), + ], + ); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); - // Act - let response = run_with_slots( - &settings, - &services, - &[article_slot()], - conditional_navigation_request(), - ) - .await; + let response = + run_with_slots(&settings, &services, &[], conditional_navigation_request()).await; let response_head = response_head(response); - // Assert + assert_eq!(response_head.status, StatusCode::INTERNAL_SERVER_ERROR); assert_eq!( response_head .headers .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), - Some("private, max-age=60"), - "disabled auction should use the private browser cache policy" + Some("public, max-age=300"), + "failed publisher HTML must retain the origin cache policy" ); - } - - #[tokio::test] - async fn navigation_without_matched_slots_replaces_origin_cache_policy() { - let settings = settings_with_enabled_auction_and_creative_opportunities(); - - for cache_control in ["no-cache", "max-age=0", "must-revalidate", "s-maxage=0"] { - // Arrange - let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, cache_control); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - - // Act - let response = - run_with_slots(&settings, &services, &[], conditional_navigation_request()) - .await; - let response_head = response_head(response); - - // Assert - assert_eq!( - response_head - .headers - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("private, max-age=60"), - "inactive server-side ad templates should replace origin {cache_control} policy" - ); - } - } - - #[tokio::test] - async fn navigation_without_matched_slots_preserves_private_origin_cache_policy() { - let settings = settings_with_enabled_auction_and_creative_opportunities(); - - for cache_control in ["private, max-age=0", "No-Store"] { - // Arrange - let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, cache_control); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - - // Act - let response = - run_with_slots(&settings, &services, &[], conditional_navigation_request()) - .await; - let response_head = response_head(response); - - // Assert - assert_eq!( - response_head - .headers - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some(cache_control), - "inactive server-side ad templates should preserve private origin {cache_control} policy" - ); - for (header_name, expected) in [ - (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", - ), - ( - header::HeaderName::from_static("cdn-cache-control"), - "max-age=300", - ), - ( - header::HeaderName::from_static("cloudflare-cdn-cache-control"), - "max-age=300", - ), - ] { - assert_eq!( - response_head - .headers - .get(&header_name) - .and_then(|value| value.to_str().ok()), - Some(expected), - "inactive server-side ad templates should preserve {header_name}" - ); - } - } - } - - #[tokio::test] - async fn request_scoped_ad_stack_suppression_preserves_origin_cache_policy() { - let settings = settings_with_enabled_auction_and_creative_opportunities(); - let slots = [article_slot()]; - let mut bot_request = conditional_navigation_request(); - bot_request.headers_mut().insert( - "user-agent", - HeaderValue::from_static("Mozilla/5.0 (compatible; Googlebot/2.1)"), + assert_eq!( + response_head.headers.get(header::ETAG), + Some(&HeaderValue::from_static(ORIGIN_ETAG)), + "failed publisher HTML must retain origin validators" ); - let mut prefetch_request = conditional_navigation_request(); - prefetch_request - .headers_mut() - .insert("sec-purpose", HeaderValue::from_static("prefetch")); - - for (skip_reason, request, consent) in [ - ("bot", bot_request, non_regulated_consent()), - ("prefetch", prefetch_request, non_regulated_consent()), - ( - "consent denied", - conditional_navigation_request(), - crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::Gdpr, - ..Default::default() - }, - ), - ] { - // Arrange - let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, "no-cache"); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - - // Act - let response = - run_with_slots_and_consent(&settings, &services, &slots, request, consent) - .await; - let response_head = response_head(response); - - // Assert - assert_eq!( - response_head - .headers - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("no-cache"), - "{skip_reason} should retain the origin cache policy" - ); - } } #[tokio::test] - async fn absent_creative_opportunities_use_short_browser_cache_policy() { - // Arrange - let settings = settings_without_creative_opportunities(); + async fn inactive_html_post_preserves_origin_cache_policy() { + let settings = settings_with_enabled_auction_and_creative_opportunities(); let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, "no-cache"); + queue_cacheable_html_response(&stub); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); + let req = HttpRequest::builder() + .method(Method::POST) + .uri("https://ts.example.com/article") + .header(header::HOST, "ts.example.com") + .body(EdgeBody::empty()) + .expect("should build publisher POST request"); - // Act - let response = - run_with_slots(&settings, &services, &[], conditional_navigation_request()).await; + let response = run_with_slots(&settings, &services, &[], req).await; let response_head = response_head(response); - // Assert + assert_eq!(response_head.status, StatusCode::OK); assert_eq!( response_head .headers .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), - Some("private, max-age=60"), - "absent creative opportunities should use the private inactive-stack cache policy" + Some("public, max-age=300"), + "non-GET publisher HTML must retain the origin cache policy" ); } #[tokio::test] - async fn inactive_ad_stack_preserves_non_ok_response_cache_policy() { - let settings = settings_with_disabled_ad_templates(); + async fn inactive_html_preserves_case_insensitive_private_origin_policy() { + let settings = settings_with_enabled_auction_and_creative_opportunities(); - for status in [206, 404, 500, 503] { - // Arrange + for origin_policy in ["PuBlIc, PrIvAtE, max-age=300", "public, NO-STORE"] { let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_status_and_cache_control(&stub, status, "no-cache"); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", origin_policy), + ], + ); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); - // Act - let response = run_with_slots( - &settings, - &services, - &[article_slot()], - conditional_navigation_request(), - ) - .await; + let response = + run_with_slots(&settings, &services, &[], conditional_navigation_request()) + .await; let response_head = response_head(response); - // Assert assert_eq!( response_head .headers .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), - Some("no-cache"), - "inactive server-side ad templates should preserve origin policy on {status}" + Some(origin_policy), + "inactive HTML must preserve origin private/no-store directives" ); } } #[tokio::test] - async fn inactive_ad_stack_preserves_gpt_diagnostics_cache_privacy() { - // Arrange - let mut settings = settings_with_disabled_ad_templates(); - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable GPT diagnostics"); + async fn inactive_html_preserves_private_policy_on_a_repeated_header_line() { + let settings = settings_with_enabled_auction_and_creative_opportunities(); let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, "no-cache"); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", "public, max-age=300"), + ("cache-control", "PrIvAtE"), + ], + ); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); - let request = HttpRequest::builder() - .method(Method::GET) - .uri("https://ts.example.com/article?ts_console=1") - .header(header::HOST, "ts.example.com") - .header("sec-fetch-dest", "document") - .body(EdgeBody::empty()) - .expect("should build GPT diagnostics request"); - // Act - let response = run_with_slots(&settings, &services, &[article_slot()], request).await; + let response = + run_with_slots(&settings, &services, &[], conditional_navigation_request()).await; let response_head = response_head(response); + let policies = response_head + .headers + .get_all(header::CACHE_CONTROL) + .iter() + .map(|value| value.to_str().expect("cache policy should be ASCII")) + .collect::>(); - // Assert assert_eq!( - response_head - .headers - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("private, no-store"), - "active GPT diagnostics should retain cache privacy when server-side ad templates are inactive" + policies, + ["public, max-age=300", "PrIvAtE"], + "a private directive on any origin header line must prevent the inactive rewrite" ); } - #[tokio::test] - async fn inactive_ad_stack_preserves_non_get_and_non_document_cache_policy() { - let settings = settings_with_disabled_ad_templates(); - - for request in [ - HttpRequest::builder() - .method(Method::POST) - .uri("https://ts.example.com/article") - .header(header::HOST, "ts.example.com") - .header("sec-fetch-dest", "document") - .body(EdgeBody::empty()) - .expect("should build non-GET document request"), - HttpRequest::builder() - .method(Method::GET) - .uri("https://ts.example.com/article") - .header(header::HOST, "ts.example.com") - .header("sec-fetch-dest", "empty") - .body(EdgeBody::empty()) - .expect("should build non-document request"), - ] { - // Arrange - let stub = Arc::new(StubHttpClient::new()); - queue_html_response_with_cache_control(&stub, "no-cache"); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - - // Act - let response = - run_with_slots(&settings, &services, &[article_slot()], request).await; - let response_head = response_head(response); - - // Assert - assert_eq!( - response_head - .headers - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("no-cache"), - "inactive server-side ad templates should preserve non-document request policy" - ); - } - } - #[tokio::test] async fn eligible_navigation_rejects_unexpected_origin_304() { for content_type in [None, Some("text/html; charset=utf-8")] { @@ -9849,7 +9604,12 @@ mod tests { body.contains(r#""renderTraceOverlay":true"#), "the exact server-owned trace cookie must populate DiagnosticsBootV1: {body}" ); - assert_eq!(body.matches(r#""renderTraceOverlay""#).count(), 1); + assert_eq!( + body.matches(r#""diagnostics":{"version":1,"renderTraceOverlay":true"#) + .count(), + 1, + "the immutable server boot must carry one active diagnostics object" + ); } #[tokio::test] @@ -10192,42 +9952,38 @@ mod tests { #[test] fn server_side_ad_stack_runs_only_when_all_auction_gates_pass() { assert!( - should_run_server_side_ad_stack(true, true, false, false, true, true, true, true), + should_run_server_side_ad_stack(true, true, false, false, true, true, true), "GET, real navigation, matched slots, and consent should run TS ad stack" ); assert!( - !should_run_server_side_ad_stack(false, true, false, false, true, true, true, true), + !should_run_server_side_ad_stack(false, true, false, false, true, true, true), "non-GET requests should skip TS ad stack" ); assert!( - !should_run_server_side_ad_stack(true, false, false, false, true, true, true, true), + !should_run_server_side_ad_stack(true, false, false, false, true, true, true), "non-document requests should skip TS ad stack" ); assert!( - !should_run_server_side_ad_stack(true, true, true, false, true, true, true, true), + !should_run_server_side_ad_stack(true, true, true, false, true, true, true), "prefetch requests should skip TS ad stack and injection" ); assert!( - !should_run_server_side_ad_stack(true, true, false, true, true, true, true, true), + !should_run_server_side_ad_stack(true, true, false, true, true, true, true), "bot requests should skip TS ad stack and injection" ); assert!( - !should_run_server_side_ad_stack(true, true, false, false, false, true, true, true), + !should_run_server_side_ad_stack(true, true, false, false, false, true, true), "requests with no matching slots should skip TS ad stack" ); assert!( - !should_run_server_side_ad_stack(true, true, false, false, true, false, true, true), + !should_run_server_side_ad_stack(true, true, false, false, true, false, true), "requests without required consent should skip TS ad stack and injection" ); assert!( - !should_run_server_side_ad_stack(true, true, false, false, true, true, false, true), + !should_run_server_side_ad_stack(true, true, false, false, true, true, false), "disabled [auction].enabled kill switch should skip TS ad stack and injection" ); - assert!( - !should_run_server_side_ad_stack(true, true, false, false, true, true, true, false), - "disabled creative-opportunity delivery should skip publisher template work" - ); } #[test] @@ -13801,6 +13557,15 @@ mod tests { Settings::from_toml(&toml).expect("should parse settings with creative_opportunities") } + fn settings_with_co_disabled() -> Settings { + let toml = format!( + "{}\n[auction]\nenabled = true\n\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", + crate_test_settings_str() + ); + Settings::from_toml(&toml) + .expect("should parse settings with creative opportunity delivery disabled") + } + async fn run_page_bids( settings: &Settings, orchestrator: &AuctionOrchestrator, @@ -14012,8 +13777,7 @@ mod tests { #[tokio::test] 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. + // An enabled configuration with no matching definitions has no work. let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let req = make_page_bids_request("/2024/01/my-article/"); @@ -14031,6 +13795,22 @@ mod tests { assert_eq!(body["slots"], serde_json::json!([])); } + #[tokio::test] + async fn disabled_creative_opportunities_return_an_exact_empty_projection() { + let settings = settings_with_co_disabled(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let slots = article_slot(); + let req = make_page_bids_request("/2024/01/my-article/"); + + let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, 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"], serde_json::json!([])); + assert_eq!(body["bids"], serde_json::json!([])); + } + #[tokio::test] async fn bot_user_agent_returns_a_terminal_projection_without_bids() { // Crawlers should get slot definitions (so HTML structure is unchanged) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 7dbd8aee0..560b53bfb 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3209,7 +3209,7 @@ impl Settings { ) -> Result, Report> { const REPRESENTATIVE_PATHS: &[&str] = &[ "/integrations/aps", - "/integrations/aps/renderer/v1", + "/integrations/aps/renderer/v2", "/integrations/aps/runner.js", ]; let mut patterns = Vec::new(); diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index c061ea277..06bb8592e 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -477,7 +477,10 @@ pub fn tsjs_bootstrap_fixture_fragment_v1( .map(|product_id| { let config = match product_id { "didomi" => serde_json::json!({ "proxyPath": "/integrations/didomi/" }), - "gpt" => serde_json::json!({ "gamAttributionEnabled": false }), + "gpt" => serde_json::json!({ + "gamAttributionEnabled": false, + "pageBidsEnabled": false, + }), "prebid" => serde_json::json!({ "accountId": "fixture", "timeout": 1_000, @@ -985,7 +988,13 @@ mod tests { ("datadome", serde_json::json!({})), ("didomi", serde_json::json!({ "proxyPath": "/consent/" })), ("google_tag_manager", serde_json::json!({})), - ("gpt", serde_json::json!({ "gamAttributionEnabled": true })), + ( + "gpt", + serde_json::json!({ + "gamAttributionEnabled": true, + "pageBidsEnabled": true, + }), + ), ("lockr", serde_json::json!({})), ("osano", serde_json::json!({})), ("permutive", serde_json::json!({})), @@ -997,7 +1006,7 @@ mod tests { assert_eq!( serde_json::to_string(&configs).expect("carrier should serialize"), - r#"{"version":1,"entries":[{"id":"aps","config":{}},{"id":"datadome","config":{}},{"id":"didomi","config":{"proxyPath":"/consent/"}},{"id":"google_tag_manager","config":{}},{"id":"gpt","config":{"gamAttributionEnabled":true}},{"id":"lockr","config":{}},{"id":"osano","config":{}},{"id":"permutive","config":{}},{"id":"prebid","config":{"accountId":"publisher"}},{"id":"sourcepoint","config":{"rewriteSdk":true}},{"id":"testlight","config":{}}]}"#, + r#"{"version":1,"entries":[{"id":"aps","config":{}},{"id":"datadome","config":{}},{"id":"didomi","config":{"proxyPath":"/consent/"}},{"id":"google_tag_manager","config":{}},{"id":"gpt","config":{"gamAttributionEnabled":true,"pageBidsEnabled":true}},{"id":"lockr","config":{}},{"id":"osano","config":{}},{"id":"permutive","config":{}},{"id":"prebid","config":{"accountId":"publisher"}},{"id":"sourcepoint","config":{"rewriteSdk":true}},{"id":"testlight","config":{}}]}"#, ); } @@ -1246,7 +1255,10 @@ mod tests { fn first_display_outline_binds_the_canonical_integration_config_digest() { let configs = IntegrationConfigsV1::new(vec![( "gpt", - serde_json::json!({ "gamAttributionEnabled": false }), + serde_json::json!({ + "gamAttributionEnabled": false, + "pageBidsEnabled": false, + }), )]) .expect("GPT browser config should be admitted"); let canonical = serde_json::to_string(&configs).expect("carrier should serialize"); @@ -1321,7 +1333,10 @@ mod tests { ], integration_configs: &IntegrationConfigsV1::new(vec![( "gpt", - serde_json::json!({ "gamAttributionEnabled": false }), + serde_json::json!({ + "gamAttributionEnabled": false, + "pageBidsEnabled": false, + }), )]) .expect("GPT browser config should be admitted"), auction_projection_json: VALID_BROWSER_AUCTION_PROJECTION_JSON, diff --git a/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts b/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts index 3ba8f4173..5b7802f42 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts @@ -14,7 +14,7 @@ import { type BrowserName = "chromium" | "firefox" | "webkit"; const RELEASE_ID_PATTERN = /^[0-9a-f]{64}$/u; -const APS_RENDERER_PATH = "/integrations/aps/renderer/v1"; +const APS_RENDERER_PATH = "/integrations/aps/renderer/v2"; const APS_RUNNER_PATH = "/integrations/aps/runner.js"; const EVIDENCE_ROOT = resolve(__dirname, "../real-gam-evidence"); const TERMINAL_STATE = "terminal"; diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts index 182efd617..073bc6871 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts @@ -12,7 +12,7 @@ const GPT_FIXTURE = runtimeTsjsFixture(["render_runtime", "aps", "gpt"]); const SLOT = "puc-lifecycle-slot"; const RESERVATION_ID = "r1_AAAAAAAAAAAAAAAAAAAAAA"; -const APS_RENDERER_URL = "https://puc.test/integrations/aps/renderer/v1"; +const APS_RENDERER_URL = "https://puc.test/integrations/aps/renderer/v2"; const APS_RUNNER_URL = "https://puc.test/integrations/aps/runner.js"; const FICTIONAL_APS_RUNNER = readFileSync( resolve(__dirname, "../../fixtures/fictional-aps-runner.js"), @@ -104,7 +104,7 @@ async function openLifecyclePage( ): Promise { await installGptStub(page); const rendererResponse = await page.request.get( - runtimeUrl("/integrations/aps/renderer/v1"), + runtimeUrl("/integrations/aps/renderer/v2"), ); expect(rendererResponse.status()).toBe(200); const rendererDocument = await rendererResponse.text(); diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-real-gam.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-real-gam.spec.ts index ab4fd8ca4..1974dceaf 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-real-gam.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-real-gam.spec.ts @@ -83,7 +83,7 @@ test.describe("protected real-GAM test network", () => { ).toBe("third-party"); expect( classifyRealGamNetworkUrl( - "https://third-party.example/integrations/aps/renderer/v1", + "https://third-party.example/integrations/aps/renderer/v2", pageOrigin, ), ).toBe("third-party"); 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 c9ec1d4e2..840a11181 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 @@ -5,10 +5,11 @@ import { runtimeUrl } from "../../helpers/state.js"; const IFRAME_CREATIVE_URL = "https://creative.example/iframe"; const APS_TEST_ORIGIN = "https://aps-renderer.test"; -const APS_TEST_RENDERER_URL = `${APS_TEST_ORIGIN}/integrations/aps/renderer/v1`; +const APS_TEST_RENDERER_URL = `${APS_TEST_ORIGIN}/integrations/aps/renderer/v2`; const APS_TEST_RUNNER_URL = `${APS_TEST_ORIGIN}/integrations/aps/runner.js`; const SANDBOX = "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation"; +const PERMANENT_SANDBOX = `${SANDBOX} allow-same-origin`; const FICTIONAL_APS_RUNNER = readFileSync( resolve(__dirname, "../../fixtures/fictional-aps-runner.js"), "utf8", @@ -48,13 +49,13 @@ function descriptor() { }; } -test.describe("APS renderer v1 protocol", () => { +test.describe("APS renderer v2 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/renderer/v1", "/integrations/aps/runner/v1.js", ]) { const response = await page.request.get(runtimeUrl(path)); @@ -66,7 +67,7 @@ test.describe("APS renderer v1 protocol", () => { page, }) => { const rendererResponse = await page.request.get( - runtimeUrl("/integrations/aps/renderer/v1"), + runtimeUrl("/integrations/aps/renderer/v2"), ); expect(rendererResponse.status()).toBe(200); expect(rendererResponse.headers()["content-type"]).toBe( @@ -82,14 +83,13 @@ test.describe("APS renderer v1 protocol", () => { expect(rendererResponse.headers()["x-frame-options"]).toBeUndefined(); const csp = rendererResponse.headers()["content-security-policy"]; expect(csp).toBe( - "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' 'self' https:; connect-src https:; frame-src https: data: blob:; img-src https: data: blob:; media-src https: data: blob:; style-src 'unsafe-inline' https:; font-src https: data:; worker-src https: blob:; form-action https:;", + "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';", ); const rendererDocument = await rendererResponse.text(); - // WebKit correctly treats `self` inside the sandbox as the iframe's opaque - // origin. Fulfil the exact served document at an HTTPS test origin so all - // engines exercise the production `https:` runner allowance; no CSP or - // sandbox relaxation is needed for the local HTTP transport. + // Fulfil the exact served document and its parent at one HTTPS test origin so + // frame-ancestors 'self', parent-origin authentication, and the live runner + // proxy URL all exercise the production shape over the local test transport. await page.route(APS_TEST_RENDERER_URL, (route) => route.fulfill({ status: 200, @@ -118,51 +118,74 @@ test.describe("APS renderer v1 protocol", () => { body: FICTIONAL_APS_RUNNER, }); }); - await page.route(runtimeUrl("/aps-v1-protocol-test"), (route) => + await page.route(`${APS_TEST_ORIGIN}/aps-v2-protocol-test`, (route) => route.fulfill({ status: 200, contentType: "text/html", headers: { "content-security-policy": - "default-src 'none'; script-src 'unsafe-inline'; frame-src https:", + "default-src 'none'; script-src 'unsafe-inline'; frame-src 'self' data: https://creative.example", }, body: `
`, }), ); - await page.goto(runtimeUrl("/aps-v1-protocol-test")); + await page.goto(`${APS_TEST_ORIGIN}/aps-v2-protocol-test`); const makeDescriptor = (bidId: string) => { const value = descriptor(); @@ -182,37 +205,39 @@ window.startApsV1 = function(options) { bidId: string, rendererOverrides: Record = {}, ) => { - const nonce = `n1_${slotId.padEnd(22, "x").slice(0, 22)}`; + const bootstrapNonce = `b1_${slotId.padEnd(22, "b").slice(0, 22)}`; + const rendererNonce = `n1_${slotId.padEnd(22, "n").slice(0, 22)}`; await page.evaluate( - ({ slotId, nonce, renderer }) => { + ({ slotId, bootstrapNonce, rendererNonce, renderer }) => { ( window as unknown as { - startApsV1(options: Record): void; + startApsV2(options: Record): void; } - ).startApsV1({ slotId, nonce, renderer }); + ).startApsV2({ slotId, bootstrapNonce, rendererNonce, renderer }); }, { slotId, - nonce, + bootstrapNonce, + rendererNonce, renderer: { ...makeDescriptor(bidId), ...rendererOverrides, }, }, ); - return nonce; + return rendererNonce; }; const messages = (slotId: string) => page.evaluate( (id) => ( window as unknown as { - apsV1Records: Record< + apsV2Records: Record< string, { messages: Array> } >; } - ).apsV1Records[id]?.messages ?? [], + ).apsV2Records[id]?.messages ?? [], slotId, ); 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 2f14981a6..b7506b5c0 100644 --- a/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs +++ b/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs @@ -27,11 +27,11 @@ const SUCCESS_HEADERS: [&str; 5] = [ // 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); -const RENDERER_V1_PATH: &str = "/integrations/aps/renderer/v1"; +const RENDERER_V2_PATH: &str = "/integrations/aps/renderer/v2"; const CLOUDFLARE_READINESS_TIMEOUT: Duration = Duration::from_secs(30); -const RENDERER_V1_CSP: &str = "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';"; -const RENDERER_V1_DOCUMENT: &str = include_str!( - "../../trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html" +const RENDERER_V2_CSP: &str = "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';"; +const RENDERER_V2_DOCUMENT: &str = include_str!( + "../../trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html" ); struct CorpusCase { @@ -520,7 +520,7 @@ fn wait_for_cloudflare_renderer_readiness(client: &Client, base_url: &str) { let exact = client .request( reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND should be valid"), - format!("{base_url}{RENDERER_V1_PATH}"), + format!("{base_url}{RENDERER_V2_PATH}"), ) .send() .is_ok_and(exact_renderer_method_response); @@ -565,7 +565,7 @@ fn actual_adapter_proxy_corpus() { let response = client .request( reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND should be valid"), - format!("{}{}", process.base_url, RENDERER_V1_PATH), + format!("{}{}", process.base_url, RENDERER_V2_PATH), ) .header("authorization", "Bearer must-not-reach-publisher") .send() @@ -573,7 +573,7 @@ fn actual_adapter_proxy_corpus() { assert_exact_local_failure(response, 405, true); let renderer = client - .get(format!("{}{}", process.base_url, RENDERER_V1_PATH)) + .get(format!("{}{}", process.base_url, RENDERER_V2_PATH)) .send() .expect("renderer request should complete"); assert_eq!(renderer.status().as_u16(), 200); @@ -589,7 +589,7 @@ fn actual_adapter_proxy_corpus() { assert_eq!(renderer.headers()["referrer-policy"], "no-referrer"); assert_eq!( renderer.headers()["content-security-policy"], - RENDERER_V1_CSP + RENDERER_V2_CSP ); assert!(!renderer.headers().contains_key("x-frame-options")); let semantic_headers: BTreeSet<&str> = renderer @@ -618,11 +618,11 @@ fn actual_adapter_proxy_corpus() { .bytes() .expect("renderer body should be readable") .as_ref(), - RENDERER_V1_DOCUMENT.as_bytes() + RENDERER_V2_DOCUMENT.as_bytes() ); for path in [ - "/integrations/aps/renderer/v2", + "/integrations/aps/renderer/v1", "/integrations/aps/runner/v1.js", ] { let response = client diff --git a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs index d3424c144..7c99fcbd7 100644 --- a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs +++ b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs @@ -191,7 +191,7 @@ fn wait_for_aps_route_ready(base_url: &str, options: super::ReadyCheckOptions) - let renderer_url = format!( "{}{}", base_url, - trusted_server_core::integrations::aps::APS_RENDERER_V1_ROUTE + trusted_server_core::integrations::aps::APS_RENDERER_V2_ROUTE ); let probe_method = reqwest::Method::from_bytes(b"PROPFIND").expect("should parse PROPFIND readiness method"); diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index 610be70b7..b22b7c35d 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -19,7 +19,7 @@ use tower::{Service as _, ServiceExt as _}; use trusted_server_adapter_axum::app::TrustedServerApp as AxumApp; use trusted_server_adapter_cloudflare::app::TrustedServerApp as CloudflareApp; use trusted_server_adapter_spin::app::TrustedServerApp as SpinApp; -use trusted_server_core::integrations::aps::APS_RENDERER_V1_ROUTE; +use trusted_server_core::integrations::aps::APS_RENDERER_V2_ROUTE; use trusted_server_core::settings::Settings; /// Shared test settings for all adapters. @@ -779,7 +779,7 @@ fn canonical_headers(headers: &HeaderMap) -> BTreeMap> { fn aps_renderer_request() -> edgezero_core::http::Request { request_builder() .method("GET") - .uri(APS_RENDERER_V1_ROUTE) + .uri(APS_RENDERER_V2_ROUTE) .body(edgezero_core::body::Body::empty()) .expect("should build APS renderer request") } @@ -795,7 +795,7 @@ fn response_parts(response: edgezero_core::http::Response) -> (u16, HeaderMap, b } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn aps_renderer_v1_response_is_exact_across_portable_adapters() { +async fn aps_renderer_v2_response_is_exact_across_portable_adapters() { let axum = trusted_server_adapter_axum::app::dispatch_reserved_with_settings( test_settings(), aps_renderer_request(), diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index b1ce0b222..ebcaffd4f 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -514,7 +514,6 @@ fn copy_bundle(filename: &str, crate_dir: &Path, dist_dir: &Path, out_dir: &Path }); return; } - return; } panic!("tsjs: bundle {filename} was not generated"); } diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 46cd22f52..b9d137f74 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -35,10 +35,15 @@ const bootstrapPrivateProperties = const firstDisplayBasePrivateProperties = /^(?:options|stateValue|agentBatch|slotResults|handoffOwner|mutationObserver|observedMutationRevision|displayWasCommitted|sealed|failed|pending|reasons|actionStarted|disposedDriver|handoffFinalized|committedArtifactsDetached|lastTimingMs|firstActionAtMs|terminalAtMs|paintAtMs|nextTraceSequence|acceptedTrace|recordTerminal|recordFirstAction|scheduleProtectedPaint|readTiming|captureHandoffData|disposeDriver|installNativeMutationIngress|observeDomMutations|isOwnedRuntimeInsertion|closeNativeMutationIngress|disposeNativeMutationIngress|claimTimer|completionTimer|controlRelease|directFrame|documentAccepted|documentAcceptancePending|documentRelease|documentTimer|documentTransferred|insertionTimer|pendingDocumentTerminal|ownerSource|ownerTicket|rendererNonce|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|recordFailure)$/; +// APS adds one private render state machine inside its independently built slice. +// These fields never enter registration, message, or handoff objects. +const firstDisplayApsPrivateProperties = + /^(?:options|stateValue|agentBatch|slotResults|handoffOwner|mutationObserver|observedMutationRevision|displayWasCommitted|sealed|failed|pending|reasons|actionStarted|disposedDriver|handoffFinalized|committedArtifactsDetached|lastTimingMs|firstActionAtMs|terminalAtMs|paintAtMs|nextTraceSequence|acceptedTrace|recordTerminal|recordFirstAction|scheduleProtectedPaint|readTiming|captureHandoffData|disposeDriver|installNativeMutationIngress|observeDomMutations|isOwnedRuntimeInsertion|closeNativeMutationIngress|disposeNativeMutationIngress|claimTimer|completionTimer|controlRelease|directFrame|documentAccepted|documentAcceptancePending|documentRelease|documentTimer|documentTransferred|insertionTimer|pendingDocumentTerminal|ownerSource|ownerTicket|rendererNonce|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|recordFailure|hostPositionOwned|previousHostPosition|previousHostPositionPriority|frameAttributes|frameContentWindow|frameSource|frameSourceDocument|bootstrapNavigated|bootstrapPolicy|bootstrapSource|originalCount|isReservationId|isLifecycleTicket|isBootstrapNonce|isRendererNonce|parseDocumentMessage|rendererUrl|sandbox|permanentSandbox|deadlines|insertionMs|documentAcceptanceMs|completionMs|ownerSettlementMs)$/; + // These names are private to the GPT initial IIFE and never cross its protocol // receipt or handoff boundary. Keep the cross-artifact protocol keys authored. const firstDisplayGptPrivateProperties = - /^(?:binding|command|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting)$/; + /^(?:options|binding|command|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restoreTargetingObservers|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting|sealedTargetingOwnership|targetingWrites|ingressClosing|diagnosticRecords|nextDiagnosticCycleOrdinal|requestTimer|completionTimer|requestOperation|requestInvoked|bindingState|operations|requested|settled|encodedBytes|readPhysicalElementId|retireCycle|snapshotDestroyedCycles|invalidateCyclesForElement|invalidateCyclesForPublisherCall|consumeTargetingWrite|invalidateStaleTargetingObservers|captureRetainedTargeting)$/; fs.rmSync(distributionDirectory, { recursive: true, force: true }); fs.mkdirSync(distributionDirectory, { recursive: true }); @@ -199,11 +204,13 @@ async function buildArtifact(artifact) { root: libDirectory, ...(artifact.role === 'bootstrap' ? { esbuild: { mangleProps: bootstrapPrivateProperties } } - : ['first_display', 'aps_initial'].includes(artifact.id) + : artifact.id === 'first_display' ? { esbuild: { mangleProps: firstDisplayBasePrivateProperties } } - : artifact.id === 'gpt_initial' - ? { esbuild: { mangleProps: firstDisplayGptPrivateProperties } } - : {}), + : artifact.id === 'aps_initial' + ? { esbuild: { mangleProps: firstDisplayApsPrivateProperties } } + : artifact.id === 'gpt_initial' + ? { esbuild: { mangleProps: firstDisplayGptPrivateProperties } } + : {}), define: { __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify(RELEASE_SENTINEL), __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: JSON.stringify(catalogIds), diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs index f31d5bd47..1f9426aa9 100644 --- a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -177,7 +177,7 @@ const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/auction.ts': Object.freeze(['core']), 'src/core/config.ts': Object.freeze(['bootstrap', 'core']), - 'src/core/contracts/aps_renderer.ts': Object.freeze(['bootstrap', 'core', 'aps_initial', 'aps']), + 'src/core/contracts/aps_renderer.ts': Object.freeze(['bootstrap', 'core', 'aps']), 'src/core/contracts/auction_projection.ts': Object.freeze([ 'bootstrap', 'core', @@ -187,14 +187,8 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/contracts/generated/renderer_validator_v1.ts': Object.freeze([ 'bootstrap', 'core', - 'aps_initial', 'aps', ]), - 'src/core/contracts/generated/renderer_validator_document_v1.ts': Object.freeze([ - 'aps_initial', - 'aps', - ]), - 'src/shared/aps_documents.ts': Object.freeze(['aps_initial', 'aps']), 'src/core/contracts/request_ads.ts': Object.freeze(['bootstrap', 'core']), 'src/core/index.ts': Object.freeze(['core']), 'src/core/log.ts': Object.freeze([ diff --git a/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs index c917e11f2..9629e4691 100644 --- a/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs +++ b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs @@ -178,7 +178,7 @@ forbid(routeAndConfigFiles, 'deprecated page-bids route', /\/__ts\/page-bids/g); forbid( routeAndConfigFiles, 'non-canonical APS renderer route', - /\/integrations\/aps\/renderer(?!\/v1(?![A-Za-z0-9_./-]))/g + /\/integrations\/aps\/renderer(?!\/v2(?![A-Za-z0-9_./-]))/g ); const apsIntegrationFile = path.join( repositoryRoot, @@ -189,7 +189,7 @@ forbidSource( apsIntegrationFile, apsIntegrationSource.split('\n#[cfg(test)]')[0] ?? apsIntegrationSource, 'non-canonical APS renderer route', - /\/integrations\/aps\/renderer(?!\/v1(?![A-Za-z0-9_./-]))/g + /\/integrations\/aps\/renderer(?!\/v2(?![A-Za-z0-9_./-]))/g ); if ( !apsIntegrationSource.includes( diff --git a/crates/trusted-server-js/lib/src/adapters/messaging.ts b/crates/trusted-server-js/lib/src/adapters/messaging.ts index be4e8b4d9..8cc141cfb 100644 --- a/crates/trusted-server-js/lib/src/adapters/messaging.ts +++ b/crates/trusted-server-js/lib/src/adapters/messaging.ts @@ -135,11 +135,11 @@ export const PROTOCOL_MESSAGE_SCHEMAS_V1 = Object.freeze({ message: TSJS_MESSAGE_PROTOCOL_V1.message.apsBootstrapReady, version: 1, }), - apsBootstrapNavigate: schema( + apsBootstrapConfigure: schema( 'global-json', - ['message', 'version', 'bootstrapNonce', 'containerUrl'], - { message: TSJS_MESSAGE_PROTOCOL_V1.message.apsBootstrapNavigate, version: 1 }, - 196_800 + ['message', 'version', 'bootstrapNonce', 'rendererNonce', 'creativeOrigin', 'tagType'], + { message: TSJS_MESSAGE_PROTOCOL_V1.message.apsBootstrapConfigure, version: 2 }, + 16_384 ), apsInnerReady: schema('global-json', ['message', 'version', 'rendererNonce'], { message: TSJS_MESSAGE_PROTOCOL_V1.message.apsInnerReady, @@ -378,25 +378,6 @@ function exactHttpOrigin(value: unknown): value is string { } } -function apsContainerUrl(value: unknown, bootstrapNonce: unknown): value is string { - if ( - !capability(bootstrapNonce, 'bootstrapNonce') || - !boundedString(value, 196_663, { controls: true }) || - !value.startsWith('data:text/html;charset=utf-8,') - ) { - return false; - } - const suffix = `#${bootstrapNonce}`; - if (!value.endsWith(suffix)) return false; - const encoded = value.slice('data:text/html;charset=utf-8,'.length, -suffix.length); - if (encoded.length === 0 || encoded.includes('#')) return false; - try { - return encodeURIComponent(decodeURIComponent(encoded)) === encoded; - } catch { - return false; - } -} - function skipWhitespace(source: string, start: number): number { let index = start; while (index < source.length && /\s/.test(source[index] ?? '')) index += 1; @@ -680,10 +661,13 @@ function validProtocolFields( ); case 'apsBootstrapReady': return capability(record['bootstrapNonce'], 'bootstrapNonce'); - case 'apsBootstrapNavigate': + case 'apsBootstrapConfigure': return ( capability(record['bootstrapNonce'], 'bootstrapNonce') && - apsContainerUrl(record['containerUrl'], record['bootstrapNonce']) + capability(record['rendererNonce'], 'nonce') && + exactHttpOrigin(record['creativeOrigin']) && + (record['creativeOrigin'] as string).startsWith('https://') && + (record['tagType'] === 'iframe' || record['tagType'] === 'script') ); case 'apsInnerReady': case 'apsInnerBind': diff --git a/crates/trusted-server-js/lib/src/composition/browser_test.ts b/crates/trusted-server-js/lib/src/composition/browser_test.ts index 18488c056..8a6de3652 100644 --- a/crates/trusted-server-js/lib/src/composition/browser_test.ts +++ b/crates/trusted-server-js/lib/src/composition/browser_test.ts @@ -48,7 +48,7 @@ import { } from '../core/registry'; import { prepareAdmIframe } from '../core/render'; import { - APS_RENDERER_V1_PATH, + APS_RENDERER_V2_PATH, renderDirectApsAttempt, renderPucApsAttempt, } from '../integrations/aps/render'; @@ -317,7 +317,7 @@ function testConfigProduct(id: string): string | undefined { function defaultBrowserTestConfig(id: string): Readonly> { if (id === 'didomi') return { proxyPath: '/integrations/didomi/consent/' }; - if (id === 'gpt') return { gamAttributionEnabled: false }; + if (id === 'gpt') return { gamAttributionEnabled: false, pageBidsEnabled: true }; if (id === 'prebid') { return { accountId: 'test', @@ -762,7 +762,7 @@ export function createBrowserComposition( const expectedPublisherOrigin = window.location.origin; return { expectedPublisherOrigin, - expectedRendererUrl: new URL('/integrations/aps/renderer/v1', expectedPublisherOrigin).href, + expectedRendererUrl: new URL('/integrations/aps/renderer/v2', expectedPublisherOrigin).href, validateApsRenderer: defaultValidator, ...options.messagingValidation, }; @@ -1592,7 +1592,7 @@ export function createTestBrowserRuntimeComposition( // to redirect the APS endpoint by predefining that creative-only stamp. const publisherOrigin = trustedDocumentHttpOrigin(window.location.origin); if (!publisherOrigin) throw new Error('Trusted publisher origin is unavailable'); - const rendererUrl = new URL(APS_RENDERER_V1_PATH, publisherOrigin).href; + const rendererUrl = new URL(APS_RENDERER_V2_PATH, publisherOrigin).href; const renderDirectAdm = Object.freeze( (attempt: RenderAttempt, container: HTMLElement): boolean => { try { diff --git a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts index efa5f4116..95faf1d82 100644 --- a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts @@ -14,19 +14,29 @@ const MAX_DIAGNOSTIC_FACTS = 512; const MAX_DIAGNOSTIC_FACT_BYTES = 1_000; const MAX_DIAGNOSTIC_SECTION_BYTES = 512 * 1024; const MAX_U32 = 4_294_967_295; +const SLOT_REQUESTED = 'slotRequested'; +const SLOT_RESPONSE_RECEIVED = 'slotResponseReceived'; +const SLOT_RENDER_ENDED = 'slotRenderEnded'; +const SLOT_ONLOAD = 'slotOnload'; +const IMPRESSION_VIEWABLE = 'impressionViewable'; +const SLOT_VISIBILITY_CHANGED = 'slotVisibilityChanged'; +const OVERLAPPING_REQUEST_CYCLES = 'overlapping_request_cycles'; +const INVALID_EVENT_ORDER = 'invalid_event_order'; +const GPT_REQUEST_FAILED = 'gpt_request_failed'; +const SLOT_UNRESOLVED = 'slot_unresolved'; const DIAGNOSTIC_ONLY_EVENTS = Object.freeze([ - 'slotResponseReceived', - 'slotOnload', - 'impressionViewable', - 'slotVisibilityChanged', + SLOT_RESPONSE_RECEIVED, + SLOT_ONLOAD, + IMPRESSION_VIEWABLE, + SLOT_VISIBILITY_CHANGED, ] as const); const DIAGNOSTIC_EVENT_ORDER: readonly FirstDisplayGptDiagnosticEventV1[] = Object.freeze([ - 'slotRequested', - 'slotResponseReceived', - 'slotRenderEnded', - 'slotOnload', - 'impressionViewable', - 'slotVisibilityChanged', + SLOT_REQUESTED, + SLOT_RESPONSE_RECEIVED, + SLOT_RENDER_ENDED, + SLOT_ONLOAD, + IMPRESSION_VIEWABLE, + SLOT_VISIBILITY_CHANGED, ]); export type FirstDisplayGptRenderResult = 'gam_empty' | 'nonempty_gam'; @@ -35,9 +45,9 @@ export type FirstDisplayGptFailureReason = | 'external_artifact_incompatible' | 'external_ready_timeout' | 'gpt_completion_timeout' - | 'gpt_request_failed' + | typeof GPT_REQUEST_FAILED | 'gpt_request_timeout' - | 'slot_unresolved'; + | typeof SLOT_UNRESOLVED; export interface FirstDisplayGptBoundCycleV1 { readonly bid: FirstDisplayProjectionBidV1; @@ -572,7 +582,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (this.disposed) return; const element = resolveElement(this.options.document, row.placement); if (!element) { - callbacks.onFailure(row.placement.slot, 'slot_unresolved'); + callbacks.onFailure(row.placement.slot, SLOT_UNRESOLVED); continue; } const matches = existing.filter((candidate) => @@ -583,7 +593,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { : false ); if (matches.length > 1) { - callbacks.onFailure(row.placement.slot, 'slot_unresolved'); + callbacks.onFailure(row.placement.slot, SLOT_UNRESOLVED); continue; } const publisherSlot = physicalSlot(matches[0]); @@ -597,7 +607,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { ]) ); if (!slot) { - callbacks.onFailure(row.placement.slot, 'slot_unresolved'); + callbacks.onFailure(row.placement.slot, SLOT_UNRESOLVED); continue; } const ownership = publisherSlot ? 'publisher' : 'trusted_server'; @@ -611,12 +621,12 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze({ initialLoadDisabled: disabled, ownership }) ); if (!plan) { - callbacks.onFailure(row.placement.slot, 'gpt_request_failed'); + callbacks.onFailure(row.placement.slot, GPT_REQUEST_FAILED); continue; } const traceTokenOrdinal = this.nextTraceTokenOrdinal; if (traceTokenOrdinal > 4_294_967_295) { - callbacks.onFailure(row.placement.slot, 'gpt_request_failed'); + callbacks.onFailure(row.placement.slot, GPT_REQUEST_FAILED); continue; } const traceToken = `gt1_${traceTokenOrdinal.toString(36)}`; @@ -687,7 +697,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } catch { if (cycle.settled) continue; - this.failCycle(cycle, callbacks, 'gpt_request_failed'); + this.failCycle(cycle, callbacks, GPT_REQUEST_FAILED); } } } @@ -709,7 +719,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } if (cycle.settled) { this.retireCycle(cycle, callbacks); - this.captureDiagnosticFact('slotRequested', cycle, event); + this.captureDiagnosticFact(SLOT_REQUESTED, cycle, event); return; } if (!cycle.requestInvoked) return; @@ -718,7 +728,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { return; } cycle.requested = true; - this.captureDiagnosticFact('slotRequested', cycle, event); + this.captureDiagnosticFact(SLOT_REQUESTED, cycle, event); if (cycle.requestTimer !== undefined) this.clearOwnedTimer(cycle.requestTimer); }; const renderListener = (event: unknown): void => { @@ -728,7 +738,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { const cycle = slot ? this.cycles.get(slot) : undefined; if (!cycle) return; if (cycle.settled) { - this.captureDiagnosticFact('slotRenderEnded', cycle, event); + this.captureDiagnosticFact(SLOT_RENDER_ENDED, cycle, event); return; } if (!cycle.requested) return; @@ -736,10 +746,10 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze({ isEmpty: member(event, 'isEmpty') }) ); if (!result) { - this.failCycle(cycle, callbacks, 'gpt_request_failed'); + this.failCycle(cycle, callbacks, GPT_REQUEST_FAILED); return; } - this.captureDiagnosticFact('slotRenderEnded', cycle, event); + this.captureDiagnosticFact(SLOT_RENDER_ENDED, cycle, event); cycle.settled = true; if (cycle.requestTimer !== undefined) this.clearOwnedTimer(cycle.requestTimer); if (cycle.completionTimer !== undefined) this.clearOwnedTimer(cycle.completionTimer); @@ -759,10 +769,10 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { }; this.requestedListener = requestedListener; this.renderListener = renderListener; - this.diagnosticListeners.set('slotRequested', requestedListener); - this.diagnosticListeners.set('slotRenderEnded', renderListener); - call(service, 'addEventListener', ['slotRequested', requestedListener]); - call(service, 'addEventListener', ['slotRenderEnded', renderListener]); + this.diagnosticListeners.set(SLOT_REQUESTED, requestedListener); + this.diagnosticListeners.set(SLOT_RENDER_ENDED, renderListener); + call(service, 'addEventListener', [SLOT_REQUESTED, requestedListener]); + call(service, 'addEventListener', [SLOT_RENDER_ENDED, renderListener]); if (this.options.diagnosticsActive === true) { for (const eventType of DIAGNOSTIC_ONLY_EVENTS) { const listener = (event: unknown): void => { @@ -1075,19 +1085,19 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { let disposition: FirstDisplayGptFactV1['disposition'] = 'matched'; let issueReason: FirstDisplayGptFactV1['issueReason'] = null; let diagnosticCycle: FirstDisplayDiagnosticCycleRecord | undefined; - if (eventType === 'slotRequested') { + if (eventType === SLOT_REQUESTED) { if (cycle.diagnosticRecords.some((record) => record.state === 'open')) { disposition = 'ambiguous'; - issueReason = 'overlapping_request_cycles'; + issueReason = OVERLAPPING_REQUEST_CYCLES; } else if (cycle.nextDiagnosticCycleOrdinal > MAX_U32) { disposition = 'unmatched'; - issueReason = 'invalid_event_order'; + issueReason = INVALID_EVENT_ORDER; } else { if (cycle.diagnosticRecords.length >= 10) { const pruneIndex = cycle.diagnosticRecords.findIndex((record) => record.state !== 'open'); if (pruneIndex < 0) { disposition = 'ambiguous'; - issueReason = 'overlapping_request_cycles'; + issueReason = OVERLAPPING_REQUEST_CYCLES; } else { cycle.diagnosticRecords.splice(pruneIndex, 1); cycle.unknownPriorCycle = true; @@ -1097,7 +1107,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { diagnosticCycle = { ordinal: cycle.nextDiagnosticCycleOrdinal, ...(responseIdentifier === undefined ? {} : { responseIdentifier }), - seen: new Set(['slotRequested']), + seen: new Set([SLOT_REQUESTED]), state: 'open', }; cycle.nextDiagnosticCycleOrdinal += 1; @@ -1132,7 +1142,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { diagnosticCycle = candidates[0]; } else if (candidates.length > 1) { disposition = 'ambiguous'; - issueReason = 'overlapping_request_cycles'; + issueReason = OVERLAPPING_REQUEST_CYCLES; } else { const duplicate = cycle.diagnosticRecords.find( (record) => @@ -1143,18 +1153,18 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { ); if (duplicate) { diagnosticCycle = duplicate; - issueReason = 'invalid_event_order'; + issueReason = INVALID_EVENT_ORDER; } else { disposition = 'unmatched'; issueReason = cycle.unknownPriorCycle ? 'unknown_prior_cycle' : 'no_request_cycle'; } } - if (diagnosticCycle && issueReason !== 'invalid_event_order') { + if (diagnosticCycle && issueReason !== INVALID_EVENT_ORDER) { diagnosticCycle.seen.add(eventType); if (diagnosticCycle.responseIdentifier === undefined && responseIdentifier !== undefined) { diagnosticCycle.responseIdentifier = responseIdentifier; } - if (eventType === 'slotRenderEnded') diagnosticCycle.state = 'completed'; + if (eventType === SLOT_RENDER_ENDED) diagnosticCycle.state = 'completed'; } } if (this.options.diagnosticsActive !== true) return; @@ -1171,7 +1181,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } const size = member(event, 'size'); const renderedSize = - eventType === 'slotRenderEnded' && + eventType === SLOT_RENDER_ENDED && Array.isArray(size) && size.length === 2 && size.every( @@ -1199,13 +1209,13 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { capturedAtMs: observedAtMs, elementId: cycle.elementId, adUnitPath: cycle.placement.gamUnitPath, - isEmpty: eventType === 'slotRenderEnded' ? optionalBoolean('isEmpty') : null, + isEmpty: eventType === SLOT_RENDER_ENDED ? optionalBoolean('isEmpty') : null, renderedSize, - isBackfill: eventType === 'slotRenderEnded' ? optionalBoolean('isBackfill') : null, + isBackfill: eventType === SLOT_RENDER_ENDED ? optionalBoolean('isBackfill') : null, slotContentChanged: - eventType === 'slotRenderEnded' ? optionalBoolean('slotContentChanged') : null, + eventType === SLOT_RENDER_ENDED ? optionalBoolean('slotContentChanged') : null, visibilityPercent: - eventType === 'slotVisibilityChanged' && + eventType === SLOT_VISIBILITY_CHANGED && typeof visibility === 'number' && Number.isFinite(visibility) && visibility >= 0 && diff --git a/crates/trusted-server-js/lib/src/first_display/agent.ts b/crates/trusted-server-js/lib/src/first_display/agent.ts index 75d395801..363e68f52 100644 --- a/crates/trusted-server-js/lib/src/first_display/agent.ts +++ b/crates/trusted-server-js/lib/src/first_display/agent.ts @@ -48,6 +48,7 @@ import { import type { FirstDisplayRenderBridgeOptionsV1 } from './render_bridge'; const MAX_U32 = 4_294_967_295; +const BUNDLE_PARTIAL: BootFailureReason = 'bundle_partial'; const AUCTION_PROTOCOLS = ['aps', 'gpt', 'prebid'] as const; const ACTION_KINDS = new Set(['gpt_adm', 'aps']); const TERMINAL_RESULTS = new Set(['accepted', 'failed', 'cancelled']); @@ -431,7 +432,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { ); return true; } catch { - return this.fail('bundle_partial'); + return this.fail(BUNDLE_PARTIAL); } } @@ -457,7 +458,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { this.observedMutationRevision = this.handoffOwner.mutationRevision; return observed; } - if (this.observedMutationRevision >= MAX_U32) return this.fail('bundle_partial'); + if (this.observedMutationRevision >= MAX_U32) return this.fail(BUNDLE_PARTIAL); this.observedMutationRevision += 1; return true; } @@ -520,7 +521,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { private settle(slotId: string, result: FirstDisplayTerminalResult, reason: string | null): void { if (!this.actionStarted) { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } if (this.stateValue !== 'active') return; @@ -531,17 +532,17 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { ? reason !== null : typeof reason !== 'string' || reason.length === 0 || reason.length > 256) ) { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } if (!this.pending.has(slotId)) { - if (!this.slotResults.has(slotId)) this.fail('bundle_partial'); + if (!this.slotResults.has(slotId)) this.fail(BUNDLE_PARTIAL); return; } if (result === 'accepted') { const atMs = this.readTiming(); if (atMs === undefined || this.nextTraceSequence > 4_294_967_295) { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } this.acceptedTrace.set( @@ -562,7 +563,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { this.stateValue = 'terminal'; this.terminalAtMs = this.readTiming(); if (this.terminalAtMs === undefined) { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } this.options.bootstrap.settle(); @@ -571,9 +572,9 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { } private recordFirstAction(): boolean { - if (this.stateValue !== 'active' || this.actionStarted) return this.fail('bundle_partial'); + if (this.stateValue !== 'active' || this.actionStarted) return this.fail(BUNDLE_PARTIAL); if (!this.options.bootstrap.startAction()) { - if (this.options.bootstrap.state !== 'failed') return this.fail('bundle_partial'); + if (this.options.bootstrap.state !== 'failed') return this.fail(BUNDLE_PARTIAL); this.failed = true; this.stateValue = 'failed'; this.disposeDriver(); @@ -582,7 +583,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { } this.actionStarted = true; const firstDisplayMs = this.readTiming(); - if (firstDisplayMs === undefined) return this.fail('bundle_partial'); + if (firstDisplayMs === undefined) return this.fail(BUNDLE_PARTIAL); this.firstActionAtMs = firstDisplayMs; this.mark('tsjs:first-display'); try { @@ -607,28 +608,28 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { try { this.options.driver.sealTsAdmission(); } catch { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } this.sealed = true; this.stateValue = 'painted'; this.paintAtMs = this.readTiming(); if (this.paintAtMs === undefined) { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } this.mark('tsjs:first-display-paint'); try { this.options.onProtectedPaint(); } catch { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); } }; try { if (this.options.paint.hidden()) this.options.paint.scheduleHidden(next); else this.options.paint.requestFrame(next); } catch { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); } } @@ -885,7 +886,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { }); this.mutationObserver = observer; } catch { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); } } @@ -894,7 +895,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { try { this.options.driver.sweepCommittedArtifacts(); } catch { - this.fail('bundle_partial'); + this.fail(BUNDLE_PARTIAL); return; } } diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts index 574bfe0b4..1f2caeea8 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts @@ -1,5 +1,4 @@ import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; -import { generateApsDataDocumentsV1 } from '../../shared/aps_documents'; import { createFirstDisplayRenderBridge, type FirstDisplayRenderBridgeOptionsV1, @@ -14,8 +13,9 @@ export type FirstDisplayApsDocumentMessageV1 = reason: 'descriptor_invalid' | 'runner_no_load' | 'runner_failed'; }>; -export interface FirstDisplayApsDocumentsV1 { - readonly outerUrl: string; +export interface FirstDisplayApsBootstrapPolicyV2 { + readonly creativeOrigin: string; + readonly tagType: 'iframe' | 'script'; } export interface FirstDisplayApsProtocolV1 { @@ -35,11 +35,9 @@ export interface FirstDisplayApsProtocolV1 { readonly isLifecycleTicket: (candidate: unknown) => candidate is string; readonly isBootstrapNonce: (candidate: unknown) => candidate is string; readonly isRendererNonce: (candidate: unknown) => candidate is string; - readonly generateDocuments: ( - renderer: unknown, - bootstrapNonce: string, - rendererNonce: string - ) => Readonly | undefined; + readonly bootstrapPolicy: ( + renderer: unknown + ) => Readonly | undefined; readonly parseDocumentMessage: ( candidate: unknown, expectedNonce: string @@ -172,6 +170,63 @@ function parseDocumentMessage( return undefined; } +function bootstrapPolicy( + candidate: unknown, + publisherOrigin: string +): Readonly | undefined { + try { + const renderer = exactRecord( + candidate, + Object.prototype.hasOwnProperty.call(candidate, 'creativeId') + ? [ + 'aaxResponse', + 'accountId', + 'bidId', + 'creativeId', + 'creativeUrl', + 'height', + 'tagType', + 'type', + 'version', + 'width', + ] + : [ + 'aaxResponse', + 'accountId', + 'bidId', + 'creativeUrl', + 'height', + 'tagType', + 'type', + 'version', + 'width', + ] + ); + if ( + !renderer || + renderer.type !== 'aps' || + renderer.version !== 1 || + (renderer.tagType !== 'iframe' && renderer.tagType !== 'script') || + typeof renderer.creativeUrl !== 'string' + ) { + return undefined; + } + const creative = new URL(renderer.creativeUrl); + if ( + creative.protocol !== 'https:' || + creative.hostname === '' || + creative.username !== '' || + creative.password !== '' || + creative.origin === publisherOrigin + ) { + return undefined; + } + return Object.freeze({ creativeOrigin: creative.origin, tagType: renderer.tagType }); + } catch { + return undefined; + } +} + /** Register the exact APS reservation identity and renderer-document protocol. */ export function installApsInitial( candidate: unknown, @@ -179,7 +234,7 @@ export function installApsInitial( ): Readonly<{ version: 1; id: 'aps' }> { const value = bindings(candidate); if (!value || typeof own !== 'function') throw new TypeError('tsjs'); - const rendererUrl = new URL('/integrations/aps/renderer/v1', value.publisherOrigin).href; + const rendererUrl = new URL('/integrations/aps/renderer/v2', value.publisherOrigin).href; const protocol: FirstDisplayApsProtocolV1 = Object.freeze({ version: 1, id: 'aps', @@ -197,15 +252,7 @@ export function installApsInitial( isLifecycleTicket: (input: unknown): input is string => exactOpaqueId(input, 't1_'), isBootstrapNonce: (input: unknown): input is string => exactOpaqueId(input, 'b1_'), isRendererNonce: (input: unknown): input is string => exactOpaqueId(input, 'n1_'), - generateDocuments: (renderer: unknown, bootstrapNonce: string, rendererNonce: string) => { - const documents = generateApsDataDocumentsV1({ - renderer, - publisherOrigin: value.publisherOrigin, - bootstrapNonce, - rendererNonce, - }); - return documents ? Object.freeze({ outerUrl: documents.outerUrl }) : undefined; - }, + bootstrapPolicy: (renderer: unknown) => bootstrapPolicy(renderer, value.publisherOrigin), parseDocumentMessage, createRenderBridge: (options: Omit) => createFirstDisplayRenderBridge({ ...options, getAps: () => protocol }), diff --git a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts index 559a0eb6f..5c834e151 100644 --- a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts @@ -9,6 +9,13 @@ import type { FirstDisplayApsProtocolV1 } from './leaf/aps_protocol'; const ADM_SANDBOX = 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; +const RENDERER_DOCUMENT_NO_LOAD = 'renderer_document_no_load'; +const INTERNAL_ERROR = 'internal_error'; +const RUNNER_FAILED = 'runner_failed'; +const ADM_DOCUMENT_NO_LOAD = 'adm_document_no_load'; +const WINNER_NOT_RENDERABLE = 'winner_not_renderable'; +const NAVIGATION_DISPOSED = 'navigation_disposed'; +const SLOT_UNRESOLVED = 'slot_unresolved'; const CLAIM_DEADLINE_MS = 3_000; const ADM_LOAD_DEADLINE_MS = 5_000; const TICKET_TTL_MS = 3_000; @@ -62,7 +69,6 @@ interface Attempt { readonly onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void; readonly reservationId: string; active: boolean; - apsContainerUrl: string | undefined; bootstrapNavigated: boolean; bootstrapNonce: string | undefined; bootstrapSource: object | undefined; @@ -89,7 +95,11 @@ interface Attempt { rendererNonce: string | undefined; ownerSource: object | undefined; pendingDocumentTerminal: - 'completed' | 'winner_not_renderable' | 'runner_no_load' | 'runner_failed' | undefined; + | 'completed' + | typeof WINNER_NOT_RENDERABLE + | 'runner_no_load' + | typeof RUNNER_FAILED + | undefined; phaseValue: | 'waiting_for_gam_and_claim' | 'waiting_for_owner' @@ -839,7 +849,6 @@ export function createFirstDisplayRenderBridge( attempt.bootstrapNavigated = false; attempt.bootstrapNonce = undefined; attempt.bootstrapSource = undefined; - attempt.apsContainerUrl = undefined; attempt.rendererNonce = undefined; retireTicket(attempt); retireReservation(attempt); @@ -880,7 +889,7 @@ export function createFirstDisplayRenderBridge( const settle = ( attempt: Attempt, result: 'accepted' | 'failed' | 'cancelled', - reason = result === 'cancelled' ? 'navigation_disposed' : 'internal_error' + reason = result === 'cancelled' ? NAVIGATION_DISPOSED : INTERNAL_ERROR ): boolean => { if (!attempt.active) return false; attempt.active = false; @@ -1023,33 +1032,36 @@ export function createFirstDisplayRenderBridge( const inspection = inspectPorts(event, messageEventPrototype); if (!inspection?.exact || inspection.originalCount !== 0 || inspection.ports.length !== 0) { for (const port of inspection?.ports ?? []) closePort(port); - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } const aps = apsProtocol(); if (!aps || attempt.bootstrapNavigated || !exactApsFrame(attempt, false)) { - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } - const containerUrl = attempt.apsContainerUrl; - if (!containerUrl) { - fail(attempt, 'winner_not_renderable'); + const rendererNonce = attempt.rendererNonce; + const policy = aps.bootstrapPolicy(attempt.cycle.bid.renderSource); + if (!rendererNonce || !policy) { + fail(attempt, WINNER_NOT_RENDERABLE); return true; } try { attempt.directFrame?.setAttribute('sandbox', aps.permanentSandbox); } catch { - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } const navigation = JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl, + rendererNonce, + creativeOrigin: policy.creativeOrigin, + tagType: policy.tagType, }); if (!exactApsFrame(attempt, true) || !postWindow(source, navigation)) { - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } attempt.bootstrapNavigated = true; @@ -1095,17 +1107,17 @@ export function createFirstDisplayRenderBridge( !exactApsFrame(attempt, true) ) { for (const candidate of inspection?.ports ?? []) closePort(candidate); - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } attempt.documentPort = port; attempt.documentRelease = listen( port, (portEvent) => handleApsDocument(attempt, portEvent), - () => fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load') + () => fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD) ); if (!attempt.documentRelease) { - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return true; } bootstrapNonces.delete(bootstrapNonce); @@ -1120,7 +1132,7 @@ export function createFirstDisplayRenderBridge( }) || !exactApsFrame(attempt, true) ) { - fail(attempt, 'renderer_document_no_load'); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); } return true; }; @@ -1129,12 +1141,12 @@ export function createFirstDisplayRenderBridge( const aps = apsProtocol(); if (!attempt.active || !attempt.rendererNonce || !aps) return; if (!exactApsFrame(attempt, true)) { - fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); + fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } const inspection = inspectPorts(event, messageEventPrototype); if (!inspection?.exact || inspection.originalCount !== 0 || inspection.ports.length !== 0) { - fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); + fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } const parsed = aps.parseDocumentMessage( @@ -1142,7 +1154,7 @@ export function createFirstDisplayRenderBridge( attempt.rendererNonce ); if (!parsed) { - fail(attempt, attempt.documentAccepted ? 'runner_failed' : 'renderer_document_no_load'); + fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } const acceptDocument = (): void => { @@ -1154,10 +1166,7 @@ export function createFirstDisplayRenderBridge( attempt.documentAcceptancePending = false; clearOwnedTimer(attempt.documentTimer); attempt.documentTimer = undefined; - attempt.completionTimer = arm( - () => fail(attempt, 'runner_failed'), - aps.deadlines.completionMs - ); + attempt.completionTimer = arm(() => fail(attempt, RUNNER_FAILED), aps.deadlines.completionMs); const pending = attempt.pendingDocumentTerminal; attempt.pendingDocumentTerminal = undefined; if (pending === 'completed') completeAps(attempt); @@ -1176,36 +1185,36 @@ export function createFirstDisplayRenderBridge( if (attempt.documentAccepted) completeAps(attempt); else if (attempt.documentAcceptancePending && !attempt.pendingDocumentTerminal) { attempt.pendingDocumentTerminal = 'completed'; - } else fail(attempt, 'renderer_document_no_load'); + } else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return; } const failureReason = - parsed.reason === 'descriptor_invalid' ? 'winner_not_renderable' : parsed.reason; + parsed.reason === 'descriptor_invalid' ? WINNER_NOT_RENDERABLE : parsed.reason; if (attempt.documentAccepted) fail(attempt, failureReason); else if (attempt.documentAcceptancePending && !attempt.pendingDocumentTerminal) { attempt.pendingDocumentTerminal = failureReason; - } else fail(attempt, 'renderer_document_no_load'); + } else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); } function completeAps(attempt: Attempt): void { if (!attempt.active || !exactApsFrame(attempt, true)) { - fail(attempt, 'slot_unresolved'); + fail(attempt, SLOT_UNRESOLVED); return; } if (attempt.overlay) { try { const frame = attempt.directFrame; if (!frame) { - fail(attempt, 'slot_unresolved'); + fail(attempt, SLOT_UNRESOLVED); return; } frame.style.setProperty('visibility', 'visible'); if (frame.style.getPropertyValue('visibility') !== 'visible') { - fail(attempt, 'internal_error'); + fail(attempt, INTERNAL_ERROR); return; } } catch { - fail(attempt, 'internal_error'); + fail(attempt, INTERNAL_ERROR); return; } } @@ -1218,10 +1227,7 @@ export function createFirstDisplayRenderBridge( clearOwnedTimer(attempt.insertionTimer); attempt.insertionTimer = undefined; if (attempt.cycle.bid.renderSource.type === 'adm') { - attempt.documentTimer = arm( - () => fail(attempt, 'adm_document_no_load'), - ADM_LOAD_DEADLINE_MS - ); + attempt.documentTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); } else if (attempt.documentAcceptancePending) { const nonce = attempt.rendererNonce; if (nonce) { @@ -1233,7 +1239,7 @@ export function createFirstDisplayRenderBridge( } else { const aps = apsProtocol(); attempt.documentTimer = arm( - () => fail(attempt, 'renderer_document_no_load'), + () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD), aps?.deadlines.documentAcceptanceMs ?? 3_000 ); } @@ -1243,7 +1249,7 @@ export function createFirstDisplayRenderBridge( if (!attempt.active) return; const ports = inspectPorts(event, messageEventPrototype); if (!ports?.exact || ports.originalCount !== 0 || ports.ports.length !== 0) { - fail(attempt, 'internal_error'); + fail(attempt, INTERNAL_ERROR); return; } const data = eventField(event, 'data', messageEventPrototype); @@ -1259,7 +1265,7 @@ export function createFirstDisplayRenderBridge( return; } if (attempt.cycle.bid.renderSource.type !== 'adm') { - fail(attempt, 'internal_error'); + fail(attempt, INTERNAL_ERROR); return; } const loaded = exactRecord(data, ['message', 'version', 'lifecycleTicket']); @@ -1277,10 +1283,10 @@ export function createFirstDisplayRenderBridge( loaded.version === 1 && loaded.lifecycleTicket === ticket ) { - fail(attempt, 'adm_document_no_load'); + fail(attempt, ADM_DOCUMENT_NO_LOAD); return; } - fail(attempt, 'adm_document_no_load'); + fail(attempt, ADM_DOCUMENT_NO_LOAD); }; const startOwner = (attempt: Attempt): boolean => { @@ -1300,7 +1306,7 @@ export function createFirstDisplayRenderBridge( source: attempt.cycle.bid.renderSource, }); } - if (!aps) return fail(attempt, 'winner_not_renderable'); + if (!aps) return fail(attempt, WINNER_NOT_RENDERABLE); attempt.overlay = true; if (!renderAps(attempt)) return false; return ( @@ -1308,7 +1314,7 @@ export function createFirstDisplayRenderBridge( message: 'TS APS Top Mount Started', version: 1, lifecycleTicket: ticket, - }) || fail(attempt, 'internal_error') + }) || fail(attempt, INTERNAL_ERROR) ); }; @@ -1356,7 +1362,7 @@ export function createFirstDisplayRenderBridge( } catch { post(responsePort, ownerRefused(attempt.reservationId)); closePort(responsePort); - fail(attempt, 'internal_error'); + fail(attempt, INTERNAL_ERROR); return; } attempt.ownerTicket = ticket; @@ -1367,7 +1373,7 @@ export function createFirstDisplayRenderBridge( () => fail( attempt, - attempt.cycle.bid.renderSource.type === 'adm' ? 'adm_document_no_load' : 'internal_error' + attempt.cycle.bid.renderSource.type === 'adm' ? ADM_DOCUMENT_NO_LOAD : INTERNAL_ERROR ) ); attempt.phaseValue = 'waiting_for_insertion'; @@ -1382,7 +1388,7 @@ export function createFirstDisplayRenderBridge( closePort(responsePort); closePort(channel.port2); if (!posted || !startOwner(attempt)) { - if (attempt.active) fail(attempt, 'internal_error'); + if (attempt.active) fail(attempt, INTERNAL_ERROR); } }; @@ -1463,7 +1469,7 @@ export function createFirstDisplayRenderBridge( const posted = utf8Length(response) <= MAX_RESPONSE_BYTES && post(claim.port, response); closePort(claim.port); if (!posted || !attempt.active) { - if (attempt.active) fail(attempt, 'internal_error'); + if (attempt.active) fail(attempt, INTERNAL_ERROR); return false; } attempt.ownerSource = claim.source; @@ -1537,17 +1543,14 @@ export function createFirstDisplayRenderBridge( settle(attempt, 'accepted'); } }; - frame.onerror = () => fail(attempt, 'adm_document_no_load'); + frame.onerror = () => fail(attempt, ADM_DOCUMENT_NO_LOAD); frame.srcdoc = intended; attempt.directFrame = frame; - attempt.documentTimer = arm( - () => fail(attempt, 'adm_document_no_load'), - ADM_LOAD_DEADLINE_MS - ); + attempt.documentTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); attempt.cycle.element.appendChild(frame); return true; } catch { - return fail(attempt, 'adm_document_no_load'); + return fail(attempt, ADM_DOCUMENT_NO_LOAD); } }; @@ -1561,42 +1564,33 @@ export function createFirstDisplayRenderBridge( aps.deadlines.insertionMs ); } - if (attempt.insertionTimer === undefined) return fail(attempt, 'internal_error'); + if (attempt.insertionTimer === undefined) return fail(attempt, INTERNAL_ERROR); const bootstrapNonce = issueBootstrapNonce(attempt); const rendererNonce = issueRendererNonce(attempt); if (!bootstrapNonce || !rendererNonce) return fail(attempt, 'capability_registry_full'); - let documents: ReturnType; + let policy: ReturnType; try { - documents = aps.generateDocuments(source, bootstrapNonce, rendererNonce); + policy = aps.bootstrapPolicy(source); } catch { - documents = undefined; - } - if ( - !documents || - typeof documents.outerUrl !== 'string' || - !documents.outerUrl.startsWith('data:text/html;charset=utf-8,') || - !documents.outerUrl.endsWith('#' + bootstrapNonce) || - utf8Length(documents.outerUrl) > 196_663 - ) { - return fail(attempt, 'winner_not_renderable'); + policy = undefined; } + if (!policy) return fail(attempt, WINNER_NOT_RENDERABLE); try { const frame = options.document.createElement('iframe'); configureFrame(frame, source.width, source.height, aps.sandbox, attempt.overlay); const intended = aps.rendererUrl + '#' + bootstrapNonce; - frame.onerror = () => fail(attempt, 'renderer_document_no_load'); + frame.onerror = () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD); frame.src = intended; - attempt.apsContainerUrl = documents.outerUrl; attempt.directFrame = frame; - if (!acquireOverlayPosition(attempt)) return fail(attempt, 'slot_unresolved'); + if (!acquireOverlayPosition(attempt)) return fail(attempt, SLOT_UNRESOLVED); attempt.cycle.element.appendChild(frame); const intendedWindow = frame.contentWindow; - if (!intendedWindow) return fail(attempt, 'renderer_document_no_load'); + if (!intendedWindow) return fail(attempt, RENDERER_DOCUMENT_NO_LOAD); attempt.bootstrapSource = intendedWindow; ownerInserted(attempt); - return exactApsFrame(attempt, false) || fail(attempt, 'renderer_document_no_load'); + return exactApsFrame(attempt, false) || fail(attempt, RENDERER_DOCUMENT_NO_LOAD); } catch { - return fail(attempt, 'renderer_document_no_load'); + return fail(attempt, RENDERER_DOCUMENT_NO_LOAD); } }; @@ -1697,7 +1691,6 @@ export function createFirstDisplayRenderBridge( } const attempt: Attempt = { active: true, - apsContainerUrl: undefined, bootstrapNavigated: false, bootstrapNonce: undefined, bootstrapSource: undefined, @@ -1756,7 +1749,7 @@ export function createFirstDisplayRenderBridge( if (result === 'gam_empty') return renderDirectFallback(attempt); if (attempt.claim) return join(attempt); attempt.claimTimer = arm(() => fail(attempt, 'bridge_claim_timeout'), CLAIM_DEADLINE_MS); - return attempt.claimTimer !== undefined || fail(attempt, 'internal_error'); + return attempt.claimTimer !== undefined || fail(attempt, INTERNAL_ERROR); }, recordFailure: (cycle: FirstDisplayGptBoundCycleV1): boolean => { const attempt = attempts.get(cycle.bid.rendererReservationId); @@ -1861,7 +1854,7 @@ export function createFirstDisplayRenderBridge( } } for (const attempt of [...attempts.values()]) { - if (attempt.active) settle(attempt, 'cancelled', 'navigation_disposed'); + if (attempt.active) settle(attempt, 'cancelled', NAVIGATION_DISPOSED); } for (const handle of [...timers]) clearOwnedTimer(handle); if (!committedArtifactsDetached) { diff --git a/crates/trusted-server-js/lib/src/integrations/aps/module.ts b/crates/trusted-server-js/lib/src/integrations/aps/module.ts index 821168fb1..596e34fc1 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/module.ts @@ -18,7 +18,7 @@ import type { PucApsMountInput } from '../../services/puc_bridge'; import { renderDirectApsAttempt, renderPucApsAttempt, - resolveApsRendererV1Url, + resolveApsRendererV2Url, validateApsRenderer, } from './render'; @@ -85,7 +85,7 @@ export function createApsIntegrationRegistration(releaseId: string): Integration ) { throw new TypeError('APS capability graph is malformed'); } - const rendererUrl = resolveApsRendererV1Url(render.publisherOrigin); + const rendererUrl = resolveApsRendererV2Url(render.publisherOrigin); if (!rendererUrl) throw new TypeError('APS publisher origin is invalid'); let active = false; const renderer = (attempt: RenderAttempt, container: HTMLElement): boolean => 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 732c3512f..913edd61e 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -11,8 +11,6 @@ import type { } from '../../services/render'; import type { ApsSlotMountBinding } from '../../services/slots'; -import { APS_PERMANENT_SANDBOX, generateApsDataDocumentsV1 } from './documents'; - const objectFreezeIntrinsic = Object.freeze; const objectGetPrototypeOfIntrinsic = Object.getPrototypeOf; const regexpTestIntrinsic = RegExp.prototype.test; @@ -84,11 +82,12 @@ const iframeSourceGetter = directDomAvailable : undefined; export { parseApsRendererDescriptor, validateApsRenderer } from '../../core/contracts/aps_renderer'; -export { APS_PERMANENT_SANDBOX, generateApsDataDocumentsV1 }; -export const APS_RENDERER_V1_PATH = '/integrations/aps/renderer/v1'; +export const APS_RENDERER_V2_PATH = '/integrations/aps/renderer/v2'; export const APS_RENDERER_SANDBOX = 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; +export const APS_PERMANENT_SANDBOX = + 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation'; /** Validate, copy, and freeze one APS tagged render source. */ export function prepareApsRenderSource( @@ -135,7 +134,7 @@ function freeze(value: Value): Readonly { return Reflect.apply(objectFreezeIntrinsic, Object, [value]) as Readonly; } -export function resolveApsRendererV1Url(publisherOrigin: string): string | undefined { +export function resolveApsRendererV2Url(publisherOrigin: string): string | undefined { try { const origin = new URL(publisherOrigin); const loopbackHttp = @@ -151,10 +150,10 @@ export function resolveApsRendererV1Url(publisherOrigin: string): string | undef ) { return undefined; } - const rendererUrl = new URL(APS_RENDERER_V1_PATH, origin); + const rendererUrl = new URL(APS_RENDERER_V2_PATH, origin); if ( rendererUrl.origin !== origin.origin || - rendererUrl.pathname !== APS_RENDERER_V1_PATH || + rendererUrl.pathname !== APS_RENDERER_V2_PATH || rendererUrl.search !== '' || rendererUrl.hash !== '' ) { @@ -320,11 +319,18 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole exactDocumentOrigin = false; } const renderer = prepareApsRenderSource(sourceCandidate, publisherOrigin); - const rendererUrl = resolveApsRendererV1Url(publisherOrigin); + const rendererUrl = resolveApsRendererV2Url(publisherOrigin); + let creativeOrigin: string | undefined; + try { + creativeOrigin = renderer ? new URL(renderer.creativeUrl).origin : undefined; + } catch { + creativeOrigin = undefined; + } if ( !exactDocumentOrigin || !renderer || !rendererUrl || + !creativeOrigin || (mode === 'puc_overlay' && !expectedContainerId) ) { try { @@ -446,13 +452,6 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole const bootstrapNonce = issuedBootstrap.nonce; const rendererNonce = issuedRenderer.nonce; - const documents = generateApsDataDocumentsV1({ - renderer, - publisherOrigin, - bootstrapNonce, - rendererNonce, - }); - if (!documents) return fail('winner_not_renderable'); let iframe: HTMLIFrameElement; try { @@ -923,10 +922,12 @@ export function mountApsTopPageAttempt(options: ApsTopPageAttemptOptions): boole return; } const navigation = JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: documents.outerUrl, + rendererNonce, + creativeOrigin, + tagType: renderer.tagType, }); if ( Reflect.apply(postWindowMethod, messaging, [frameSource, navigation, '*', []]) !== true || diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/later.ts b/crates/trusted-server-js/lib/src/integrations/gpt/later.ts index 6ec9a5f7c..146da8136 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/later.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/later.ts @@ -72,6 +72,7 @@ export function createGptLaterIntegrationRegistration(releaseId: string): Integr ) { throw new TypeError('GPT later capability graph is invalid'); } + const pageBidsEnabled = config.pageBidsEnabled; return Object.freeze({ activate: ({ onDispose }: IntegrationActivationContext) => { const candidateView = runtime.document.defaultView; @@ -176,6 +177,7 @@ export function createGptLaterIntegrationRegistration(releaseId: string): Integr ) { throw new TypeError('GPT later lifecycle owner is invalid'); } + if (!pageBidsEnabled) return; wrappedPushState = wrap(pushState); wrappedReplaceState = wrap(replaceState); Object.defineProperty(history, 'pushState', { diff --git a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts index ae0dbdff6..75c31417e 100644 --- a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts @@ -460,7 +460,7 @@ interface ApsMessagingValidationRegistration { readonly validateApsRenderer: (candidate: unknown) => boolean; } -const APS_RENDERER_PATH = '/integrations/aps/renderer/v1'; +const APS_RENDERER_PATH = '/integrations/aps/renderer/v2'; function exactRuntimeCapability( interfaces: Readonly> diff --git a/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts b/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts index 663c497d5..759449541 100644 --- a/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts +++ b/crates/trusted-server-js/lib/src/kernel/contracts/message_protocol.ts @@ -15,7 +15,7 @@ export const TSJS_MESSAGE_PROTOCOL_V1 = Object.freeze({ admLoaded: 'TS ADM Loaded' as const, admFailed: 'TS ADM Failed' as const, apsBootstrapReady: 'TS APS Bootstrap Ready' as const, - apsBootstrapNavigate: 'TS APS Bootstrap Navigate' as const, + apsBootstrapConfigure: 'TS APS Bootstrap Configure' as const, apsInnerReady: 'TS APS Inner Ready' as const, apsInnerBind: 'TS APS Inner Bind' as const, apsContainerReady: 'TS APS Container Ready' as const, diff --git a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts index d7403b465..79d7c2e8e 100644 --- a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts +++ b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts @@ -148,10 +148,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object id: 'aps_initial', include: 'aps_participates', allowedImports: [ - 'core/contracts/aps_renderer', - 'core/contracts/generated/renderer_validator_document_v1', - 'core/contracts/generated/renderer_validator_v1', - 'shared/aps_documents', 'shared/first_display_contracts', 'first_display/registration_client', 'first_display/slices/definition', diff --git a/crates/trusted-server-js/lib/src/shared/aps_documents.ts b/crates/trusted-server-js/lib/src/shared/aps_documents.ts index 8395256b8..94fd77e52 100644 --- a/crates/trusted-server-js/lib/src/shared/aps_documents.ts +++ b/crates/trusted-server-js/lib/src/shared/aps_documents.ts @@ -72,6 +72,8 @@ export interface ApsDataDocumentsV1 { readonly innerUrl: string; } +// Build-time authority for the renderer-v2 response generator and its contract tests. +// Production TSJS artifacts must not import this module. const INNER_TEMPLATE = ` diff --git a/crates/trusted-server-js/lib/src/shared/integration_config_validators.ts b/crates/trusted-server-js/lib/src/shared/integration_config_validators.ts index a32a80186..4ae83f69a 100644 --- a/crates/trusted-server-js/lib/src/shared/integration_config_validators.ts +++ b/crates/trusted-server-js/lib/src/shared/integration_config_validators.ts @@ -75,9 +75,19 @@ function frozenStringArray(candidate: unknown): candidate is readonly string[] { } } -export function isGptIntegrationConfigV1(candidate: unknown): boolean { - const config = exactFrozenRecord(candidate, ['gamAttributionEnabled']); - return typeof config?.gamAttributionEnabled === 'boolean'; +export interface GptIntegrationConfigV1 { + readonly gamAttributionEnabled: boolean; + readonly pageBidsEnabled: boolean; +} + +export function isGptIntegrationConfigV1( + candidate: unknown +): candidate is Readonly { + const config = exactFrozenRecord(candidate, ['gamAttributionEnabled', 'pageBidsEnabled']); + return ( + typeof config?.gamAttributionEnabled === 'boolean' && + typeof config.pageBidsEnabled === 'boolean' + ); } export function isDidomiIntegrationConfigV1(candidate: unknown): boolean { 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 357121707..88b72a384 100644 --- a/crates/trusted-server-js/lib/test/adapters/messaging.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/messaging.test.ts @@ -693,7 +693,7 @@ describe('browser messaging adapter', () => { admLoaded: 'TS ADM Loaded', admFailed: 'TS ADM Failed', apsBootstrapReady: 'TS APS Bootstrap Ready', - apsBootstrapNavigate: 'TS APS Bootstrap Navigate', + apsBootstrapConfigure: 'TS APS Bootstrap Configure', apsInnerReady: 'TS APS Inner Ready', apsInnerBind: 'TS APS Inner Bind', apsContainerReady: 'TS APS Container Ready', @@ -773,13 +773,19 @@ describe('browser messaging adapter', () => { const adapter = createBrowserMessagingAdapter(createTarget()); const bootstrapNonce = `b1_${'b'.repeat(22)}`; const rendererNonce = `n1_${'n'.repeat(22)}`; - const documentSource = '

container

'; - const containerUrl = `data:text/html;charset=utf-8,${encodeURIComponent(documentSource)}#${bootstrapNonce}`; + const creativeOrigin = 'https://creative.example'; const messages = [ ['apsBootstrapReady', { message: 'TS APS Bootstrap Ready', version: 1, bootstrapNonce }], [ - 'apsBootstrapNavigate', - { message: 'TS APS Bootstrap Navigate', version: 1, bootstrapNonce, containerUrl }, + 'apsBootstrapConfigure', + { + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce, + creativeOrigin, + tagType: 'iframe', + }, ], ['apsInnerReady', { message: 'TS APS Inner Ready', version: 1, rendererNonce }], ['apsInnerBind', { message: 'TS APS Inner Bind', version: 1, rendererNonce }], @@ -801,46 +807,46 @@ describe('browser messaging adapter', () => { } expect( adapter.parseProtocolMessage( - 'apsBootstrapNavigate', - `{"message":"TS APS Bootstrap Navigate","version":1,"bootstrapNonce":"${bootstrapNonce}","bootstrapNonce":"${bootstrapNonce}","containerUrl":${JSON.stringify(containerUrl)}}` + 'apsBootstrapConfigure', + `{"message":"TS APS Bootstrap Configure","version":2,"bootstrapNonce":"${bootstrapNonce}","bootstrapNonce":"${bootstrapNonce}","rendererNonce":"${rendererNonce}","creativeOrigin":"${creativeOrigin}","tagType":"iframe"}` ) ).toBeUndefined(); expect( adapter.parseProtocolMessage( - 'apsBootstrapNavigate', + 'apsBootstrapConfigure', JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: `${containerUrl}x`, + rendererNonce, + creativeOrigin: 'http://creative.example', + tagType: 'iframe', }) ) ).toBeUndefined(); - const dataPrefix = 'data:text/html;charset=utf-8,'; - const nonceSuffix = `#${bootstrapNonce}`; - const boundaryUrl = `${dataPrefix}${'a'.repeat( - 196_663 - dataPrefix.length - nonceSuffix.length - )}${nonceSuffix}`; - expect(new TextEncoder().encode(boundaryUrl)).toHaveLength(196_663); expect( adapter.parseProtocolMessage( - 'apsBootstrapNavigate', + 'apsBootstrapConfigure', JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: boundaryUrl, + rendererNonce, + creativeOrigin, + tagType: 'script', }) ) ).toBeDefined(); expect( adapter.parseProtocolMessage( - 'apsBootstrapNavigate', + 'apsBootstrapConfigure', JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: `${boundaryUrl.slice(0, -nonceSuffix.length)}a${nonceSuffix}`, + rendererNonce, + creativeOrigin, + tagType: 'image', }) ) ).toBeUndefined(); @@ -1081,7 +1087,7 @@ describe('browser messaging adapter', () => { expect( adapter.parseProtocolMessage('apsTopMountStarted', { ...message, - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', }) ).toBeUndefined(); }); @@ -1096,7 +1102,7 @@ describe('browser messaging adapter', () => { }); const adapter = createBrowserMessagingAdapter(createTarget(), { expectedPublisherOrigin: 'https://publisher.example', - expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', validateApsRenderer: validator, }); const parsed = adapter.parseProtocolMessage('apsEnvelope', { @@ -1118,7 +1124,7 @@ describe('browser messaging adapter', () => { const validator = vi.fn(() => true); const adapter = createBrowserMessagingAdapter(createTarget(), { expectedPublisherOrigin: 'https://publisher.example', - expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + expectedRendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', validateApsRenderer: validator, }); const parse = (renderer: unknown) => 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 018ec6294..8443d401c 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 @@ -436,7 +436,7 @@ test('generated first-display components self-register through one authenticated return function() {}; } }), - config: Object.freeze({gamAttributionEnabled: false}) + config: Object.freeze({gamAttributionEnabled: false, pageBidsEnabled: false}) }); } return undefined; @@ -720,7 +720,7 @@ test('co-bundled render_runtime and independent GPT start one branded display fl version: 1, entries: freeze([freeze({ id: 'gpt', - config: freeze({ gamAttributionEnabled: false }) + config: freeze({ gamAttributionEnabled: false, pageBidsEnabled: false }) })]) }), creative: freeze({ 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 f0ed3446a..75428af83 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -1950,7 +1950,7 @@ describe('browser composition', () => { it('injects GPT and Prebid module boundaries with only server-frozen configuration', async () => { const releaseId = 'a'.repeat(64); const target = {}; - const gptConfig = Object.freeze({ gamAttributionEnabled: false }); + const gptConfig = Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }); const prebidConfig = Object.freeze({ accountId: 'test', timeout: 1_000, @@ -2079,7 +2079,10 @@ describe('browser composition', () => { integrations: { version: 1, entries: [ - { id: 'gpt', config: { gamAttributionEnabled: false } }, + { + id: 'gpt', + config: { gamAttributionEnabled: false, pageBidsEnabled: true }, + }, { id: 'prebid', config: prebidConfig }, ], }, 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 b34fb61f0..79700c42e 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 @@ -183,7 +183,9 @@ function tracedRegistration( function integrationConfig(id: string): unknown { if (id === 'didomi') return Object.freeze({ proxyPath: '/integrations/didomi/consent/' }); - if (id === 'gpt') return Object.freeze({ gamAttributionEnabled: false }); + if (id === 'gpt') { + return Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }); + } if (id === 'prebid') { return Object.freeze({ accountId: 'test', diff --git a/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs b/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs index 457b22482..aecd94e89 100644 --- a/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs +++ b/crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs @@ -16,7 +16,7 @@ const validatorUrl = new URL( const validatorSource = await readFile(validatorUrl, 'utf8'); const bootstrapDocument = await readFile( new URL( - '../../../../trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html', + '../../../../trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html', import.meta.url ), 'utf8' @@ -168,7 +168,10 @@ function bootstrapScript() { return match[1]; } -function executeBootstrap(hash = '#b1_AAECAwQFBgcICQoLDA0ODw') { +function executeBootstrap( + hash = '#b1_AAECAwQFBgcICQoLDA0ODw', + referrer = 'https://publisher.example/article' +) { const parent = { messages: [], postMessage(message, origin) { @@ -180,11 +183,11 @@ function executeBootstrap(hash = '#b1_AAECAwQFBgcICQoLDA0ODw') { const context = vm.createContext({ URL, TextEncoder, - document: { referrer: 'https://publisher.example/article' }, + document: { referrer }, history: { replaceState() {} }, location: { hash, - pathname: '/integrations/aps/renderer/v1', + pathname: '/integrations/aps/renderer/v2', search: '', replace(value) { replacements.push(value); @@ -201,7 +204,7 @@ function executeBootstrap(hash = '#b1_AAECAwQFBgcICQoLDA0ODw') { }, }); vm.runInContext(bootstrapScript(), context, { - filename: 'aps_renderer_bootstrap_v1.html', + filename: 'aps_renderer_bootstrap_v2.html', }); return { parent, @@ -212,96 +215,94 @@ function executeBootstrap(hash = '#b1_AAECAwQFBgcICQoLDA0ODw') { }; } -test('the generated renderer response is an ES5 descriptor-free bootstrap', () => { +test('the generated renderer response is an ES5 descriptor-isolated materializer', () => { const source = bootstrapScript(); assert.doesNotMatch(source, /=>|\b(?:const|let|class)\b|\?\.|\?\?/); - assert.doesNotMatch( - source, - /aaxResponse|accountId|bidId|creativeId|creativeUrl|runner|createElement|iframe|fetch|XMLHttpRequest/i - ); + assert.doesNotMatch(source, /example-account-id|fictional-selected-bid-id|fictional-creative-id/u); + assert.doesNotMatch(source, /fetch|XMLHttpRequest/u); assert.match(source, /TS APS Bootstrap Ready/); - assert.match(source, /TS APS Bootstrap Navigate/); + assert.match(source, /TS APS Bootstrap Configure/); + assert.match(source, /classifyApsRendererV1/u); assert.match(source, /data:text\/html;charset=utf-8,/); }); -test('the generated bootstrap accepts one exact parent navigation instruction', () => { - const nonce = 'b1_AAECAwQFBgcICQoLDA0ODw'; - const harness = executeBootstrap(`#${nonce}`); - assert.deepEqual(harness.parent.messages, [ - { - message: JSON.stringify({ - message: 'TS APS Bootstrap Ready', - version: 1, - bootstrapNonce: nonce, - }), - origin: 'https://publisher.example', - }, - ]); - - const containerUrl = `data:text/html;charset=utf-8,%3C!doctype%20html%3E#${nonce}`; - const navigation = JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, - bootstrapNonce: nonce, - containerUrl, - }); - harness.dispatch({ - source: harness.parent, - origin: 'https://publisher.example', - data: navigation, - ports: [], +test('the generated bootstrap materializes the opaque documents after first-action configuration', () => { + const bootstrapNonce = 'b1_AAECAwQFBgcICQoLDA0ODw'; + const rendererNonce = 'n1_AAECAwQFBgcICQoLDA0ODw'; + const harness = executeBootstrap(`#${bootstrapNonce}`); + const configuration = JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce, + creativeOrigin: 'https://creative.example', + tagType: 'iframe', }); + harness.dispatch({ source: harness.parent, origin: 'https://publisher.example', - data: navigation, + data: configuration, ports: [], }); - assert.deepEqual(harness.replacements, [containerUrl]); -}); -test('the generated bootstrap accepts the exact container URL boundary', () => { - const nonce = `b1_${'a'.repeat(22)}`; - const prefix = 'data:text/html;charset=utf-8,'; - const suffix = `#${nonce}`; - const containerUrl = `${prefix}${'a'.repeat(196_663 - prefix.length - suffix.length)}${suffix}`; - const navigation = JSON.stringify({ - message: 'TS APS Bootstrap Navigate', - version: 1, - bootstrapNonce: nonce, - containerUrl, - }); - assert.equal(Buffer.byteLength(containerUrl), 196_663); - assert.ok(Buffer.byteLength(navigation) <= 196_800); - const harness = executeBootstrap(`#${nonce}`); + assert.equal(harness.replacements.length, 1); + const containerUrl = harness.replacements[0]; + assert.match(containerUrl, /^data:text\/html;charset=utf-8,/u); + assert.match(containerUrl, new RegExp(`#${bootstrapNonce}$`, 'u')); + const outerDocument = decodeURIComponent( + containerUrl.slice('data:text/html;charset=utf-8,'.length, -(`#${bootstrapNonce}`).length) + ); + assert.match(outerDocument, new RegExp(rendererNonce, 'u')); + assert.match(outerDocument, /TS APS Container Ready/u); + assert.doesNotMatch( + outerDocument, + /example-account-id|fictional-selected-bid-id|fictional-creative-id/u + ); +}); +test('the generated bootstrap authenticates the parent event without referrer dependence', () => { + const bootstrapNonce = 'b1_AAECAwQFBgcICQoLDA0ODw'; + const harness = executeBootstrap(`#${bootstrapNonce}`, ''); + assert.deepEqual(harness.parent.messages, [ + { + message: JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce, + }), + origin: '*', + }, + ]); harness.dispatch({ source: harness.parent, origin: 'https://publisher.example', - data: navigation, + data: JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce, + rendererNonce: 'n1_AAECAwQFBgcICQoLDA0ODw', + creativeOrigin: 'https://creative.example', + tagType: 'iframe', + }), ports: [], }); - - assert.deepEqual(harness.replacements, [containerUrl]); + assert.equal(harness.replacements.length, 1); }); -test('the generated bootstrap rejects malformed, misbound, and oversized navigation', () => { +test('the generated bootstrap rejects malformed, misbound, and oversized configuration', () => { const nonce = 'b1_AAECAwQFBgcICQoLDA0ODw'; - const containerUrl = `data:text/html;charset=utf-8,%3C!doctype%20html%3E#${nonce}`; - const containerPrefix = 'data:text/html;charset=utf-8,'; - const containerSuffix = `#${nonce}`; - const overlongContainerUrl = `${containerPrefix}${'a'.repeat( - 196_664 - containerPrefix.length - containerSuffix.length - )}${containerSuffix}`; const valid = { - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce: nonce, - containerUrl, + rendererNonce: 'n1_AAECAwQFBgcICQoLDA0ODw', + creativeOrigin: 'https://creative.example', + tagType: 'iframe', }; const cases = [ { source: {}, origin: 'https://publisher.example', data: JSON.stringify(valid), ports: [] }, - { source: null, origin: 'https://attacker.example', data: JSON.stringify(valid), ports: [] }, + { source: null, origin: 'null', data: JSON.stringify(valid), ports: [] }, { source: null, origin: 'https://publisher.example', @@ -317,7 +318,7 @@ test('the generated bootstrap rejects malformed, misbound, and oversized navigat { source: null, origin: 'https://publisher.example', - data: JSON.stringify(valid).replace('"version":1', '"version":1,"version":1'), + data: JSON.stringify(valid).replace('"version":2', '"version":2,"version":2'), ports: [], }, { @@ -331,14 +332,14 @@ test('the generated bootstrap rejects malformed, misbound, and oversized navigat origin: 'https://publisher.example', data: JSON.stringify({ ...valid, - containerUrl: overlongContainerUrl, + creativeOrigin: 'https://publisher.example', }), ports: [], }, { source: null, origin: 'https://publisher.example', - data: 'x'.repeat(196_801), + data: 'x'.repeat(16_385), ports: [], }, ]; diff --git a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts index 307cd4dd8..408ff3298 100644 --- a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts @@ -117,7 +117,7 @@ function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) version: 1 as const, id: 'aps' as const, publisherOrigin: 'https://publisher.example', - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', sandbox: 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation', permanentSandbox: @@ -136,16 +136,10 @@ function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) typeof candidate === 'string' && /^b1_[A-Za-z0-9_-]{22}$/.test(candidate), isRendererNonce: (candidate: unknown): candidate is string => typeof candidate === 'string' && /^n1_[A-Za-z0-9_-]{22}$/.test(candidate), - generateDocuments: ( - _renderer: unknown, - bootstrapNonce: string, - rendererNonce: string - ) => + bootstrapPolicy: () => Object.freeze({ - outerUrl: - 'data:text/html;charset=utf-8,' + - encodeURIComponent(`rendererNonce=${rendererNonce}`) + - `#${bootstrapNonce}`, + creativeOrigin: 'https://creative.example', + tagType: 'iframe' as const, }), createRenderBridge: () => { throw new Error('the full bridge fixture already owns construction'); @@ -317,12 +311,7 @@ function startApsDocument(h: ReturnType) { string, unknown >; - const decodedOuter = decodeURIComponent( - (navigation.containerUrl as string) - .slice('data:text/html;charset=utf-8,'.length) - .split('#')[0] ?? '' - ); - const nonce = /rendererNonce=(n1_[A-Za-z0-9_-]{22})/.exec(decodedOuter)?.[1]; + const nonce = navigation.rendererNonce; if (!nonce) throw new Error('expected the renderer nonce'); const documentPort = new FakePort(); h.dispatch({ @@ -723,21 +712,19 @@ describe('bounded first-display render bridge', () => { unknown >; expect(navigation).toMatchObject({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: expect.stringMatching(/^data:text\/html;charset=utf-8,/), + rendererNonce: expect.stringMatching(/^n1_[A-Za-z0-9_-]{22}$/), + creativeOrigin: 'https://creative.example', + tagType: 'iframe', }); + expect(navigation).not.toHaveProperty('containerUrl'); expect(postMessage.mock.calls[0]?.slice(1)).toEqual(['*', []]); expect(frame?.getAttribute('sandbox')).toBe( 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation' ); - const decodedOuter = decodeURIComponent( - (navigation.containerUrl as string) - .slice('data:text/html;charset=utf-8,'.length) - .split('#')[0] ?? '' - ); - const nonce = /rendererNonce=(n1_[A-Za-z0-9_-]{22})/.exec(decodedOuter)?.[1]; + const nonce = navigation.rendererNonce as string; expect(nonce).toMatch(/^n1_[A-Za-z0-9_-]{22}$/); const documentPort = new FakePort(); h.dispatch({ @@ -804,12 +791,7 @@ describe('bounded first-display render bridge', () => { string, unknown >; - const decodedOuter = decodeURIComponent( - (navigation.containerUrl as string) - .slice('data:text/html;charset=utf-8,'.length) - .split('#')[0] ?? '' - ); - const rendererNonce = /rendererNonce=(n1_[A-Za-z0-9_-]{22})/.exec(decodedOuter)?.[1]; + const rendererNonce = navigation.rendererNonce; const documentPort = new FakePort(); h.dispatch({ data: JSON.stringify({ diff --git a/crates/trusted-server-js/lib/test/first_display/slices.test.ts b/crates/trusted-server-js/lib/test/first_display/slices.test.ts index e17e9434c..f3dd26f9f 100644 --- a/crates/trusted-server-js/lib/test/first_display/slices.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/slices.test.ts @@ -1094,7 +1094,7 @@ describe('first-display initial slice definitions', () => { afterActivate: () => undefined, }) ); - expect(protocol?.rendererUrl).toBe('https://publisher.example/integrations/aps/renderer/v1'); + expect(protocol?.rendererUrl).toBe('https://publisher.example/integrations/aps/renderer/v2'); expect(protocol?.publisherOrigin).toBe('https://publisher.example'); expect(protocol?.sandbox).toBe( 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation' diff --git a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts index a1772cf23..4912702b2 100644 --- a/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/aps/module.test.ts @@ -79,7 +79,7 @@ describe('APS provider', () => { expect(registeredRenderer).toBe(aps.render); expect(registeredValidation).toMatchObject({ expectedPublisherOrigin: window.location.origin, - expectedRendererUrl: new URL('/integrations/aps/renderer/v1', window.location.origin).href, + expectedRendererUrl: new URL('/integrations/aps/renderer/v2', window.location.origin).href, }); activationRelease.reverse().forEach((callback) => callback()); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts index c0e6ff063..9f214a6d7 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/later.test.ts @@ -21,7 +21,7 @@ type NavigationResult = current: boolean; }>; -function harness() { +function harness(pageBidsEnabled = true) { const navigationGeneration = Object.freeze({}); const navigate = vi.fn<(_path: string) => Promise>(async (_path: string) => Object.freeze({ status: 'committed', navigationGeneration, current: true }) @@ -40,7 +40,7 @@ function harness() { }); const prepared = createGptLaterIntegrationRegistration(RELEASE_ID).prepare( Object.freeze({ - config: Object.freeze({ gamAttributionEnabled: false }), + config: Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled }), interfaces, onDispose: (callback: () => void) => preparationDisposers.push(callback), signal: new AbortController().signal, @@ -83,6 +83,27 @@ describe('GPT deferred navigation and reconciliation owner', () => { expect(owner.activateLaterLifecycle).not.toHaveBeenCalled(); }); + it('owns reconciliation without installing SPA hooks when page-bids delivery is disabled', async () => { + vi.useFakeTimers(); + const beforePush = Object.getOwnPropertyDescriptor(window.history, 'pushState'); + const beforeReplace = Object.getOwnPropertyDescriptor(window.history, 'replaceState'); + const owner = harness(false); + + owner.prepared.activate(owner.activationContext); + + expect(owner.activateLaterLifecycle).toHaveBeenCalledOnce(); + expect(Object.getOwnPropertyDescriptor(window.history, 'pushState')).toEqual(beforePush); + expect(Object.getOwnPropertyDescriptor(window.history, 'replaceState')).toEqual(beforeReplace); + window.history.pushState({}, '', '/disabled-page-bids'); + window.dispatchEvent(new PopStateEvent('popstate')); + await vi.runAllTimersAsync(); + expect(owner.navigate).not.toHaveBeenCalled(); + + owner.activationDisposers.reverse().forEach((release) => release()); + expect(owner.release).toHaveBeenCalledOnce(); + owner.preparationDisposers.reverse().forEach((release) => release()); + }); + it('owns one deferred history listener and coalesced navigation timer across repeated routes', async () => { vi.useFakeTimers(); const owner = harness(); 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 bb125c70c..d7aa7f72c 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 @@ -39,7 +39,7 @@ import type { SlotRequestOutcome } from '../../../src/services/slots'; const RELEASE_ID = 'a'.repeat(64); const RESERVATION_ID = `r1_${'a'.repeat(22)}`; -const GPT_CONFIG = Object.freeze({ gamAttributionEnabled: false }); +const GPT_CONFIG = Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }); describe('GPT first-display diagnostics adoption', () => { it('hydrates exact physical-slot cycle state before slot adoption', () => { diff --git a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts index 03ce66bfb..36c7404dc 100644 --- a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts @@ -714,7 +714,7 @@ describe('render_runtime provider', () => { }; const renderer = vi.fn(() => true); const origin = window.location.origin; - const rendererUrl = new URL('/integrations/aps/renderer/v1', origin).href; + const rendererUrl = new URL('/integrations/aps/renderer/v2', origin).href; const validation = Object.freeze({ expectedPublisherOrigin: origin, expectedRendererUrl: rendererUrl, 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 882518012..f46881f54 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -125,7 +125,7 @@ function configProduct(id: string): string | undefined { function defaultConfig(id: string): Readonly> { if (id === 'didomi') return { proxyPath: '/integrations/didomi/sdk.js' }; - if (id === 'gpt') return { gamAttributionEnabled: false }; + if (id === 'gpt') return { gamAttributionEnabled: false, pageBidsEnabled: true }; if (id === 'prebid') { return { accountId: 'test', timeout: 1_000, debug: false, bidders: [] }; } @@ -983,7 +983,7 @@ describe('Runtime bootstrap owner', () => { const tracePresentationCapability = Object.freeze({ attachPresentation: traceAttach }); const gptLaterRelease = vi.fn(); const prebidLaterRelease = vi.fn(); - const gptConfig = { gamAttributionEnabled: true }; + const gptConfig = { gamAttributionEnabled: true, pageBidsEnabled: true }; const prebidConfig = { accountId: 'publisher' }; const gptLater = Object.freeze({ activate: vi.fn(() => gptLaterRelease), 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 8bda7e0d3..78fefad64 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 @@ -1152,7 +1152,7 @@ describe('Universal Creative bridge dispatcher', () => { message: 'TS APS Start', version: 1, lifecycleTicket: LIFECYCLE_TICKET, - rendererUrl: 'https://publisher.example/integrations/aps/renderer/v1', + rendererUrl: 'https://publisher.example/integrations/aps/renderer/v2', envelope: { version: 1, nonce: 'n1_abcdefghijklmnopqrstuv', 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 89b608fa8..6db23c88b 100644 --- a/crates/trusted-server-js/lib/test/services/render.test.ts +++ b/crates/trusted-server-js/lib/test/services/render.test.ts @@ -10,10 +10,10 @@ import type { RenderAttemptScope, WinnerContext } from '../../src/kernel/session import { APS_PERMANENT_SANDBOX, APS_RENDERER_SANDBOX, - APS_RENDERER_V1_PATH, + APS_RENDERER_V2_PATH, renderDirectApsAttempt, renderPucApsAttempt, - resolveApsRendererV1Url, + resolveApsRendererV2Url, } from '../../src/integrations/aps/render'; import { bindCommittedArtifactGuard, @@ -1221,7 +1221,7 @@ describe('direct APS attempt rendering', () => { const source = frame.contentWindow!; const postMessage = vi.spyOn(source, 'postMessage'); expect(frame.getAttribute('src')).toBe( - `${window.location.origin}${APS_RENDERER_V1_PATH}#${bootstrapNonce}` + `${window.location.origin}${APS_RENDERER_V2_PATH}#${bootstrapNonce}` ); expect(frame.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX); expect(document.querySelectorAll('#fictional-slot iframe')).toHaveLength(1); @@ -1244,12 +1244,12 @@ describe('direct APS attempt rendering', () => { expect(navigation).toBeTypeOf('string'); const parsedNavigation = JSON.parse(navigation as string) as Record; expect(parsedNavigation).toEqual({ - message: 'TS APS Bootstrap Navigate', - version: 1, + message: 'TS APS Bootstrap Configure', + version: 2, bootstrapNonce, - containerUrl: expect.stringMatching( - new RegExp(`^data:text/html;charset=utf-8,.*#${bootstrapNonce}$`) - ), + rendererNonce, + creativeOrigin: 'https://creative.example', + tagType: 'iframe', }); expect(navigation).toBe(JSON.stringify(parsedNavigation)); expect(postMessage.mock.calls[0]?.slice(1)).toEqual(['*', []]); @@ -1564,7 +1564,7 @@ describe('direct APS attempt rendering', () => { try { mutated.frame?.setAttribute( 'src', - window.location.origin + APS_RENDERER_V1_PATH + '#b1_9999999999999999999999' + window.location.origin + APS_RENDERER_V2_PATH + '#b1_9999999999999999999999' ); mutated.bootstrapReady(); expect(mutated.render.snapshot().outcome).toEqual({ @@ -1691,19 +1691,19 @@ describe('direct APS attempt rendering', () => { }); 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(resolveApsRendererV2Url('https://publisher.example')).toBe( + 'https://publisher.example/integrations/aps/renderer/v2' ); - expect(resolveApsRendererV1Url('http://localhost:8080')).toBe( - 'http://localhost:8080/integrations/aps/renderer/v1' + expect(resolveApsRendererV2Url('http://localhost:8080')).toBe( + 'http://localhost:8080/integrations/aps/renderer/v2' ); - expect(resolveApsRendererV1Url('http://127.0.0.1:8080')).toBe( - 'http://127.0.0.1:8080/integrations/aps/renderer/v1' + expect(resolveApsRendererV2Url('http://127.0.0.1:8080')).toBe( + 'http://127.0.0.1:8080/integrations/aps/renderer/v2' ); - expect(resolveApsRendererV1Url('http://[::1]:8080')).toBe( - 'http://[::1]:8080/integrations/aps/renderer/v1' + expect(resolveApsRendererV2Url('http://[::1]:8080')).toBe( + 'http://[::1]:8080/integrations/aps/renderer/v2' ); - expect(resolveApsRendererV1Url('http://publisher.example')).toBeUndefined(); + expect(resolveApsRendererV2Url('http://publisher.example')).toBeUndefined(); }); it('keeps a PUC APS overlay hidden until completion and preserves the slot host', () => { diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 2d24f191c..9624e8bec 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/v1 + Client->>Client: Validate renderer descriptor
Create opaque sandbox iframe
Load /integrations/aps/renderer/v2 Note right of Client: Fragment-bound nonce and one-time acknowledgement
No allow-same-origin on the outer frame deactivate Client end diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 3969a70dc..c79263a9d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1891,9 +1891,18 @@ publisher-specific. Startup fails if `{section}` is used without a valid `section_root`. Startup rejects a blank `gam_network_id` only when an absent path/default or a `{network_id}` template consumes it; static paths and templates without `{network_id}` do not consume it. A -`enabled = false` explicitly disables publisher and page-bids template delivery. -An enabled block with no slots has no templates to match, so its `gam_network_id` -is not checked. The `enabled` key is required whenever the table is present. +`enabled = false` explicitly disables publisher and page-bids template delivery: +Trusted Server performs no publisher slot matching or automatic auction, injects no +initial slot/auction projection, and does not install SPA page-bids navigation hooks. +`/_ts/page-bids` returns the canonical empty projection. Direct `POST /auction` +remains live under its ordinary auction and consent gates. A successful inactive +publisher HTML `GET` receives `Cache-Control: max-age=60` unless the origin policy +contains `private` or `no-store` (case-insensitive); origin validators and +surrogate/CDN directives remain intact. Non-HTML, failed, and non-`GET` responses +retain the origin cache policy. An enabled block with no slots has no templates to +match, so its `gam_network_id` is not checked. The `enabled` key is required whenever +the table is present, and disabling delivery does not bypass startup validation of +its slots, assembly mode, or template-cache fields. Both knobs are config-driven, so the URL→section convention stays with the publisher: `section_segment` selects which segment names the section, and diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index 349cda81f..787083119 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -248,7 +248,7 @@ Trusted Server does not insert APS creative markup into the publisher document. Seats, `impid`, markup, notifications, user-sync data, sibling bids, losing seats, and unknown fields are not exposed. The browser decodes this envelope and cross-checks the ID, dimensions, URL, and tag type before any DOM mutation or message suppression. -Both rendering paths use `GET /integrations/aps/renderer/v1`, a versioned static Trusted Server document with its own restrictive CSP. The document initializes the account-keyed APS queue and then loads the live runner through the fixed first-party proxy at `GET /integrations/aps/runner.js`. Trusted Server neither vendors nor pins the upstream runner bytes. +Both rendering paths use `GET /integrations/aps/renderer/v2`, a versioned static Trusted Server materializer with its own restrictive CSP. After the first APS action, the top page sends only independent nonces, the validated creative origin, and tag type. The materializer creates the opaque outer and inner data documents; only the inner document receives and validates the descriptor, initializes the account-keyed APS queue, and loads the live runner through the fixed first-party proxy at `GET /integrations/aps/runner.js`. Trusted Server neither vendors nor pins the upstream runner bytes. Both paths are reserved before configured `[[handlers]]` are evaluated. They are browser-facing and intentionally anonymous; Basic Auth handler patterns do not @@ -272,7 +272,7 @@ allow-scripts allow-top-navigation-by-user-activation ``` -It deliberately omits `allow-same-origin`, so APS and bidder execution remains below an opaque-origin boundary. The renderer response repeats these restrictions with a CSP `sandbox` directive, preventing another embedding path from restoring publisher-origin execution by omitting the iframe attribute. Trusted Server generates a fresh 128-bit nonce, binds it in the iframe URL fragment before navigation, and requires the same one-time nonce in the parent message and renderer acknowledgement. Existing slot content is retained until the static renderer has accepted the descriptor and loaded the fixed runner. +It deliberately omits `allow-same-origin` during bootstrap, so APS and bidder execution remains below an opaque-origin boundary. The renderer response repeats these restrictions with a CSP `sandbox` directive, preventing another embedding path from restoring publisher-origin execution by omitting the iframe attribute. Trusted Server generates independent 128-bit bootstrap and renderer nonces, binds each to its own phase, and accepts each exactly once. No descriptor or data-document URL crosses the bootstrap configuration channel. Existing slot content is retained until the inner renderer has accepted the descriptor and APS reports render completion. ### Direct `/auction` @@ -360,7 +360,7 @@ Use fictional values in source-controlled configuration and fixtures. Supply con ### Winner targets but does not render -- Confirm `GET /integrations/aps/renderer/v1` returns HTML with its CSP and `Referrer-Policy: no-referrer`. +- Confirm `GET /integrations/aps/renderer/v2` returns HTML with its CSP and `Referrer-Policy: no-referrer`. - Confirm publisher CSP permits `frame-src 'self'`. - Confirm the GAM creative uses the supported Prebid Universal Creative bridge and the winning `hb_adid`. - For client-side `trustedServer` adapter auctions, confirm the bid joins the server-minted `r1_` reservation and that the private PUC bridge accepts exactly one claim for it. 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 6a08967bd..fb655f463 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 @@ -430,7 +430,7 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled ## Phase 2 — Harden APS containment and top-page ownership -### Task 5: Replace renderer v1 with the descriptor-free bootstrap contract +### Task 5: Hard-cut the renderer route to the v2 materialization bootstrap **Files:** @@ -439,6 +439,8 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - Modify: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json` - Modify: `scripts/generate-aps-renderer-contract.mjs` - Modify generated: `crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js` +- Delete generated: `crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html` +- Create generated: `crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html` - Modify: `crates/trusted-server-core/src/integrations/aps.rs` - Modify: `crates/trusted-server-core/src/publisher.rs` - Modify: `scripts/integration-tests-aps-runner-proxy.sh` @@ -447,12 +449,13 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - Test: colocated APS Rust tests - Test: `scripts/integration-tests-aps-runner-proxy.sh` -- [ ] **Step 1: Add failing renderer response tests.** The HTTP response is an - immutable descriptor-free bootstrap with exact CSP, content type, cache, - nosniff, no-referrer, no X-Frame-Options, and exact initial sandbox. It accepts - only its `b1_` fragment, exact checked-parent navigation message, and bounded - `data:text/html;charset=utf-8,` container URL. It contains no runner, - descriptor, price, bid id, creative URL, child frame, or publisher DOM access. +- [ ] **Step 1: Add failing renderer response tests.** The v2 HTTP response is an + immutable descriptor-free materialization bootstrap with exact CSP, content + type, cache, nosniff, no-referrer, no X-Frame-Options, and exact initial + sandbox. It accepts only its `b1_` fragment and one canonical v2 policy + configuration from the exact parent. It embeds TS-authored templates, the ES5 + validator, and the fixed runner-proxy path, but no concrete descriptor value, + vendor bytes, publisher DOM authority, or pre-navigation network operation. - [ ] **Step 2: Add failing route/security tests.** APS-enabled final publisher responses append an independent `Content-Security-Policy: frame-ancestors @@ -470,8 +473,9 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 4: Update the single renderer generator and generated contract.** Keep the schema's `x-*` semantic markers documentary; generator code remains the - enforcement authority. Generate deterministic ES5-compatible bootstrap bytes - and reuse the shared corpus across Rust, TypeScript, and Node tests. + enforcement authority. Generate deterministic ES5-compatible v2 bootstrap + bytes, reuse the shared corpus across Rust, TypeScript, and Node tests, delete + the v1 body, and serve v1 as an unknown local `404` with no compatibility path. - [ ] **Step 5: Preserve the live runner proxy exactly.** Keep its fixed target, credential/referrer stripping, duplicate-header evidence, identity encoding, @@ -481,7 +485,7 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 6: Make the Cloudflare/Wrangler readiness proof exact.** Before the adversarial corpus starts, require two consecutive - `PROPFIND /integrations/aps/renderer/v1` responses with the complete local 405 + `PROPFIND /integrations/aps/renderer/v2` responses with the complete local 405 contract, including `Allow: GET` and `Cache-Control: no-store`. Any other result resets the consecutive count. Implement this in the actual adapter harness `crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs`; @@ -507,15 +511,17 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 8: Commit.** ```bash - git add crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json scripts/generate-aps-renderer-contract.mjs scripts/integration-tests-aps-runner-proxy.sh crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/publisher.rs - git commit -m "Make the APS renderer endpoint a bounded bootstrap" + git add crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json scripts/generate-aps-renderer-contract.mjs scripts/integration-tests-aps-runner-proxy.sh crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html crates/trusted-server-core/src/integrations/aps.rs crates/trusted-server-core/src/publisher.rs + git commit -m "Hard-cut APS rendering to the v2 bootstrap" ``` -### Task 6: Generate the exact outer and inner APS data documents +### Task 6: Materialize the exact APS data documents after first action **Files:** -- Create: `crates/trusted-server-js/lib/src/integrations/aps/documents.ts` +- Modify: `crates/trusted-server-js/lib/src/shared/aps_documents.ts` +- Modify: `scripts/generate-aps-renderer-contract.mjs` +- Modify generated: `crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html` - Modify: `crates/trusted-server-js/lib/src/core/contracts/aps_renderer.ts` - Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts` - Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` @@ -525,7 +531,8 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - Test: `crates/trusted-server-js/lib/test/adapters/messaging.test.ts` - Test: `crates/trusted-server-js/lib/test/first_display/slices.test.ts` -- [ ] **Step 1: Add failing template tests.** Generate detached documents using +- [ ] **Step 1: Add failing template tests.** Have the v2 server bootstrap generate + detached documents only after it receives the first-action policy message, using independent `b1_` and `n1_` nonces, exact origin validation, exact permanent sandbox order, sentinel-once substitution, context escaping, and separate 65,536-byte pre-encoding caps. The outer document contains only origins, @@ -551,7 +558,12 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/aps/documents.test.ts test/integrations/aps/render.test.ts test/adapters/messaging.test.ts test/first_display/slices.test.ts ``` -- [ ] **Step 5: Implement pure document generation and exact parsers.** The inner +- [ ] **Step 5: Implement build-time document materialization and exact parsers.** + Keep the templates and ES5 validator as generator/test authorities, emit them + into the server-owned v2 response, and remove them from both first-display and + persistent production TSJS graphs. The top page sends only exact nonces, + creative origin, and tag type; no descriptor or generated URL crosses that + channel. The inner clears its fragment, queues the APS event with one-shot resolve/reject, loads only the same-origin proxy with anonymous CORS/no-referrer, treats script load as progress, and sends completion/failure only on callback settlement. @@ -568,8 +580,8 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 7: Commit.** ```bash - git add crates/trusted-server-js/lib/src/integrations/aps crates/trusted-server-js/lib/src/core/contracts/aps_renderer.ts crates/trusted-server-js/lib/src/adapters/messaging.ts crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts crates/trusted-server-js/lib/test/integrations/aps crates/trusted-server-js/lib/test/adapters/messaging.test.ts crates/trusted-server-js/lib/test/first_display/slices.test.ts - git commit -m "Generate isolated APS data documents" + git add crates/trusted-server-js/lib/src/shared/aps_documents.ts crates/trusted-server-js/lib/src/integrations/aps crates/trusted-server-js/lib/src/core/contracts/aps_renderer.ts crates/trusted-server-js/lib/src/adapters/messaging.ts crates/trusted-server-js/lib/src/first_display crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs scripts/generate-aps-renderer-contract.mjs crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html crates/trusted-server-js/lib/test + git commit -m "Materialize APS documents after first action" ``` ### Task 7: Mount direct APS through the three-phase top-page protocol @@ -595,7 +607,8 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 2: Add failing direct-mount state-machine tests.** Create only the bootstrap iframe initially. Require exact node/parent/src/WindowProxy/ - generation/nonce checks before sandbox mutation/navigation, then exact outer + generation/nonce checks before sandbox mutation and the v2 policy-only + configuration message, then exact materialized outer readiness and one port before sending the envelope. Enforce one-second insertion, one shared three-second document deadline, and the kernel-owned ten-second completion deadline beginning at document acceptance. @@ -613,9 +626,10 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled ``` - [ ] **Step 5: Implement the shared top-page mount service for direct APS.** Keep - descriptor generation before DOM mutation, permanent sandbox after bootstrap - readiness, exact winning size/layout, and one terminal latch. Direct mount is - an ordinary child and does not acquire overlay styling. + descriptor validation before DOM mutation, permanent sandbox after bootstrap + readiness, policy-only server materialization after first action, exact winning + size/layout, and one terminal latch. Direct mount is an ordinary child and does + not acquire overlay styling. - [ ] **Step 6: Run GREEN and adjacent direct-ADM regressions.** @@ -1025,6 +1039,11 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled paired GPT/APS <=1.10 timing/transfer, APS action/completion/paint/heap ceilings, and the common 4 MiB retained-heap ceiling. Require real User Timing marks and no persistent/deferred request, preload, prepare, or execution before paint. + The production source graph must prove that neither first-display nor + persistent artifacts reach `shared/aps_documents.ts` or the document-form ES5 + validator; those authorities exist only in the generator and contract tests. + The mandatory APS + creative + GPT first-display mask must pass the unchanged + 90,000/30,000/26,000 raw/gzip/Brotli ceilings. - [ ] **Step 4: Keep CI logic in scripts.** Workflow YAML may set immutable inputs, install toolchains, and invoke checked-in scripts. Put Git ancestry validation, 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 f1a8094c7..ed160aa07 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 @@ -1555,7 +1555,7 @@ hold capacity until the 15-minute tombstone expiry as a live entry. ### 3.6 Static renderer and APS runner proxy endpoints -`/integrations/aps/renderer/v1` and `/integrations/aps/runner.js` are always-reserved +`/integrations/aps/renderer/v2` and `/integrations/aps/runner.js` are always-reserved Trusted Server routes. When APS is enabled, `GET` returns the local static bootstrap or proxies the APS-hosted creative runner respectively. When APS is disabled, `GET` returns a local `404 no-store`; neither route ever falls through to a publisher @@ -1571,10 +1571,14 @@ anonymous. A configured `[[handlers]]` pattern that matches renderer or runner. Operator-required admission control, rate limiting, and request shielding live at the deployment platform. -The renderer v1 response is an immutable, descriptor-free bootstrap served with a -long-lived immutable cache policy. It is not the document that receives the bid or -loads the runner. Its TS-created iframe initially has exactly this sandbox token set, -serialized in this order: +The renderer v2 response is an immutable, descriptor-free materialization bootstrap +served with a long-lived immutable cache policy. It embeds the TS-authored outer and +inner document templates, the generated ES5 descriptor validator, and the fixed +same-origin runner-proxy path. It contains no attempt descriptor, concrete bid or +creative value, vendor bytes, publisher DOM authority, or pre-navigation network +operation. It is not the document that receives the bid or executes the runner. Its +TS-created iframe initially has exactly this sandbox token set, serialized in this +order: ```text allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation @@ -1588,22 +1592,44 @@ response CSP is: default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none'; ``` -The bootstrap can only validate its exact `b1_` fragment nonce, receive one exact -navigation instruction from its checked parent carrying that same -`bootstrapNonce`, and replace its own location with the supplied -`data:text/html;charset=utf-8,` container URL. It has no descriptor, runner, creative -URL path, price, bid id, network capability, child frame, or publisher DOM access. -The response also has +The bootstrap validates its exact `b1_` fragment nonce, sends readiness only to its +actual `parent`, and accepts one canonical JSON configuration from that exact +`WindowProxy`, with an exact browser-reported HTTP(S) sender origin and no transferred +ports. It has no `document.referrer` dependency, so a publisher's stricter referrer +policy cannot disable the handshake: + +```ts +{ + message: 'TS APS Bootstrap Configure' + version: 2 + bootstrapNonce: string + rendererNonce: string + creativeOrigin: string + tagType: 'iframe' | 'script' +} +``` + +The complete configuration is capped at 16,384 UTF-8 bytes. `creativeOrigin` must +be an exact HTTPS origin distinct from the parent, and the two nonces must have their +exact independent `b1_` and `n1_` forms. The bootstrap then substitutes only those +policy values into the checked templates, enforces the separate 65,536-byte document +caps and 196,663-byte encoded-container cap, and replaces its own location with the +fresh outer `data:text/html;charset=utf-8,` URL. It receives no descriptor and cannot +make a runner or creative request before that navigation. The response also has exactly `Content-Type: text/html; charset=utf-8`, `Cache-Control: public, max-age=31536000, immutable`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. It deliberately omits `X-Frame-Options`; `frame-ancestors 'self'` is mandatory because both direct and PUC paths mount the bootstrap from the publisher top page, never beneath GAM, Universal Creative, or bidder content. The TS-authored container and inner renderer -are checked-in templates encoded into per-attempt `data:` URLs by TSJS; they are not -HTTP artifacts and contain no third-party bytes. Any shipped bootstrap body/header -or data-document protocol change before this release updates the single v1 contract; -after release it requires a new route/protocol version. +remain `data:` documents rather than separately addressable HTTP artifacts and +contain no third-party bytes. The checked-in generator materializes their source into +the server-owned v2 response at build time; neither the first-display nor persistent +TSJS production graph imports the document templates or generated ES5 validator. +The abandoned renderer-v1 route is a local `404`, with no compatibility branch. Any +shipped bootstrap body/header or data-document protocol change before this release +updates the single v2 contract; after release it requires a new route/protocol +version. When APS is enabled, final response privacy appends a separate `Content-Security-Policy: frame-ancestors 'self'` policy after operator headers. CSP @@ -1727,7 +1753,7 @@ new CustomEvent('prebid/creative/render', { ``` `resolve` and `reject` are the Promise's one-shot functions and are the only -non-serializable fields. The top-page owner encodes its exact validated HTTP(S) +non-serializable fields. The server-owned v2 materializer encodes the exact validated HTTP(S) Trusted Server origin and absolute `/integrations/aps/runner.js` URL into the TS-authored inner data document before navigation. The inner CSP permits that exact origin as its only external script source, and the renderer loads only that route. It creates the @@ -1750,7 +1776,7 @@ upstream. Load failure, explicit rejection, and silence fail closed through the existing APS-completion deadline. A changed or compromised APS runner can invoke `resolve` prematurely or incorrectly; this is an accepted external-dependency risk that the outer renderer cannot detect. Real-browser DOM/network conformance reduces -but does not eliminate it. V1 never loads the APS URL directly from the browser, +but does not eliminate it. This design never loads the APS URL directly from the browser, executes a stored fallback, treats script `load` as completion, or uses a reusable global `postMessage` acknowledgement. @@ -2049,7 +2075,7 @@ publisher destruction, DOM-integrity loss, or navigation disposal as applicable. The mount has three document phases: -1. Create one iframe at the immutable renderer-v1 bootstrap URL with the attempt's +1. Create one iframe at the immutable renderer-v2 bootstrap URL with the attempt's `b1_` bootstrap nonce in its fragment and the initial sandbox from §3.6. The bootstrap is publisher-origin by URL but opaque because `allow-same-origin` is absent. It can only send exact @@ -2058,12 +2084,14 @@ The mount has three document phases: 2. After exact element, parent, `src`, `WindowProxy`, generation, and bootstrap-nonce checks, add `allow-same-origin` to the sandbox and post exact - `{message:'TS APS Bootstrap Navigate',version:1,bootstrapNonce,containerUrl}`. - The URL must be the attempt-owned `data:text/html;charset=utf-8,` container with - that same `b1_` fragment. The previously loaded bootstrap remains opaque while it - performs `location.replace`; the destination is naturally opaque because it is + `{message:'TS APS Bootstrap Configure',version:2,bootstrapNonce,rendererNonce,creativeOrigin,tagType}`. + No descriptor or generated document URL crosses this channel. The bootstrap + verifies the exact parent, configuration, nonces, creative origin, and tag type, + materializes the attempt-owned outer and inner data documents, and performs + `location.replace`. The previously loaded bootstrap remains opaque while it + performs that navigation; the destination is naturally opaque because it is `data:`. -3. The outer data container creates one inner data renderer whose `data:` URL +3. The materialized outer data container creates one inner data renderer whose `data:` URL fragment is the independent `n1_` renderer nonce and whose permanent sandbox contains the §3.6 tokens plus `allow-same-origin`. Both final documents remain naturally opaque. After the @@ -2185,7 +2213,7 @@ The inner renderer sends only these exact document-port messages: where `reason` is `descriptor_invalid | runner_no_load | runner_failed`. -Insertion has a one-second deadline. Bootstrap readiness, data navigation, +Insertion has a one-second deadline. Bootstrap readiness, configuration/materialization navigation, container-channel creation, descriptor transfer, and inner document acceptance share one three-second deadline from outer iframe insertion. The kernel is the sole owner of the ten-second APS-completion deadline beginning at inner document acceptance. @@ -2330,12 +2358,13 @@ The shared protocol corpus fixes these bounds and encodings: - before `JSON.parse`, an inbound top-page global-dispatcher string is at most 4,096 UTF-8 bytes; a larger value is unrecognizable and causes no property access or - state lookup. The distinct top-page-to-bootstrap navigation parser accepts only - its exact expected parent and `b1_` nonce and caps the complete JSON instruction at - `MAX_APS_BOOTSTRAP_NAVIGATION_MESSAGE_BYTES = 196_800`; its `containerUrl` is at - most 196,663 UTF-8 bytes (29-byte data prefix + worst-case 3 × 65,536-byte encoded - document + 26-byte `#b1_…` fragment). No other global message receives this larger - allowance; + state lookup. The distinct top-page-to-bootstrap configuration parser accepts only + its exact expected parent, exact `b1_`/`n1_` nonces, exact HTTPS creative origin, + and `iframe | script` tag type, and caps the complete canonical JSON instruction at + 16,384 UTF-8 bytes. The bootstrap-created outer `containerUrl` never crosses that + channel; it is capped internally at 196,663 UTF-8 bytes (29-byte data prefix + + worst-case 3 × 65,536-byte encoded document + 26-byte `#b1_…` fragment). No other + global message receives a larger allowance; - a TS `adId` is exactly the 25-character `r1_` reservation form; a lifecycle ticket, bootstrap nonce, renderer nonce, and attempt id are exactly the respective 25-character `t1_`, `b1_`, `n1_`, and `a1_` forms from §2.2; @@ -2343,7 +2372,7 @@ The shared protocol corpus fixes these bounds and encodings: exact PUC-shape conformance and is never a fetch target or authority; - `publisherOrigin` is at most 2,048 UTF-8 bytes and must serialize an exact HTTP(S) origin with no path/query/fragment. The mount service locally derives the absolute - `/integrations/aps/renderer/v1` URL from that trusted origin, verifies no query or + `/integrations/aps/renderer/v2` URL from that trusted origin, verifies no query or fragment, and appends the exact `b1_` bootstrap-nonce fragment. `rendererUrl` is never serialized in a v4 window or port message. The generated inner `data:` URL independently appends its exact `n1_` renderer-nonce fragment; @@ -4150,7 +4179,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 @@ -4324,7 +4357,10 @@ interface GptDiagnosticsAdManagerIdentity { } type GptDiagnosticsResponseClass = - 'empty' | 'backfill' | 'reservation' | 'unclassified_non_empty' + | 'empty' + | 'backfill' + | 'reservation' + | 'unclassified_non_empty' type GptDiagnosticsRequestPath = | 'trusted_server_direct' @@ -4334,7 +4370,9 @@ type GptDiagnosticsRequestPath = | 'unattributed' type GptDiagnosticsTrustedServerOpportunity = - 'renderable_candidate' | 'unrenderable_candidate' | 'no_candidate' + | 'renderable_candidate' + | 'unrenderable_candidate' + | 'no_candidate' type GptDiagnosticsCreativeFailure = | 'missing_render_source' diff --git a/scripts/generate-aps-renderer-contract.mjs b/scripts/generate-aps-renderer-contract.mjs index ba65830bb..ef68974c9 100644 --- a/scripts/generate-aps-renderer-contract.mjs +++ b/scripts/generate-aps-renderer-contract.mjs @@ -19,7 +19,7 @@ const es5Path = path.join( ); const bootstrapPath = path.join( repositoryRoot, - "crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v1.html", + "crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html", ); const typescriptPath = path.join( repositoryRoot, @@ -29,10 +29,15 @@ const documentValidatorPath = path.join( repositoryRoot, "crates/trusted-server-js/lib/src/core/contracts/generated/renderer_validator_document_v1.ts", ); +const documentsSourcePath = path.join( + repositoryRoot, + "crates/trusted-server-js/lib/src/shared/aps_documents.ts", +); -const [schemaText, corpusText] = await Promise.all([ +const [schemaText, corpusText, documentsSource] = await Promise.all([ readFile(schemaPath, "utf8"), readFile(corpusPath, "utf8"), + readFile(documentsSourcePath, "utf8"), ]); const schema = JSON.parse(schemaText); const corpus = JSON.parse(corpusText); @@ -312,22 +317,56 @@ const documentValidatorOutput = JSON.stringify(es5Output) + ";\n"; -const bootstrapOutput = String.raw` +function extractDocumentTemplate(name) { + const prefix = "const " + name + " = `"; + const start = documentsSource.indexOf(prefix); + invariant(start >= 0, "missing APS document template " + name); + const contentStart = start + prefix.length; + const end = documentsSource.indexOf("`;\n", contentStart); + invariant(end >= 0, "unterminated APS document template " + name); + const value = documentsSource.slice(contentStart, end); + invariant( + !value.includes("`"), + "APS document template contains a nested template literal", + ); + return value; +} + +function inlineScriptString(value) { + return JSON.stringify(value).replaceAll("", "<\\/script>"); +} + +const innerTemplate = extractDocumentTemplate("INNER_TEMPLATE"); +const outerTemplate = extractDocumentTemplate("OUTER_TEMPLATE"); + +const bootstrapOutput = + String.raw` `; From c45ca09ce19df7e5e8691237dcea7fab4c26a7c4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:41:28 -0700 Subject: [PATCH 827/844] Make GPT own parser-time GAM attribution --- .../src/integrations/gpt.rs | 8 - .../src/integrations/registry.rs | 102 ------------- crates/trusted-server-core/src/tsjs.rs | 63 +++++++- crates/trusted-server-js/lib/build-all.mjs | 2 +- .../lib/scripts/check-bundle-budgets.mjs | 1 + .../lib/src/adapters/googletag.ts | 54 +++++++ .../lib/src/core/adapters/gam_attribution.ts | 47 ++++++ .../lib/src/core/bootstrap.ts | 9 +- .../src/first_display/adapters/googletag.ts | 30 ++-- .../lib/src/first_display/agent.ts | 3 +- .../src/first_display/leaf/gpt_protocol.ts | 27 +++- .../src/first_display/slices/definition.ts | 3 +- .../lib/src/first_display/slices/gpt.ts | 9 +- .../lib/src/integrations/gpt/module.ts | 12 +- .../lib/src/shared/first_display_contracts.ts | 9 +- .../lib/test/adapters/googletag.test.ts | 41 ++++++ .../lib/test/build/release-v1.test.mjs | 1 + .../lib/test/composition/browser.test.ts | 2 + .../lib/test/first_display/agent.test.ts | 14 +- .../lib/test/first_display/contracts.test.ts | 12 +- .../test/first_display/gpt_adapter.test.ts | 48 ++++++ .../lib/test/first_display/handoff.test.ts | 7 +- .../lib/test/first_display/slices.test.ts | 139 +++++++++++++----- .../lib/test/first_display/takeover.test.ts | 7 +- .../lib/test/integrations/gpt/module.test.ts | 73 +++++++-- .../lib/test/services/slots.test.ts | 2 + 26 files changed, 519 insertions(+), 206 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 21ccea703..0c93c1a3c 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -492,14 +492,6 @@ impl IntegrationHeadInjector for GptIntegration { fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { Vec::new() } - - fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { - if self.config.gam_attribution_enabled { - vec![("data-ts-gam-attribution", "true")] - } else { - Vec::new() - } - } } // Default value functions diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 23a185031..91a7eba3a 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -577,11 +577,6 @@ pub trait IntegrationHeadInjector: Send + Sync { fn integration_id(&self) -> &'static str; /// Return HTML snippets to insert at the start of ``. fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec; - - /// Return attributes to add to the publisher TSJS bundle tag. - fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { - Vec::new() - } } /// Registration payload returned by integration builders. @@ -1442,30 +1437,6 @@ impl IntegrationRegistry { inserts } - /// Collect static attributes for the publisher TSJS bundle tag. - #[must_use] - pub fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { - let mut attributes: Vec<(&'static str, &'static str)> = Vec::new(); - for injector in &self.inner.head_injectors { - for attribute in injector.tsjs_script_tag_attributes() { - let existing = attributes - .iter() - .find(|(name, _)| *name == attribute.0) - .copied(); - match existing { - None => attributes.push(attribute), - Some((_, kept_value)) if kept_value != attribute.1 => log::warn!( - "Integration `{}` emits conflicting value for publisher tag attribute `{}`; keeping the first", - injector.integration_id(), - attribute.0 - ), - Some(_) => {} - } - } - } - attributes - } - /// Provide a snapshot of registered integrations and their hooks. #[must_use] pub fn registered_integrations(&self) -> Vec { @@ -1896,79 +1867,6 @@ mod tests { use crate::platform::test_support::noop_services; use http::{HeaderValue, StatusCode, header}; - struct DefaultMetadataHeadInjector; - - impl IntegrationHeadInjector for DefaultMetadataHeadInjector { - fn integration_id(&self) -> &'static str { - "default-metadata" - } - - fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - Vec::new() - } - } - - struct StaticMetadataHeadInjector; - - impl IntegrationHeadInjector for StaticMetadataHeadInjector { - fn integration_id(&self) -> &'static str { - "static-metadata" - } - - fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - Vec::new() - } - - fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { - vec![ - ("data-ts-gam-attribution", "true"), - ("data-test-order", "second"), - ] - } - } - - struct ConflictingMetadataHeadInjector; - - impl IntegrationHeadInjector for ConflictingMetadataHeadInjector { - fn integration_id(&self) -> &'static str { - "conflicting-metadata" - } - - fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - Vec::new() - } - - fn tsjs_script_tag_attributes(&self) -> Vec<(&'static str, &'static str)> { - vec![ - ("data-ts-gam-attribution", "false"), - ("data-third-attribute", "third"), - ] - } - } - - #[test] - fn tsjs_script_tag_attributes_preserve_registration_order_and_default_empty() { - let registry = IntegrationRegistry::from_rewriters_with_head_injectors( - Vec::new(), - Vec::new(), - vec![ - Arc::new(DefaultMetadataHeadInjector), - Arc::new(StaticMetadataHeadInjector), - Arc::new(ConflictingMetadataHeadInjector), - ], - ); - - assert_eq!( - registry.tsjs_script_tag_attributes(), - vec![ - ("data-ts-gam-attribution", "true"), - ("data-test-order", "second"), - ("data-third-attribute", "third"), - ], - "should keep the first value for duplicate names and preserve attribute order" - ); - } - // Mock integration proxy for testing struct MockProxy; diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 06bb8592e..23a793a84 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -163,6 +163,15 @@ impl IntegrationConfigsV1 { selected.validate_manifest(module_ids)?; Ok(selected) } + + fn gpt_gam_attribution_enabled(&self) -> bool { + self.entries + .iter() + .find(|entry| entry.id == "gpt") + .and_then(|entry| entry.config.get("gamAttributionEnabled")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + } } impl Default for IntegrationConfigsV1 { @@ -311,6 +320,7 @@ pub fn tsjs_bootstrap_fragment_v1( FirstDisplaySelectionConfigV1 { enabled_integrations: &enabled_integrations, creative: config.creative, + gam_attribution_enabled: config.integration_configs.gpt_gam_attribution_enabled(), }, ); let agent = match first_display.as_ref() { @@ -562,6 +572,8 @@ pub struct FirstDisplaySelectionConfigV1<'a> { pub enabled_integrations: &'a [&'a str], /// Exact server-resolved parser-time creative guard policy. pub creative: CreativeBootConfigV1, + /// Whether GPT owns the document-local parser-time GAM attribution obligation. + pub gam_attribution_enabled: bool, } /// Exact ordered first-display slice mask selected for one immutable projection. @@ -668,7 +680,7 @@ pub fn select_first_display_slices_v1( let creative_guard = enabled.contains("creative") && config.creative.enabled && (config.creative.click_guard || config.creative.render_guard); - let gpt_participates = winner_count > 0; + let gpt_participates = winner_count > 0 || config.gam_attribution_enabled; let prebid_participates = enabled.contains("prebid") && projection.bids.iter().any(|bid| bid.provider == "prebid"); let mut mask = 0_u16; @@ -1286,6 +1298,46 @@ mod tests { ); } + #[test] + fn production_bootstrap_selects_gpt_initial_for_attribution_without_a_winner() { + let configs = IntegrationConfigsV1::new(vec![( + "gpt", + serde_json::json!({ + "gamAttributionEnabled": true, + "pageBidsEnabled": false, + }), + )]) + .expect("GPT browser config should be admitted"); + let projection = serde_json::to_string(&first_display_projection(None)) + .expect("no-bid projection should serialize"); + + let script = tsjs_bootstrap_fragment_v1( + TsjsBootScriptConfigV1 { + module_ids: &["render_runtime", "gpt"], + integration_configs: &configs, + auction_projection_json: &projection, + creative: CreativeBootConfigV1 { + enabled: false, + click_guard: false, + render_guard: false, + }, + render_trace_overlay: false, + gpt_diagnostics_active: false, + }, + "https://publisher.example", + ) + .expect("attribution-only first-display bootstrap should serialize"); + + assert!( + script.contains(r#""slices":["first_display","gpt_initial"]"#), + "typed GAM attribution must select the GPT parser-time owner: {script}" + ); + assert!( + script.contains("m=0041"), + "attribution-only first display must use the admitted GPT mask: {script}" + ); + } + fn hash_query_value(src: &str) -> &str { src.split_once("?v=") .map(|(_, hash)| hash) @@ -1802,6 +1854,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &enabled, creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .expect("closed no-bid batch should select the base agent"); @@ -1813,6 +1866,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &enabled, creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .expect("closed GPT ADM batch should select GPT initial ownership"); @@ -1825,6 +1879,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &enabled, creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .is_none(), @@ -1838,6 +1893,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &enabled, creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .is_none(), @@ -1856,6 +1912,7 @@ mod tests { click_guard: false, render_guard: true, }, + gam_attribution_enabled: false, }, ) .expect("bounded creative/GPT configuration should select the agent"); @@ -1871,6 +1928,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &["aps", "gpt"], creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .expect("bounded APS/GPT configuration should select the agent"); @@ -1887,6 +1945,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &["gpt", "prebid"], creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .expect("bounded Prebid/GPT configuration should select the agent"); @@ -1924,6 +1983,7 @@ mod tests { click_guard: false, render_guard: true, }, + gam_attribution_enabled: false, }, ) .expect("the optimized closed composition should fit the generated budget"); @@ -1938,6 +1998,7 @@ mod tests { FirstDisplaySelectionConfigV1 { enabled_integrations: &unknown, creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, }, ) .is_none(), diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index b9d137f74..1e71c3331 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -43,7 +43,7 @@ const firstDisplayApsPrivateProperties = // These names are private to the GPT initial IIFE and never cross its protocol // receipt or handoff boundary. Keep the cross-artifact protocol keys authored. const firstDisplayGptPrivateProperties = - /^(?:options|binding|command|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restoreTargetingObservers|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting|sealedTargetingOwnership|targetingWrites|ingressClosing|diagnosticRecords|nextDiagnosticCycleOrdinal|requestTimer|completionTimer|requestOperation|requestInvoked|bindingState|operations|requested|settled|encodedBytes|readPhysicalElementId|retireCycle|snapshotDestroyedCycles|invalidateCyclesForElement|invalidateCyclesForPublisherCall|consumeTargetingWrite|invalidateStaleTargetingObservers|captureRetainedTargeting)$/; + /^(?:options|binding|command|cycleMap|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restoreTargetingObservers|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting|sealedTargetingOwnership|targetingWrites|ingressClosing|diagnosticRecords|nextDiagnosticCycleOrdinal|requestTimer|completionTimer|requestOperation|requestInvoked|bindingState|operations|requested|settled|encodedBytes|readPhysicalElementId|retireCycle|snapshotDestroyedCycles|invalidateCyclesForElement|invalidateCyclesForPublisherCall|consumeTargetingWrite|invalidateStaleTargetingObservers|captureRetainedTargeting|timer)$/; fs.rmSync(distributionDirectory, { recursive: true, force: true }); fs.mkdirSync(distributionDirectory, { recursive: true }); diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs index 1f9426aa9..b09e95a11 100644 --- a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -105,6 +105,7 @@ const CURRENT_PROVIDER_SOURCE_OWNERS = Object.freeze({ 'src/integrations/sourcepoint/consent.ts': 'sourcepoint_consent', }); const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ + 'src/core/adapters/gam_attribution.ts': 'bootstrap', 'src/core/bootstrap.ts': 'bootstrap', 'src/core/contracts/boot.ts': 'bootstrap', 'src/core/contracts/integration_configs.ts': 'bootstrap', diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index b4bb235f4..0c351d4be 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -220,6 +220,7 @@ export interface GoogletagOperation { /** Narrow GPT boundary consumed by kernel sessions and services. */ export interface GoogletagAdapter { bindingStatus(): GoogletagBindingStatus; + enqueueGamAttribution(): boolean; adoptDiagnosticsState?(input: GoogletagDiagnosticsAdoptionV1): boolean; diagnosticsIdentity(slot: object): Readonly | undefined; traceToken(slot: object): GptSlotTokenV1 | undefined; @@ -1118,6 +1119,58 @@ export function createBrowserGoogletagAdapter( let pendingReservations = 0; let disposed = false; let firstDisplayObserved = false; + let gamAttributionEnqueued = false; + + const enqueueGamAttribution = (): boolean => { + if (disposed) return false; + if (gamAttributionEnqueued) return true; + let binding = readTarget(target); + if (binding === undefined || binding === null) { + const created = { cmd: [] }; + try { + if ( + !Reflect.defineProperty(target, 'googletag', { + configurable: true, + enumerable: true, + value: created, + writable: true, + }) || + readTarget(target) !== created + ) { + return false; + } + } catch { + return false; + } + binding = created; + } + if ((typeof binding !== 'object' || binding === null) && typeof binding !== 'function') { + return false; + } + const queue = commandQueue(binding as object); + if (!queue) return false; + gamAttributionEnqueued = true; + try { + queueCommand(queue, () => { + try { + const current = readTarget(target); + const root = + (typeof current === 'object' && current !== null) || typeof current === 'function' + ? (current as object) + : (binding as object); + const setConfig = safeMember(root, 'setConfig'); + if (typeof setConfig === 'function') { + Reflect.apply(setConfig, root, [{ targeting: { ts: 'true' } }]); + } + } catch { + // Missing or hostile GPT targeting cannot block later queue work. + } + }); + return true; + } catch { + return false; + } + }; const reportDiagnosticsFailure = (code: GoogletagDiagnosticsFailureCode): void => { try { @@ -2986,6 +3039,7 @@ export function createBrowserGoogletagAdapter( return Object.freeze({ bindingStatus: (): GoogletagBindingStatus => currentBinding().status, + enqueueGamAttribution, adoptDiagnosticsState, diagnosticsIdentity: (slot: object): Readonly | undefined => { const state = diagnosticsSlotState(slot); diff --git a/crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts b/crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts new file mode 100644 index 000000000..5ee6f99a1 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts @@ -0,0 +1,47 @@ +type GoogletagTarget = Window & { googletag?: unknown }; + +function object(value: unknown): Record | undefined { + return (typeof value === 'object' && value !== null) || typeof value === 'function' + ? (value as Record) + : undefined; +} + +/** Enqueue the document-local GAM attribution command at the parser-time boundary. */ +export function enqueueFirstDisplayGamAttribution(target: GoogletagTarget): boolean { + try { + let binding = object(target.googletag); + if (!binding) { + binding = { cmd: [] }; + if ( + !Reflect.defineProperty(target, 'googletag', { + configurable: true, + enumerable: true, + value: binding, + writable: true, + }) || + target.googletag !== binding + ) { + return false; + } + } + const queue = object(Reflect.get(binding, 'cmd')); + const push = queue && Reflect.get(queue, 'push'); + if (!queue || typeof push !== 'function') return false; + Reflect.apply(push, queue, [ + () => { + try { + const root = object(target.googletag) ?? binding; + const setConfig = Reflect.get(root, 'setConfig'); + if (typeof setConfig === 'function') { + Reflect.apply(setConfig, root, [{ targeting: { ts: 'true' } }]); + } + } catch { + // Missing or hostile GPT targeting cannot block later queue work. + } + }, + ]); + return true; + } catch { + return false; + } +} diff --git a/crates/trusted-server-js/lib/src/core/bootstrap.ts b/crates/trusted-server-js/lib/src/core/bootstrap.ts index 9f6459bb8..c8eef9683 100644 --- a/crates/trusted-server-js/lib/src/core/bootstrap.ts +++ b/crates/trusted-server-js/lib/src/core/bootstrap.ts @@ -9,6 +9,7 @@ import type { PreparedKernelTakeover } from '../kernel/integration_registry'; import type { FirstDisplaySliceId } from '../kernel/release_catalog'; import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; +import { enqueueFirstDisplayGamAttribution } from './adapters/gam_attribution'; import { snapshotBootstrapInputV1, type BootstrapInputSnapshotV1 } from './contracts/boot'; import { integrationConfigValueV1 } from './contracts/integration_configs'; import { EMBEDDED_RELEASE_ID } from './release_id'; @@ -506,7 +507,13 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): if (protocols.get(id) === value) protocols.delete(id); }; }; - if (id === 'gpt_initial') return Object.freeze({ observe, register }); + if (id === 'gpt_initial') { + return Object.freeze({ + gam: () => enqueueFirstDisplayGamAttribution(window), + observe, + register, + }); + } if (id === 'aps_initial') { return Object.freeze({ observe, publisherOrigin: location.origin, register }); } diff --git a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts index 95faf1d82..ce61c3e91 100644 --- a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts @@ -270,7 +270,7 @@ function winnerRows(projection: FirstDisplayProjectionV1): readonly Readonly<{ } class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { - private readonly cycles = new Map(); + private readonly cycleMap = new Map(); private readonly createdSlots = new Set(); private readonly diagnosticFacts: Readonly[] = []; private readonly diagnosticListeners = new Map void>(); @@ -354,7 +354,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { this.ingressClosed || this.ingressClosing || !this.started || - [...this.cycles.values()].some((cycle) => !cycle.settled) || + [...this.cycleMap.values()].some((cycle) => !cycle.settled) || !Array.isArray(committedSlotIds) ) { return false; @@ -363,7 +363,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (committed.size !== committedSlotIds.length) return false; const retainedSlots = new Set(); for (const slotId of committed) { - const matches = [...this.cycles.values()].filter((cycle) => cycle.slotId === slotId); + const matches = [...this.cycleMap.values()].filter((cycle) => cycle.slotId === slotId); if (matches.length !== 1 || !matches[0]?.settled) return false; retainedSlots.add(matches[0].physicalSlot); } @@ -422,7 +422,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { public captureHandoff(): readonly FirstDisplayGptHandoffCycleV1[] | undefined { if (this.disposed || !this.ingressClosed) return undefined; return Object.freeze( - [...this.cycles.values()].map((cycle) => { + [...this.cycleMap.values()].map((cycle) => { const targetingOwnership = this.sealedTargetingOwnership.get(cycle.physicalSlot) ?? Object.freeze([]); return Object.freeze({ @@ -444,7 +444,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (this.disposed || !this.ingressClosed) return undefined; return Object.freeze({ cycles: Object.freeze( - [...this.cycles.values()].map((cycle) => + [...this.cycleMap.values()].map((cycle) => Object.freeze({ nextCycleOrdinal: cycle.nextDiagnosticCycleOrdinal, quarantines: Object.freeze([]), @@ -479,7 +479,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (requested.size !== slotIds.length) return false; const selected: object[] = []; for (const slotId of requested) { - const matches = [...this.cycles.values()].filter((cycle) => cycle.slotId === slotId); + const matches = [...this.cycleMap.values()].filter((cycle) => cycle.slotId === slotId); if (matches.length !== 1 || !matches[0]?.settled) return false; selected.push(matches[0].physicalSlot); } @@ -515,7 +515,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } this.createdSlots.clear(); - this.cycles.clear(); + this.cycleMap.clear(); this.sealedTargetingOwnership.clear(); if (this.detachedSlots.size === 0) this.restoreCreatedBinding(); else this.createdBinding = undefined; @@ -653,7 +653,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { traceToken, unknownPriorCycle: ownership === 'publisher', }; - this.cycles.set(slot, cycle); + this.cycleMap.set(slot, cycle); callbacks.onBound( Object.freeze({ bid: cycle.bid, @@ -672,7 +672,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } - for (const cycle of this.cycles.values()) { + for (const cycle of this.cycleMap.values()) { if (this.disposed || cycle.settled) continue; try { for (let index = 0; index < cycle.operations.length; index += 1) { @@ -710,7 +710,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (this.disposed || this.requestedListener !== requestedListener) return; this.notifyNativeMutation(); const slot = physicalSlot(member(event, 'slot')); - const cycle = slot ? this.cycles.get(slot) : undefined; + const cycle = slot ? this.cycleMap.get(slot) : undefined; if (!cycle) { if (slot) { this.invalidateCyclesForElement(this.readPhysicalElementId(slot), slot, callbacks); @@ -735,7 +735,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (this.disposed || this.renderListener !== renderListener) return; this.notifyNativeMutation(); const slot = physicalSlot(member(event, 'slot')); - const cycle = slot ? this.cycles.get(slot) : undefined; + const cycle = slot ? this.cycleMap.get(slot) : undefined; if (!cycle) return; if (cycle.settled) { this.captureDiagnosticFact(SLOT_RENDER_ENDED, cycle, event); @@ -779,7 +779,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (this.disposed || this.diagnosticListeners.get(eventType) !== listener) return; this.notifyNativeMutation(); const slot = physicalSlot(member(event, 'slot')); - const cycle = slot ? this.cycles.get(slot) : undefined; + const cycle = slot ? this.cycleMap.get(slot) : undefined; if (cycle) this.captureDiagnosticFact(eventType, cycle, event); }; this.diagnosticListeners.set(eventType, listener); @@ -997,12 +997,12 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private snapshotDestroyedCycles(arguments_: readonly unknown[]): readonly ActiveCycle[] { try { const targets = arguments_[0]; - if (targets === undefined) return Object.freeze([...this.cycles.values()]); + if (targets === undefined) return Object.freeze([...this.cycleMap.values()]); if (!Array.isArray(targets)) return Object.freeze([]); const cycles = new Set(); for (let index = 0; index < targets.length; index += 1) { const target = physicalSlot(targets[index]); - const cycle = target ? this.cycles.get(target) : undefined; + const cycle = target ? this.cycleMap.get(target) : undefined; if (cycle) cycles.add(cycle); } return Object.freeze([...cycles]); @@ -1017,7 +1017,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { callbacks: FirstDisplayGoogletagBatchCallbacks ): void { if (!elementId) return; - for (const cycle of this.cycles.values()) { + for (const cycle of this.cycleMap.values()) { if (cycle.physicalSlot !== replacement && cycle.elementId === elementId) { this.retireCycle(cycle, callbacks); } diff --git a/crates/trusted-server-js/lib/src/first_display/agent.ts b/crates/trusted-server-js/lib/src/first_display/agent.ts index 363e68f52..9b4717ef7 100644 --- a/crates/trusted-server-js/lib/src/first_display/agent.ts +++ b/crates/trusted-server-js/lib/src/first_display/agent.ts @@ -1050,7 +1050,8 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { protocolId && AUCTION_PROTOCOLS.includes(protocolId) ? captureProtocolRegistration(candidate, protocolId, fullProtocols) : candidate, - own + own, + config.value ); if (protocolId && AUCTION_PROTOCOLS.includes(protocolId)) { if (auctionProtocols.has(protocolId) || !protocolIdentity(installed, protocolId)) { diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts index 50f19c6b1..c96cdafaa 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts @@ -24,7 +24,8 @@ export interface FirstDisplayGptProtocolV1 { } interface GptInitialBindings { - readonly observe: (name: 'protocol_version', value: number) => void; + readonly gam: () => boolean; + readonly observe: (name: 'gam' | 'v', value: boolean | number) => void; readonly register: (protocol: FirstDisplayGptProtocolV1) => () => void; } @@ -75,8 +76,11 @@ function bindings(candidate: unknown): GptInitialBindings | undefined { ) { return undefined; } - const fields = exactRecord(candidate, ['observe', 'register']); - return fields && typeof fields.observe === 'function' && typeof fields.register === 'function' + const fields = exactRecord(candidate, ['gam', 'observe', 'register']); + return fields && + typeof fields.gam === 'function' && + typeof fields.observe === 'function' && + typeof fields.register === 'function' ? (fields as unknown as GptInitialBindings) : undefined; } catch { @@ -135,10 +139,19 @@ function classifyRenderEnded(candidate: unknown): 'gam_empty' | 'nonempty_gam' | export function installGptInitial( candidate: unknown, own: FirstDisplaySliceActivationContext['own'], - createBatch: FirstDisplayGptBatchFactoryV1 + createBatch: FirstDisplayGptBatchFactoryV1, + configCandidate: unknown ): Readonly<{ version: 1; id: 'gpt' }> { const value = bindings(candidate); - if (!value || typeof own !== 'function' || typeof createBatch !== 'function') { + const gamAttributionEnabled = ( + configCandidate as Readonly<{ gamAttributionEnabled?: unknown }> | undefined + )?.gamAttributionEnabled; + if ( + !value || + typeof own !== 'function' || + typeof createBatch !== 'function' || + typeof gamAttributionEnabled !== 'boolean' + ) { throw new TypeError('tsjs'); } const protocol: FirstDisplayGptProtocolV1 = Object.freeze({ @@ -158,6 +171,8 @@ export function installGptInitial( const release = value.register(protocol); if (typeof release !== 'function') throw new TypeError('tsjs'); own(release); - value.observe('protocol_version', 1); + value.observe('gam', gamAttributionEnabled); + value.observe('v', 1); + if (gamAttributionEnabled && !value.gam()) throw new TypeError('tsjs'); return Object.freeze({ version: 1, id: 'gpt' }); } diff --git a/crates/trusted-server-js/lib/src/first_display/slices/definition.ts b/crates/trusted-server-js/lib/src/first_display/slices/definition.ts index 847f396ed..870db6447 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/definition.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/definition.ts @@ -14,7 +14,8 @@ export interface FirstDisplaySliceHost { export type InitialSliceInstaller = ( bindings: unknown, - own: FirstDisplaySliceActivationContext['own'] + own: FirstDisplaySliceActivationContext['own'], + config: unknown ) => unknown; export interface PreparedInitialSlice { diff --git a/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts index 0eb7e71dd..15c319846 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts @@ -3,9 +3,12 @@ import { installGptInitial } from '../leaf/gpt_protocol'; import { defineInitialSlice, registerInitialSlice } from './definition'; -export const GPT_INITIAL_SLICE = defineInitialSlice('gpt_initial', (candidate, own) => - installGptInitial(candidate, own, (input, protocol) => - createFirstDisplayGoogletagBatch({ ...input, protocol }) +export const GPT_INITIAL_SLICE = defineInitialSlice('gpt_initial', (candidate, own, config) => + installGptInitial( + candidate, + own, + (input, protocol) => createFirstDisplayGoogletagBatch({ ...input, protocol }), + config ) ); 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 215008c6b..98bc53bc4 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -1660,6 +1660,7 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg if (!isGptIntegrationConfigV1(context.config)) { throw new TypeError('GPT integration config is invalid'); } + const config = context.config; const auction = exactCapability(context.interfaces, 'auction.v1'); const slotCapability = exactCapability(context.interfaces, 'slots.v1'); const aps = exactCapability(context.interfaces, 'aps.v1'); @@ -1888,14 +1889,19 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg : undefined; if ( selected === undefined || + (config.gamAttributionEnabled && !selected) || (selected && (!initialState || - initialState.values.length !== 1 || - initialState.values[0]?.[0] !== 'protocol_version' || - initialState.values[0][1] !== 1)) + initialState.values.length !== 2 || + initialState.values[0]?.[0] !== 'gam' || + initialState.values[0][1] !== config.gamAttributionEnabled || + initialState.values[1]?.[0] !== 'v' || + initialState.values[1][1] !== 1)) ) { throw new TypeError('GPT first-display parser state is invalid'); } + } else if (config.gamAttributionEnabled && !googletag.enqueueGamAttribution()) { + throw new TypeError('GPT GAM attribution is unavailable'); } const diagnosticsEventRelease: { current?: () => void } = {}; const diagnosticsRelease: { current?: () => void } = {}; diff --git a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts index 4f091b494..c4c1260f4 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts @@ -468,9 +468,16 @@ function validParserValues( typeof value === 'number' && Number.isInteger(value) && value >= 0; switch (sliceId) { case 'aps_initial': - case 'gpt_initial': case 'prebid_initial': return single('protocol_version', (value) => value === 1); + case 'gpt_initial': + return ( + values.length === 2 && + values[0]?.[0] === 'gam' && + typeof values[0][1] === 'boolean' && + values[1]?.[0] === 'v' && + values[1][1] === 1 + ); case 'creative_initial': return single('guard_count', (value) => integer(value) && value >= 1 && value <= 3); case 'datadome_initial': 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 3fa8291a7..9e0cac656 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -61,6 +61,47 @@ function createReadyGoogletag( describe('browser googletag adapter readiness', () => { afterEach(() => vi.useRealTimers()); + it('enqueues GAM attribution once before later publisher commands', () => { + const ready = createReadyGoogletag({ deferCommands: true }); + const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); + const order: string[] = []; + ready.googletag.setConfig.mockImplementation((config) => { + order.push('trusted-server'); + return JSON.stringify(config); + }); + + expect(adapter.enqueueGamAttribution()).toBe(true); + expect(adapter.enqueueGamAttribution()).toBe(true); + ready.googletag.cmd.push(() => order.push('publisher')); + + expect(ready.commands).toHaveLength(2); + ready.commands.splice(0).forEach((command) => command()); + expect(order).toEqual(['trusted-server', 'publisher']); + expect(ready.googletag.setConfig).toHaveBeenCalledExactlyOnceWith({ + targeting: { ts: 'true' }, + }); + }); + + it('creates an absent GPT queue and isolates a missing or throwing targeting API', () => { + const target: { googletag?: unknown } = {}; + const adapter = createBrowserGoogletagAdapter(target); + + expect(adapter.enqueueGamAttribution()).toBe(true); + const created = target.googletag as { cmd: Array<() => void> }; + expect(created.cmd).toHaveLength(1); + expect(() => created.cmd[0]?.()).not.toThrow(); + + const publisher = vi.fn(); + const throwing = createReadyGoogletag(); + throwing.googletag.setConfig.mockImplementation(() => { + throw new Error('targeting unavailable'); + }); + const throwingAdapter = createBrowserGoogletagAdapter({ googletag: throwing.googletag }); + expect(throwingAdapter.enqueueGamAttribution()).toBe(true); + expect(() => throwing.googletag.cmd.push(publisher)).not.toThrow(); + expect(publisher).toHaveBeenCalledOnce(); + }); + it('reports present and gives the command only a frozen narrow facade', async () => { const ready = createReadyGoogletag(); const adapter = createBrowserGoogletagAdapter({ googletag: ready.googletag }); 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 8443d401c..9104e6d41 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 @@ -430,6 +430,7 @@ test('generated first-display components self-register through one authenticated if (id === 'gpt_initial') { return Object.freeze({ bindings: Object.freeze({ + gam: function() { return true; }, observe: function() {}, register: function(protocol) { window.__firstDisplayEvents.push('gpt:' + protocol.id); 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 75428af83..5b0dc3a77 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -353,6 +353,7 @@ function synchronousGptAdapter(initialSlots: readonly object[] = []) { }); const adapter: GoogletagAdapter = Object.freeze({ bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), diagnosticsIdentity: () => undefined, dispose: vi.fn(), notifyReady: vi.fn(), @@ -1456,6 +1457,7 @@ describe('browser composition', () => { } as unknown as GoogletagFacade; const googletag: GoogletagAdapter = Object.freeze({ bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), diagnosticsIdentity: () => undefined, dispose: vi.fn(), notifyReady: vi.fn(), diff --git a/crates/trusted-server-js/lib/test/first_display/agent.test.ts b/crates/trusted-server-js/lib/test/first_display/agent.test.ts index 43cf2619b..65f5705b6 100644 --- a/crates/trusted-server-js/lib/test/first_display/agent.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/agent.test.ts @@ -676,8 +676,11 @@ describe('bounded first-display agent', () => { Object.freeze([ Object.freeze({ sliceId: 'gpt_initial', - observations: Object.freeze(['protocol_version']), - values: Object.freeze([Object.freeze(['protocol_version', 1] as const)]), + observations: Object.freeze(['gam', 'v']), + values: Object.freeze([ + Object.freeze(['gam', false] as const), + Object.freeze(['v', 1] as const), + ]), }), ]), handoff: Object.freeze({ @@ -703,8 +706,11 @@ describe('bounded first-display agent', () => { parserState: [ { sliceId: 'gpt_initial', - observations: ['protocol_version'], - values: [['protocol_version', 1]], + observations: ['gam', 'v'], + values: [ + ['gam', false], + ['v', 1], + ], }, ], attempts: [ diff --git a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts index abfe5edf9..818f3d92a 100644 --- a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts @@ -81,8 +81,11 @@ function handoff(overrides: Record = {}): Record { parserState: [ { sliceId: 'gpt_initial', - observations: ['protocol_version', 'extra'], + observations: ['gam', 'v', 'extra'], values: [ - ['protocol_version', 1], + ['gam', false], + ['v', 1], ['extra', true], ], }, diff --git a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts index 928386276..6ecc63caa 100644 --- a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; import { createFirstDisplayGoogletagBatch } from '../../src/first_display/adapters/googletag'; +import { enqueueFirstDisplayGamAttribution } from '../../src/core/adapters/gam_attribution'; import { snapshotFirstDisplayBatchV1 } from '../../src/first_display/leaf/projection'; const RESERVATION_ID = `r1_${'a'.repeat(22)}`; @@ -156,6 +157,53 @@ function protocol() { } describe('first-display GPT adapter', () => { + it('owns enabled GAM attribution before publisher parser work without a winning bid', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const commands: Array<() => void> = []; + const order: string[] = []; + const setConfig = vi.fn(() => order.push('trusted-server')); + Object.defineProperty(dom.window, 'tsjs', { + configurable: true, + value: { adInit: true }, + }); + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: { cmd: commands, setConfig }, + writable: true, + }); + expect(enqueueFirstDisplayGamAttribution(dom.window as unknown as Window)).toBe(true); + commands.push(() => order.push('publisher')); + commands.splice(0).forEach((command) => command()); + + expect(order).toEqual(['trusted-server', 'publisher']); + expect(setConfig).toHaveBeenCalledExactlyOnceWith({ targeting: { ts: 'true' } }); + }); + + it('creates an absent GPT queue and isolates a missing or throwing targeting API', () => { + const absent: { googletag?: unknown } = {}; + expect(enqueueFirstDisplayGamAttribution(absent as unknown as Window)).toBe(true); + const created = absent.googletag as { cmd: Array<() => void> }; + expect(created.cmd).toHaveLength(1); + expect(() => created.cmd[0]?.()).not.toThrow(); + + const publisher = vi.fn(); + const commands: Array<() => void> = []; + const throwing = { + googletag: { + cmd: commands, + setConfig: () => { + throw new Error('targeting unavailable'); + }, + }, + }; + expect(enqueueFirstDisplayGamAttribution(throwing as unknown as Window)).toBe(true); + commands.push(publisher); + expect(() => commands.splice(0).forEach((command) => command())).not.toThrow(); + expect(publisher).toHaveBeenCalledOnce(); + }); + it('observes publisher GPT calls, events, and targeting until ingress closes', () => { const dom = new JSDOM('
', { url: 'https://publisher.example/', diff --git a/crates/trusted-server-js/lib/test/first_display/handoff.test.ts b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts index 9c6dc7975..c7bcc9b33 100644 --- a/crates/trusted-server-js/lib/test/first_display/handoff.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts @@ -79,8 +79,11 @@ function acceptedHandoff(revision: number): Record { parserState: [ { sliceId: 'gpt_initial', - observations: ['protocol_version'], - values: [['protocol_version', 1]], + observations: ['gam', 'v'], + values: [ + ['gam', false], + ['v', 1], + ], }, ], timing: { bidsScriptMs: 1, firstDisplayMs: 2, terminalMs: 3, paintMs: 4 }, diff --git a/crates/trusted-server-js/lib/test/first_display/slices.test.ts b/crates/trusted-server-js/lib/test/first_display/slices.test.ts index f3dd26f9f..9ee5571a1 100644 --- a/crates/trusted-server-js/lib/test/first_display/slices.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/slices.test.ts @@ -17,6 +17,7 @@ import { OSANO_INITIAL_SLICE } from '../../src/first_display/slices/osano'; import { PERMUTIVE_INITIAL_SLICE } from '../../src/first_display/slices/permutive'; import { SOURCEPOINT_INITIAL_SLICE } from '../../src/first_display/slices/sourcepoint'; import { TESTLIGHT_INITIAL_SLICE } from '../../src/first_display/slices/testlight'; +import type { InitialSliceInstaller } from '../../src/first_display/slices/definition'; import type { FirstDisplayRouteRuleV1 } from '../../src/first_display/leaf/route_guard'; import { installDidomiInitial } from '../../src/first_display/leaf/config_guard'; import type { FirstDisplayCreativeGuardV1 } from '../../src/first_display/leaf/creative_guard'; @@ -146,22 +147,26 @@ describe('first-display initial slice definitions', () => { }), own ), - installGptInitial(Object.freeze({ observe, register }), own, () => - Object.freeze({ - start: () => true, - closeIngress: () => true, - captureHandoff: () => Object.freeze([]), - captureDiagnosticsHandoff: () => - Object.freeze({ - cycles: Object.freeze([]), - facts: Object.freeze([]), - nextTraceTokenOrdinal: 1, - overflowCount: 0, - dropCount: 0, - }), - detachCommittedSlots: () => true, - dispose: () => undefined, - }) + installGptInitial( + Object.freeze({ gam: () => true, observe, register }), + own, + () => + Object.freeze({ + start: () => true, + closeIngress: () => true, + captureHandoff: () => Object.freeze([]), + captureDiagnosticsHandoff: () => + Object.freeze({ + cycles: Object.freeze([]), + facts: Object.freeze([]), + nextTraceTokenOrdinal: 1, + overflowCount: 0, + dropCount: 0, + }), + detachCommittedSlots: () => true, + dispose: () => undefined, + }), + Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }) ), installPrebidInitial(Object.freeze({ observe, register }), own), ]; @@ -182,6 +187,52 @@ describe('first-display initial slice definitions', () => { } }); + it.each([false, true])( + 'projects the typed GAM attribution flag into the first-display GPT owner (%s)', + (gamAttributionEnabled) => { + const observe = vi.fn(); + const gam = vi.fn(() => true); + let protocol: FirstDisplayGptProtocolV1 | undefined; + const createBatch = vi.fn((_input: unknown) => + Object.freeze({ + start: () => true, + closeIngress: () => true, + captureHandoff: () => Object.freeze([]), + captureDiagnosticsHandoff: () => + Object.freeze({ + cycles: Object.freeze([]), + facts: Object.freeze([]), + nextTraceTokenOrdinal: 1, + overflowCount: 0, + dropCount: 0, + }), + detachCommittedSlots: () => true, + dispose: () => undefined, + }) + ); + installGptInitial( + Object.freeze({ + gam, + observe, + register: (candidate: FirstDisplayGptProtocolV1) => { + protocol = candidate; + return () => undefined; + }, + }), + () => undefined, + createBatch, + Object.freeze({ gamAttributionEnabled, pageBidsEnabled: true }) + ); + + protocol?.createBatch({} as never); + + expect(createBatch).toHaveBeenCalledOnce(); + expect(createBatch.mock.calls[0]?.[0]).toEqual({}); + expect(gam).toHaveBeenCalledTimes(gamAttributionEnabled ? 1 : 0); + expect(observe).toHaveBeenCalledWith('gam', gamAttributionEnabled); + } + ); + it('pins the exact twelve optional slices in build order', () => { expect(INITIAL_SLICE_DEFINITIONS.map(({ id }) => id)).toEqual([ 'aps_initial', @@ -366,10 +417,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('didomi_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); const prepared = DIDOMI_INITIAL_SLICE.prepare(host); @@ -478,7 +529,7 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('testlight_initial'); install?.( @@ -487,7 +538,8 @@ describe('first-display initial slice definitions', () => { observe: (_name: string, count: number) => observations.push(count), target, }), - own + own, + undefined ); }, }); @@ -550,7 +602,7 @@ describe('first-display initial slice definitions', () => { activate: ( _id: string, own: (release: () => void) => void, - install?: (candidate: unknown, ownEffect: (release: () => void) => void) => void + install?: InitialSliceInstaller ) => install?.( Object.freeze({ @@ -561,7 +613,8 @@ describe('first-display initial slice definitions', () => { return dispose; }, }), - own + own, + undefined ), }); fixture.definition.prepare(host).activate( @@ -609,8 +662,8 @@ describe('first-display initial slice definitions', () => { activate: ( _id: string, own: (release: () => void) => void, - install?: (candidate: unknown, ownEffect: (release: () => void) => void) => void - ) => install?.(bindings, own), + install?: InitialSliceInstaller + ) => install?.(bindings, own, undefined), }); LOCKR_INITIAL_SLICE.prepare(host).activate( @@ -677,10 +730,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (release: () => void) => void, - install?: (candidate: unknown, ownEffect: (release: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('permutive_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); @@ -753,10 +806,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (release: () => void) => void, - install?: (candidate: unknown, ownEffect: (release: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('sourcepoint_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); @@ -822,10 +875,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (release: () => void) => void, - install?: (candidate: unknown, ownEffect: (release: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('osano_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); @@ -981,10 +1034,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('creative_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); @@ -1041,7 +1094,7 @@ describe('first-display initial slice definitions', () => { activate: ( _id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => install?.( Object.freeze({ @@ -1053,7 +1106,8 @@ describe('first-display initial slice definitions', () => { observe: () => undefined, register, }), - own + own, + undefined ), }); expect(() => @@ -1081,10 +1135,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('aps_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); @@ -1162,6 +1216,7 @@ describe('first-display initial slice definitions', () => { const disposers: Array<() => void> = []; let protocol: FirstDisplayGptProtocolV1 | undefined; const bindings = Object.freeze({ + gam: vi.fn(() => true), observe: vi.fn(), register: (candidate: FirstDisplayGptProtocolV1) => { protocol = candidate; @@ -1172,10 +1227,14 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('gpt_initial'); - install?.(bindings, own); + install?.( + bindings, + own, + Object.freeze({ gamAttributionEnabled: false, pageBidsEnabled: true }) + ); }, }); @@ -1232,10 +1291,10 @@ describe('first-display initial slice definitions', () => { activate: ( id: string, own: (dispose: () => void) => void, - install?: (candidate: unknown, ownEffect: (dispose: () => void) => void) => void + install?: InitialSliceInstaller ) => { expect(id).toBe('prebid_initial'); - install?.(bindings, own); + install?.(bindings, own, undefined); }, }); diff --git a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts index 41c8d4287..ea4c5cbcb 100644 --- a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts @@ -59,8 +59,11 @@ function handoff(revision = 0): Record { parserState: [ { sliceId: 'gpt_initial', - observations: ['protocol_version'], - values: [['protocol_version', 1]], + observations: ['gam', 'v'], + values: [ + ['gam', false], + ['v', 1], + ], }, ], gptDiagnostics: { facts: [], overflowCount: 0, dropCount: 0 }, 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 d7aa7f72c..28390d0d8 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 @@ -809,14 +809,51 @@ describe('transactional GPT integration module', () => { ); it.each([ - { diagnosticsActive: false, adoptInitialDisplay: false, parserStateValid: true }, - { diagnosticsActive: true, adoptInitialDisplay: false, parserStateValid: true }, - { diagnosticsActive: false, adoptInitialDisplay: true, parserStateValid: true }, - { diagnosticsActive: false, adoptInitialDisplay: true, parserStateValid: false }, - { diagnosticsActive: false, adoptInitialDisplay: true, parserStateValid: 'absent' }, + { + diagnosticsActive: false, + adoptInitialDisplay: false, + parserStateValid: true, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: true, + adoptInitialDisplay: false, + parserStateValid: true, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: false, + parserStateValid: true, + gamAttributionEnabled: true, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: true, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: true, + gamAttributionEnabled: true, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: false, + gamAttributionEnabled: false, + }, + { + diagnosticsActive: false, + adoptInitialDisplay: true, + parserStateValid: 'absent', + gamAttributionEnabled: false, + }, ])( - 'uses only catalog capabilities without replaying adopted display (diagnostics=$diagnosticsActive, adoption=$adoptInitialDisplay, parser=$parserStateValid)', - async ({ diagnosticsActive, adoptInitialDisplay, parserStateValid }) => { + 'uses only catalog capabilities without replaying adopted display (diagnostics=$diagnosticsActive, adoption=$adoptInitialDisplay, parser=$parserStateValid, attribution=$gamAttributionEnabled)', + async ({ diagnosticsActive, adoptInitialDisplay, parserStateValid, gamAttributionEnabled }) => { vi.useFakeTimers(); const NativeMutationObserver = window.MutationObserver; const activeMutationObservers = new Set(); @@ -893,6 +930,7 @@ describe('transactional GPT integration module', () => { removedTypes.push(type); }), }; + const setConfig = vi.fn(); (window as Window & { googletag?: unknown }).googletag = { apiReady: true, pubadsReady: true, @@ -902,7 +940,7 @@ describe('transactional GPT integration module', () => { display, getConfig: vi.fn(() => ({ disableInitialLoad: false })), pubads: () => pubads, - setConfig: vi.fn(), + setConfig, }; const providerFacades = new Map>>(); const protect = vi.fn(() => true); @@ -994,7 +1032,8 @@ describe('transactional GPT integration module', () => { runtimeCapability: runtime, getBindings: (id) => Object.freeze({ - config: id === 'gpt' ? GPT_CONFIG : undefined, + config: + id === 'gpt' ? Object.freeze({ ...GPT_CONFIG, gamAttributionEnabled }) : undefined, interfaces: Object.freeze({}), }), onCapabilityStaged: (key, facade) => { @@ -1078,9 +1117,10 @@ describe('transactional GPT integration module', () => { ? Object.freeze([ Object.freeze({ sliceId: 'gpt_initial', - observations: Object.freeze(['protocol_version']), + observations: Object.freeze(['gam', 'v']), values: Object.freeze([ - Object.freeze(['protocol_version', 1] as const), + Object.freeze(['gam', gamAttributionEnabled] as const), + Object.freeze(['v', 1] as const), ]), }), ]) @@ -1173,6 +1213,7 @@ describe('transactional GPT integration module', () => { const result = await registry.install(installCallbacks); if (adoptInitialDisplay && parserStateValid !== true) { expect(result).toMatchObject({ state: 'fallback', reason: 'bundle_partial' }); + expect(setConfig).not.toHaveBeenCalled(); expect(defineSlot).not.toHaveBeenCalled(); expect(display).not.toHaveBeenCalled(); expect(refresh).not.toHaveBeenCalled(); @@ -1180,6 +1221,12 @@ describe('transactional GPT integration module', () => { return; } expect(result.state).toBe('kernel'); + expect(setConfig).toHaveBeenCalledTimes( + gamAttributionEnabled && !adoptInitialDisplay ? 1 : 0 + ); + if (gamAttributionEnabled && !adoptInitialDisplay) { + expect(setConfig).toHaveBeenCalledExactlyOnceWith({ targeting: { ts: 'true' } }); + } const gpt = providerFacades.get('gpt.v1') as { activateLaterLifecycle: () => Readonly<{ navigate: (path: string) => Promise; @@ -1426,6 +1473,10 @@ describe('transactional GPT integration module', () => { sourceFrame.remove(); slotElement.remove(); + expect(setConfig.mock.calls).toEqual( + gamAttributionEnabled ? [[{ targeting: { ts: 'true' } }]] : [] + ); + if (result.state === 'kernel') result.dispose(); expect(activeMutationObservers.size).toBe(0); expect(removedTypes.sort()).toEqual([...listenerTypes].sort()); 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 b7c3c6628..a4f936660 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -160,6 +160,7 @@ function createGptHarness( }); const adapter: GoogletagAdapter = Object.freeze({ bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), diagnosticsIdentity: () => undefined, dispose: vi.fn(), notifyReady: vi.fn(), @@ -4626,6 +4627,7 @@ describe('Task 11 adversarial ownership review', () => { let rejectNext = true; const adapter: GoogletagAdapter = Object.freeze({ bindingStatus: () => 'present', + enqueueGamAttribution: vi.fn(() => true), diagnosticsIdentity: () => undefined, dispose: vi.fn(), notifyReady: vi.fn(), From 5d412de737b24a399cb40d71b3799cd665c1e3e2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:09:18 -0700 Subject: [PATCH 828/844] Prove deferred Prebid refresh ownership --- .../test/integrations/prebid/later.test.ts | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 crates/trusted-server-js/lib/test/integrations/prebid/later.test.ts diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/later.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/later.test.ts new file mode 100644 index 000000000..9f1ef515c --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/later.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createPrebidLaterIntegrationRegistration } from '../../../src/integrations/prebid/later'; +import type { PrebidRefreshPolicy } from '../../../src/integrations/prebid/refresh'; +import { createTestNavigationIdentityIssuer } from '../../../src/kernel/identity'; +import type { + IntegrationActivationContext, + IntegrationPrepareContext, + PreparedIntegration, +} from '../../../src/kernel/integration_registry'; +import { createRuntimeSession } from '../../../src/kernel/sessions'; + +const RELEASE_ID = 'a'.repeat(64); +const CONFIG = Object.freeze({ + accountId: 'fictional-account', + timeout: 1_000, + debug: false, + bidders: Object.freeze(['server']), + clientSideBidders: Object.freeze(['client']), + excludedGamAdUnitPathSuffixes: Object.freeze(['/excluded']), +}); + +function harness(acceptPolicy = true) { + const runtime = createRuntimeSession({ + createIdentityIssuer: () => + createTestNavigationIdentityIssuer({ + getRandomValues: (target) => { + target.fill(4); + return target; + }, + }), + }); + const navigationResult = runtime.startInitialNavigation(); + if (!navigationResult.ok) throw new Error('Expected current navigation'); + const navigation = navigationResult.value; + const physicalSlot = Object.freeze({ id: 'physical-slot' }); + const directAuctionUnit = Object.freeze({ + code: 'slot-one', + 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({ + server: Object.freeze({ placement: 'server-owned' }), + }), + }), + }), + Object.freeze({ bidder: 'client', params: Object.freeze({ placement: 'publisher-client' }) }), + ]), + }); + const clearTargeting = vi.fn(); + const gptOperationDispose = vi.fn(); + const googletagRun = vi.fn((command: (facade: object) => unknown) => + Object.freeze({ + result: Promise.resolve( + command( + Object.freeze({ + adUnitPath: () => '/network/eligible', + clearTargeting, + }) + ) + ), + dispose: gptOperationDispose, + }) + ); + const requestBids = vi.fn((request: Readonly>) => { + const callback = request.bidsBackHandler; + if (typeof callback === 'function') callback(); + }); + const setTargetingForGpt = vi.fn(); + const prebidOperationDispose = vi.fn(); + const prebidRun = vi.fn((command: (facade: object) => unknown) => + Object.freeze({ + result: Promise.resolve( + command( + Object.freeze({ + requestBids, + setTargetingForGpt, + }) + ) + ), + dispose: prebidOperationDispose, + }) + ); + let policy: PrebidRefreshPolicy | undefined; + const policyRelease = vi.fn(); + const installRefreshPolicy = vi.fn((candidate: PrebidRefreshPolicy) => { + policy = candidate; + return acceptPolicy ? policyRelease : undefined; + }); + const directAuctionUnitForSlot = vi.fn((slot: object) => + slot === physicalSlot ? directAuctionUnit : undefined + ); + const activationDisposers: Array<() => void> = []; + const registration = createPrebidLaterIntegrationRegistration(RELEASE_ID); + const prepared = registration.prepare( + Object.freeze({ + config: CONFIG, + interfaces: Object.freeze({ + 'runtime.v1': Object.freeze({}), + 'slots.v1': Object.freeze({}), + 'gpt.v1': Object.freeze({ + adapter: Object.freeze({ run: googletagRun }), + directAuctionUnitForSlot, + installRefreshPolicy, + navigation: () => navigation, + }), + 'prebid.v1': Object.freeze({ + adapter: Object.freeze({ run: prebidRun }), + clientSideBidders: CONFIG.clientSideBidders, + excludedGamAdUnitPathSuffixes: CONFIG.excludedGamAdUnitPathSuffixes, + }), + }), + onDispose: () => undefined, + signal: new AbortController().signal, + } satisfies IntegrationPrepareContext) + ) as PreparedIntegration; + const activation = Object.freeze({ + afterCommit: vi.fn(), + onDispose: (callback: () => void) => activationDisposers.push(callback), + signal: new AbortController().signal, + } satisfies IntegrationActivationContext); + + return { + activation, + activationDisposers, + clearTargeting, + directAuctionUnitForSlot, + googletagRun, + installRefreshPolicy, + physicalSlot, + policy: () => policy, + policyRelease, + prebidRun, + registration, + requestBids, + runtime, + setTargetingForGpt, + prepared, + }; +} + +describe('RCJ-PREBID-04 deferred refresh ownership', () => { + it('has no initial-admission effect and runs one detached refresh after activation', async () => { + const owner = harness(); + + expect(owner.registration).toMatchObject({ id: 'prebid_later', phase: 'deferred' }); + expect(owner.installRefreshPolicy).not.toHaveBeenCalled(); + expect(owner.googletagRun).not.toHaveBeenCalled(); + expect(owner.prebidRun).not.toHaveBeenCalled(); + expect(owner.directAuctionUnitForSlot).not.toHaveBeenCalled(); + + owner.prepared.activate(owner.activation); + expect(owner.installRefreshPolicy).toHaveBeenCalledOnce(); + expect(owner.prebidRun).not.toHaveBeenCalled(); + + const completion = owner + .policy() + ?.prepare(Object.freeze({ slots: Object.freeze([owner.physicalSlot]) })); + expect(completion).toBeDefined(); + await Promise.resolve(completion); + + expect(owner.googletagRun).toHaveBeenCalledOnce(); + expect(owner.directAuctionUnitForSlot).toHaveBeenCalledExactlyOnceWith(owner.physicalSlot); + expect(owner.prebidRun).toHaveBeenCalledOnce(); + expect(owner.requestBids).toHaveBeenCalledOnce(); + expect(owner.setTargetingForGpt).toHaveBeenCalledExactlyOnceWith(['slot-one']); + expect(owner.requestBids.mock.calls[0]?.[0]).toMatchObject({ + adUnits: [ + { + code: 'slot-one', + bids: [ + { + bidder: 'trustedServer', + params: { bidderParams: { server: { placement: 'server-owned' } } }, + }, + { bidder: 'client', params: { placement: 'publisher-client' } }, + ], + }, + ], + }); + + owner.activationDisposers.reverse().forEach((dispose) => dispose()); + expect(owner.policyRelease).toHaveBeenCalledOnce(); + owner.runtime.dispose(); + }); + + it('fails closed when GPT already has a refresh owner without starting another auction', () => { + const owner = harness(false); + + expect(() => owner.prepared.activate(owner.activation)).toThrow( + 'Prebid refresh policy is duplicated' + ); + expect(owner.installRefreshPolicy).toHaveBeenCalledOnce(); + expect(owner.googletagRun).not.toHaveBeenCalled(); + expect(owner.prebidRun).not.toHaveBeenCalled(); + + owner.activationDisposers.reverse().forEach((dispose) => dispose()); + expect(owner.policyRelease).not.toHaveBeenCalled(); + owner.runtime.dispose(); + }); +}); From 4154e5280ff202353c0e0d85e4712bd3c43e4189 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:49:17 -0700 Subject: [PATCH 829/844] Prove APS top-mount containment in browsers --- .../browser/fixtures/fictional-aps-runner.js | 50 +- .../browser/helpers/gpt-stub.ts | 15 + .../tests/shared/aps-puc-lifecycle.spec.ts | 140 +++++ .../browser/tests/shared/aps-renderer.spec.ts | 500 ++++++++++++++++-- docs/guide/integrations/aps.md | 12 +- 5 files changed, 681 insertions(+), 36 deletions(-) diff --git a/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js b/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js index 900aee7eb..a334e4516 100644 --- a/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js +++ b/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js @@ -36,17 +36,65 @@ detail.reject(new Error("fictional_rejection")); return; } + if (bidId.indexOf("deferred-") === 0) { + window.__fictionalApsResolve = function () { + delete window.__fictionalApsResolve; + detail.resolve(); + }; + return; + } + var decoded; + var bid; + try { + decoded = JSON.parse(atob(detail.aaxResponse)); + bid = decoded.seatbid[0].bid[0]; + } catch (_error) { + detail.reject(new Error("fictional_envelope_invalid")); + return; + } if (bidId.indexOf("nested-") === 0) { var frame = document.createElement("iframe"); + frame.setAttribute("data-fictional-creative", "nested"); frame.setAttribute("sandbox", "allow-scripts"); + frame.setAttribute("width", String(bid.w)); + frame.setAttribute("height", String(bid.h)); + frame.style.border = "0"; + frame.style.display = "block"; + frame.style.width = String(bid.w) + "px"; + frame.style.height = String(bid.h) + "px"; + frame.style.margin = "0"; + frame.style.overflow = "hidden"; frame.srcdoc = - "`, + }); + } + if (url.pathname === "/script-allowed.js") { + return route.fulfill({ + status: 200, + contentType: "application/javascript", + body: `document.documentElement.dataset.scriptCreative='executed';var script=document.createElement('script');script.src='https://other.example/other.js';document.head.appendChild(script);`, + }); + } + if (url.pathname === "/script-redirect.js") { + return route.fulfill({ + status: 302, + headers: { location: "https://redirect.example/final.js" }, + }); + } + return route.abort(); + }); await page.route(`${APS_TEST_ORIGIN}/aps-v2-protocol-test`, (route) => route.fulfill({ status: 200, @@ -131,13 +207,24 @@ window.apsV2Records = Object.create(null); window.startApsV2 = function(options) { var slot = document.createElement('div'); slot.id = options.slotId; + slot.style.height = options.renderer.height + 'px'; + slot.style.overflow = 'hidden'; + slot.style.position = 'relative'; + slot.style.width = options.renderer.width + 'px'; slot.innerHTML = 'existing publisher content'; document.getElementById('slots').appendChild(slot); var frame = document.createElement('iframe'); frame.setAttribute('sandbox', ${JSON.stringify(SANDBOX)}); + frame.setAttribute('width', String(options.renderer.width)); + frame.setAttribute('height', String(options.renderer.height)); frame.src = ${JSON.stringify(APS_TEST_RENDERER_URL)} + '#' + options.bootstrapNonce; + frame.style.border = '0'; frame.style.display = 'none'; - var record = {messages: [], frame: frame, port: null}; + frame.style.height = options.renderer.height + 'px'; + frame.style.margin = '0'; + frame.style.overflow = 'hidden'; + frame.style.width = options.renderer.width + 'px'; + var record = {messages: [], snapshots: [], bootstrapConfiguration: null, frame: frame, port: null}; window.apsV2Records[options.slotId] = record; function receive(event) { if (event.source !== frame.contentWindow || event.origin !== 'null' || @@ -148,14 +235,40 @@ window.startApsV2 = function(options) { value.bootstrapNonce === options.bootstrapNonce && event.ports.length === 0) { frame.setAttribute('sandbox', ${JSON.stringify(PERMANENT_SANDBOX)}); var creativeOrigin = new URL(options.renderer.creativeUrl).origin; - frame.contentWindow.postMessage(JSON.stringify({ + if (options.adversarialBootstrap) { + var invalidChannel = new MessageChannel(); + frame.contentWindow.postMessage('{"message":', '*', []); + frame.contentWindow.postMessage('x'.repeat(16385), '*', []); + frame.contentWindow.postMessage(JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce: 'b1_0000000000000000000000', + rendererNonce: options.rendererNonce, + creativeOrigin: creativeOrigin, + tagType: options.renderer.tagType + }), '*', []); + frame.contentWindow.postMessage(JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce: options.bootstrapNonce, + rendererNonce: options.rendererNonce, + creativeOrigin: creativeOrigin, + tagType: options.renderer.tagType + }), '*', [invalidChannel.port1]); + invalidChannel.port2.close(); + } + record.bootstrapConfiguration = { message: 'TS APS Bootstrap Configure', version: 2, bootstrapNonce: options.bootstrapNonce, rendererNonce: options.rendererNonce, creativeOrigin: creativeOrigin, tagType: options.renderer.tagType - }), '*', []); + }; + frame.contentWindow.postMessage(JSON.stringify(record.bootstrapConfiguration), '*', []); + if (options.adversarialBootstrap) { + frame.contentWindow.postMessage(JSON.stringify(record.bootstrapConfiguration), '*', []); + } return; } if (value.message !== 'TS APS Container Ready' || value.version !== 1 || @@ -165,6 +278,11 @@ window.startApsV2 = function(options) { record.port = event.ports[0]; record.port.onmessage = function(portEvent) { record.messages.push(portEvent.data); + record.snapshots.push({ + message: portEvent.data && portEvent.data.message, + display: frame.style.display, + existing: slot.querySelectorAll('.existing').length + }); if (portEvent.data && portEvent.data.message === 'TS APS Render Completed') { var existing = slot.querySelector('.existing'); if (existing) existing.remove(); @@ -187,23 +305,11 @@ window.startApsV2 = function(options) { ); await page.goto(`${APS_TEST_ORIGIN}/aps-v2-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 = {}, + adversarialBootstrap = false, ) => { const bootstrapNonce = `b1_${slotId.padEnd(22, "b").slice(0, 22)}`; const rendererNonce = `n1_${slotId.padEnd(22, "n").slice(0, 22)}`; @@ -219,10 +325,8 @@ window.startApsV2 = function(options) { slotId, bootstrapNonce, rendererNonce, - renderer: { - ...makeDescriptor(bidId), - ...rendererOverrides, - }, + renderer: descriptor(bidId, rendererOverrides), + adversarialBootstrap, }, ); return rendererNonce; @@ -240,6 +344,38 @@ window.startApsV2 = function(options) { ).apsV2Records[id]?.messages ?? [], slotId, ); + const snapshots = (slotId: string) => + page.evaluate( + (id) => + ( + window as unknown as { + apsV2Records: Record< + string, + { + snapshots: Array<{ + message: string; + display: string; + existing: number; + }>; + } + >; + } + ).apsV2Records[id]?.snapshots ?? [], + slotId, + ); + const bootstrapConfiguration = (slotId: string) => + page.evaluate( + (id) => + ( + window as unknown as { + apsV2Records: Record< + string, + { bootstrapConfiguration: Record | null } + >; + } + ).apsV2Records[id]?.bootstrapConfiguration ?? null, + slotId, + ); await start("duplicate-success", "duplicate-success-bid"); await expect @@ -278,7 +414,61 @@ window.startApsV2 = function(options) { await page.waitForTimeout(150); expect(await messages("silent-case")).toHaveLength(2); - await start("nested-case", "nested-case-bid"); + await start("deferred-visibility", "deferred-visibility-bid"); + await expect + .poll(async () => + (await messages("deferred-visibility")).map( + (message) => message.message, + ), + ) + .toEqual(["TS APS Document Accepted", "TS APS Runner Loaded"]); + expect(await snapshots("deferred-visibility")).toEqual([ + { + message: "TS APS Document Accepted", + display: "none", + existing: 1, + }, + { + message: "TS APS Runner Loaded", + display: "none", + existing: 1, + }, + ]); + await expect(page.locator("#deferred-visibility > iframe")).toHaveCSS( + "display", + "none", + ); + await page + .frameLocator("#deferred-visibility > iframe") + .frameLocator('iframe[title="Ad content"]') + .locator("body") + .evaluate(() => + ( + window as unknown as { + __fictionalApsResolve?: () => void; + } + ).__fictionalApsResolve?.(), + ); + await expect + .poll(async () => + (await messages("deferred-visibility")).map( + (message) => message.message, + ), + ) + .toEqual([ + "TS APS Document Accepted", + "TS APS Runner Loaded", + "TS APS Render Completed", + ]); + await expect(page.locator("#deferred-visibility > iframe")).toBeVisible(); + await expect(page.locator("#deferred-visibility .existing")).toHaveCount(0); + + const nestedRendererNonce = await start( + "nested-case", + "nested-case-bid", + {}, + true, + ); await expect .poll(async () => await messages("nested-case")) .toContainEqual( @@ -286,7 +476,257 @@ window.startApsV2 = function(options) { message: "TS APS Render Completed", }), ); + const nestedOuterFrame = page.locator("#nested-case > iframe"); + const nestedOuter = page.frameLocator("#nested-case > iframe"); + const nestedInnerFrame = nestedOuter.locator( + 'body > iframe[title="Ad content"]', + ); + const nestedInner = nestedOuter.frameLocator( + 'body > iframe[title="Ad content"]', + ); + const nestedCreativeFrame = nestedInner.locator( + 'body > iframe[data-fictional-creative="nested"]', + ); + const nestedCreative = nestedInner.frameLocator( + 'body > iframe[data-fictional-creative="nested"]', + ); + await expect(nestedOuterFrame).toHaveCount(1); + await expect(nestedInnerFrame).toHaveCount(1); + await expect(nestedCreativeFrame).toHaveCount(1); + await expect(nestedOuterFrame).toHaveAttribute( + "sandbox", + PERMANENT_SANDBOX, + ); + await expect(nestedInnerFrame).toHaveAttribute( + "sandbox", + PERMANENT_SANDBOX, + ); + await expect(nestedCreativeFrame).toHaveAttribute( + "sandbox", + "allow-scripts", + ); + expect( + await nestedOuter.locator("html").evaluate(() => location.origin), + ).toBe("null"); + expect( + await nestedInner.locator("html").evaluate(() => location.origin), + ).toBe("null"); + expect( + await nestedCreative.locator("html").evaluate(() => location.origin), + ).toBe("null"); + for (const frame of [nestedOuter, nestedInner, nestedCreative]) { + expect( + await frame.locator("html").evaluate(() => { + try { + return window.top?.document === document; + } catch { + return false; + } + }), + ).toBe(false); + } + await expect( + nestedOuter.locator('meta[http-equiv="Content-Security-Policy"]'), + ).toHaveAttribute( + "content", + containerCsp("https://creative.example", true), + ); + await expect( + nestedInner.locator('meta[http-equiv="Content-Security-Policy"]'), + ).toHaveAttribute( + "content", + containerCsp("https://creative.example", false), + ); + const nestedBootstrapNonce = `b1_${"nested-case".padEnd(22, "b").slice(0, 22)}`; + expect(nestedRendererNonce).not.toBe(nestedBootstrapNonce); + await expect(nestedOuterFrame).toHaveAttribute( + "src", + `${APS_TEST_RENDERER_URL}#${nestedBootstrapNonce}`, + ); + expect(await nestedInnerFrame.getAttribute("src")).toContain( + `#${nestedRendererNonce}`, + ); + for (const html of [ + await nestedOuter + .locator("html") + .evaluate((element) => element.outerHTML), + await nestedInner + .locator("html") + .evaluate((element) => element.outerHTML), + ]) { + expect(html).not.toContain("nested-case-bid"); + expect(html).not.toContain("example-account-id"); + expect(html).not.toContain("fictional-nested-case-creative"); + } + expect( + await page.locator("#nested-case").evaluate((slot) => ({ + iframeAncestor: slot.parentElement?.closest("iframe") !== null, + gamAncestor: + slot.parentElement?.closest( + "[data-google-query-id],[data-google-container-id]", + ) !== null, + safeFrameAncestor: + slot.parentElement?.closest('[id*="safeframe" i]') !== null, + })), + ).toEqual({ + iframeAncestor: false, + gamAncestor: false, + safeFrameAncestor: false, + }); + expect(await bootstrapConfiguration("nested-case")).toEqual({ + message: "TS APS Bootstrap Configure", + version: 2, + bootstrapNonce: nestedBootstrapNonce, + rendererNonce: nestedRendererNonce, + creativeOrigin: "https://creative.example", + tagType: "iframe", + }); + + await start("creative-script", "creative-script-bid", { + creativeUrl: "https://creative.example/script-allowed.js", + tagType: "script", + }); + await expect + .poll(async () => await messages("creative-script")) + .toContainEqual( + expect.objectContaining({ message: "TS APS Render Completed" }), + ); + const scriptInner = page + .frameLocator("#creative-script > iframe") + .frameLocator('iframe[title="Ad content"]'); + await expect( + page + .frameLocator("#creative-script > iframe") + .locator('meta[http-equiv="Content-Security-Policy"]'), + ).toHaveAttribute( + "content", + containerCsp("https://creative.example", true, true), + ); + await expect( + scriptInner.locator('meta[http-equiv="Content-Security-Policy"]'), + ).toHaveAttribute( + "content", + containerCsp("https://creative.example", false, true), + ); + expect( + await scriptInner.locator("html").evaluate(() => ({ + allowed: document.documentElement.dataset.scriptCreative, + unrelated: document.documentElement.dataset.otherScript, + })), + ).toEqual({ allowed: "executed", unrelated: undefined }); + + await start("creative-redirect", "creative-redirect-bid", { + creativeUrl: "https://creative.example/script-redirect.js", + tagType: "script", + }); + await expect + .poll(async () => await messages("creative-redirect")) + .toContainEqual( + expect.objectContaining({ + message: "TS APS Render Failed", + reason: "runner_failed", + }), + ); + const redirectInner = page + .frameLocator("#creative-redirect > iframe") + .frameLocator('iframe[title="Ad content"]'); + expect( + await redirectInner + .locator("html") + .evaluate(() => document.documentElement.dataset.redirectedScript), + ).toBeUndefined(); + for (const { slotId, bidId, width, height } of [ + { + slotId: "creative-boundary-min", + bidId: "creative-min", + width: 1, + height: 1, + }, + { + slotId: "creative-boundary-max", + bidId: "creative-max", + width: 4096, + height: 4096, + }, + ]) { + await start(slotId, bidId, { + creativeUrl: "https://creative.example/iframe-policy", + tagType: "iframe", + width, + height, + }); + await expect + .poll(async () => await messages(slotId)) + .toContainEqual( + expect.objectContaining({ message: "TS APS Render Completed" }), + ); + const outerFrame = page.locator(`#${slotId} > iframe`); + const outer = page.frameLocator(`#${slotId} > iframe`); + const innerFrame = outer.locator('iframe[title="Ad content"]'); + const inner = outer.frameLocator('iframe[title="Ad content"]'); + const creativeFrame = inner.locator( + 'iframe[data-fictional-creative="iframe"]', + ); + const creative = inner.frameLocator( + 'iframe[data-fictional-creative="iframe"]', + ); + for (const frame of [outerFrame, creativeFrame]) { + await expect(frame).toHaveAttribute("width", String(width)); + await expect(frame).toHaveAttribute("height", String(height)); + } + for (const frame of [outerFrame, innerFrame, creativeFrame]) { + expect( + await frame.evaluate((element) => { + const value = element as HTMLElement; + const box = value.getBoundingClientRect(); + const computed = getComputedStyle(value); + return { + width: box.width, + height: box.height, + clientWidth: value.clientWidth, + clientHeight: value.clientHeight, + margin: computed.margin, + overflow: computed.overflow, + }; + }), + ).toEqual({ + width, + height, + clientWidth: width, + clientHeight: height, + margin: "0px", + overflow: "hidden", + }); + } + for (const frame of [outer, inner, creative]) { + expect( + await frame.locator("body").evaluate((body) => { + const computed = getComputedStyle(body); + return { + clientWidth: body.clientWidth, + clientHeight: body.clientHeight, + scrollWidth: body.scrollWidth, + scrollHeight: body.scrollHeight, + margin: computed.margin, + overflow: computed.overflow, + }; + }), + ).toEqual({ + clientWidth: width, + clientHeight: height, + scrollWidth: width, + scrollHeight: height, + margin: "0px", + overflow: "hidden", + }); + } + expect( + await creative + .locator("html") + .evaluate(() => document.documentElement.dataset.externalScript), + ).toBeUndefined(); + } const requestsBeforeInvalid = runnerRequests; await start("invalid-case", "invalid-case-bid", { unexpected: true, diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index 787083119..d0c27b82e 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -288,6 +288,11 @@ After the native runner loads, Trusted Server replaces the existing children of For client-side `trustedServer` adapter auctions, Prebid generates its own `hb_adid`. Trusted Server binds that generated ID to the validated APS descriptor in a bounded, expiring browser registry before GAM refresh. The bridge verifies that the requesting Universal Creative iframe belongs to the same ad unit, consumes the capability once, and passes the APS bid ID separately to the Amazon runner. +The protected browser conformance environment uses Prebid Universal Creative +`1.17.2`. Configure that release in GAM outside this repository. Trusted Server +does not vendor, proxy, pin, archive, or serve PUC bytes; the version is test and +operator metadata for the externally hosted creative only. + These paths do not fetch PBS Cache, fire generic APS win/billing beacons, or call `apstag.setDisplayBids()` for the Trusted Server winner. Publisher-owned native APS objects are otherwise left untouched. ## Publisher CSP @@ -325,11 +330,8 @@ This release is a direct configuration and protocol cutover: 6. Prepare GAM line items and Universal Creative for `hb_bidder=aps` and the selected APS `hb_adid`. 7. Disable publisher-native APS demand for the Trusted Server test cohort. -There is no legacy runtime switch. Roll back by disabling `[auction]` or -removing the APS provider, restoring native APS for the cohort, or deploying -the prior binary. - -Changing `rendering_mode` does not update pages that are already loaded or stored in an HTML cache. A cached `trusted_server` page can continue requesting `/integrations/aps/renderer` after a native-mode deployment removes that route. A cached `publisher_native` page continues using its captured native mode after rollback. Coordinate the mode change with HTML cache expiry or purge and reload active test sessions before judging the result. +There is no legacy runtime switch. Roll back by disabling `[integrations.aps]`, restoring native APS for the cohort, or deploying the prior binary. +Disabling `[integrations.aps]` is also the containment stop for renderer or live-runner incidents: it removes APS auction and browser materialization ownership without adding a cached or vendored fallback. ## Rollout From 2febc8ce4fd2579ddfa1b72801bd3531314f80e4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:13:20 -0700 Subject: [PATCH 830/844] Fix APS renderer materialization boundaries --- .../src/integrations/aps.rs | 4 +- .../generated/aps_renderer_bootstrap_v2.html | 2 +- .../browser/tests/shared/aps-renderer.spec.ts | 2 +- .../tests/aps_runner_proxy.rs | 2 +- crates/trusted-server-js/lib/build-all.mjs | 4 +- .../lib/src/first_display/render_bridge.ts | 25 ++-- .../lib/src/shared/aps_documents.ts | 9 +- .../lib/test/build/release-v1.test.mjs | 22 ++++ .../test/contract/aps-renderer-es5.test.mjs | 110 +++++++++++++++++- ...s-render-fix-and-tsjs-resilience-design.md | 11 +- 10 files changed, 173 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4262fb40f..05372b457 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -87,7 +87,7 @@ pub const APS_RUNNER_BLOCKING_READ_TIMEOUT: Duration = Duration::from_millis(250 pub fn is_aps_family_path(path: &str) -> bool { path == "/integrations/aps" || path.starts_with("/integrations/aps/") } -const APS_RENDERER_V2_CSP: &str = "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';"; +const APS_RENDERER_V2_CSP: &str = "default-src 'none'; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' http: https:; connect-src http: https:; frame-src data: https:; img-src data: blob: http: https:; media-src blob: http: https:; style-src 'unsafe-inline' http: https:; font-src data: http: https:; worker-src blob: http: https:; frame-ancestors 'self'; form-action https:;"; // This document is served with an immutable v2 URL. Any semantic change must // ship at a new versioned route so cached v2 bytes retain their contract. @@ -4134,7 +4134,7 @@ mod tests { assert!(!response.headers().contains_key("x-frame-options")); assert_eq!( APS_RENDERER_V2_CSP, - "default-src 'none'; sandbox allow-scripts; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; frame-ancestors 'self'; form-action 'none';" + "default-src 'none'; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' http: https:; connect-src http: https:; frame-src data: https:; img-src data: blob: http: https:; media-src blob: http: https:; style-src 'unsafe-inline' http: https:; font-src data: http: https:; worker-src blob: http: https:; frame-ancestors 'self'; form-action https:;" ); assert_eq!( APS_RENDERER_SANDBOX, diff --git a/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html index 6cf1e995a..3b9e3fc9a 100644 --- a/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html +++ b/crates/trusted-server-core/src/integrations/generated/aps_renderer_bootstrap_v2.html @@ -6,7 +6,7 @@ var MAX_CONFIGURATION_BYTES=16384; var MAX_CONTAINER_URL_BYTES=196663; var DATA_PREFIX='data:text/html;charset=utf-8,'; -var INNER_TEMPLATE="\n\n\n\n\n`; const comparisonSetup = ``; - if (resources.artifactModel === "legacy-main-v1") { + if (resources.artifactModel === "legacy-rc-v1") { const release = manualDisplay ? "" : "window.__fixtureGpt.release();"; - return `${comparisonSetup}${selectedTag}
`; + return `${comparisonSetup}${selectedTag}
`; } if (!resources.controllerDocument) throw new Error("generated controller document is unavailable"); @@ -508,8 +627,11 @@ function fixtureDocument( return released; } -async function installFixtureGpt(context: BrowserContext): Promise { - await context.addInitScript(() => { +async function installFixtureGpt( + context: BrowserContext, + performanceCase: PerformanceCase, +): Promise { + await context.addInitScript((selectedCase) => { type Listener = (event: Record) => void; type Slot = { addService(service: object): Slot; @@ -567,6 +689,86 @@ async function installFixtureGpt(context: BrowserContext): Promise { for (const listener of listeners.get(name) ?? []) listener({ slot, ...facts }); }; + const startFictionalPuc = (slot: Slot): void => { + const adId = slot.getTargeting("hb_adid")[0]; + const root = document.getElementById(slot.getSlotElementId()); + if (!adId || !root) + throw new Error("fictional APS PUC input is unavailable"); + const frame = document.createElement("iframe"); + frame.dataset.fictionalPuc = ""; + frame.srcdoc = `\n", + ); + validateRealGamArtifact(roots.realGam, expectedRealGam); + const manifestPath = path.join(traceRoot, "evidence-manifest.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + fs.writeFileSync( + manifestPath, + `${JSON.stringify({ ...manifest, conclusion: "failure" })}\n`, + ); + assert.throws( + () => validateRealGamArtifact(roots.realGam, expectedRealGam), + /successful conclusion/u, + ); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); fs.writeFileSync( path.join(traceRoot, "secret.txt"), environment.TS_REAL_GAM_AUTH_HEADER, @@ -274,8 +382,38 @@ function selfTest() { const command = process.argv[2]; if (command === "self-test") selfTest(); -else if (!execute(command)) { +else if (command === "validate-real-gam") { + const [ + downloadRoot, + evidenceId, + releaseId, + commitSha, + runId, + previousArtifactId, + ] = process.argv.slice(3); + if ( + !downloadRoot || + !evidenceId || + !releaseId || + !/^[0-9a-f]{40}$/u.test(commitSha ?? "") || + !/^[1-9][0-9]*$/u.test(runId ?? "") || + !previousArtifactId || + process.argv.length !== 9 + ) { + throw new Error( + "usage: aps-tsjs-evidence.mjs validate-real-gam ", + ); + } + validateRealGamArtifact(downloadRoot, { + evidenceId, + releaseId, + commitSha, + runId, + previousArtifactId, + }); + process.stdout.write("APS real-GAM evidence is valid\n"); +} else if (!execute(command)) { throw new Error( - "usage: aps-tsjs-evidence.mjs {write-quality|write-integration|write-real-gam|scrub-integration|scrub-real-gam|self-test}", + "usage: aps-tsjs-evidence.mjs {write-quality|write-integration|write-real-gam|scrub-integration|scrub-real-gam|validate-real-gam|self-test}", ); } diff --git a/scripts/ci/aps-tsjs-quality.sh b/scripts/ci/aps-tsjs-quality.sh index e64168259..813a4c6a9 100755 --- a/scripts/ci/aps-tsjs-quality.sh +++ b/scripts/ci/aps-tsjs-quality.sh @@ -42,6 +42,13 @@ run_quality_gates() { cp "$repository_root/crates/trusted-server-js/dist/tsjs-build-metrics-v1.json" "$evidence_root/" } +run_contract_tests() { + cd "$repository_root" + node --test scripts/ci/dispatch-aps-tsjs-gate.test.mjs + node scripts/ci/aps-tsjs-evidence.mjs self-test + node scripts/validate-tsjs-performance-evidence.mjs --self-test +} + case "${1:-}" in install) install_dependencies @@ -49,8 +56,11 @@ case "${1:-}" in run) run_quality_gates ;; + contracts) + run_contract_tests + ;; *) - printf 'usage: %s {install|run}\n' "$0" >&2 + printf 'usage: %s {install|run|contracts}\n' "$0" >&2 exit 64 ;; esac diff --git a/scripts/ci/dispatch-aps-tsjs-gate.mjs b/scripts/ci/dispatch-aps-tsjs-gate.mjs new file mode 100644 index 000000000..b29e2303e --- /dev/null +++ b/scripts/ci/dispatch-aps-tsjs-gate.mjs @@ -0,0 +1,402 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync } from "node:fs"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const SHA_PATTERN = /^[0-9a-f]{40}$/u; +const RELEASE_PATTERN = /^[0-9a-f]{64}$/u; +const TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u; +const RUN_LIST_FIELDS = "databaseId,displayTitle,headSha,createdAt"; +const WORKFLOWS = Object.freeze({ + performance: ".github/workflows/tsjs-performance-gate.yml", + "real-gam": ".github/workflows/aps-real-gam.yml", +}); + +function fail(message) { + throw new Error(`APS/TSJS dispatch refused: ${message}`); +} + +function exactSha(value, label) { + if (!SHA_PATTERN.test(value ?? "")) + fail(`${label} must be 40 lowercase hex characters`); + return value; +} + +function exactToken(value, label) { + if (!TOKEN_PATTERN.test(value ?? "")) fail(`${label} is invalid`); + return value; +} + +function exactRef(value) { + if ( + !REF_PATTERN.test(value ?? "") || + value.includes("..") || + value.includes("//") || + value.endsWith("/") || + value.endsWith(".lock") + ) { + fail("ref is invalid"); + } + return value; +} + +function defaultRunner(command, args, options = {}) { + return execFileSync(command, args, { + cwd: process.cwd(), + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + ...options, + }); +} + +function defaultSleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +export function createEvidenceId({ kind, mode, headSha, now, randomSuffix }) { + if (kind !== "performance" && kind !== "real-gam") + fail("gate kind is invalid"); + exactSha(headSha, "head SHA"); + if (!(now instanceof Date) || !Number.isFinite(now.valueOf())) + fail("dispatch time is invalid"); + if (!/^[0-9a-f]{8}$/u.test(randomSuffix ?? "")) + fail("random suffix is invalid"); + const timestamp = now + .toISOString() + .replace(/[-:]/gu, "") + .replace(/\.\d{3}Z$/u, "Z"); + const phase = kind === "performance" ? exactToken(mode, "mode") : "cutover"; + const evidenceId = `aps-tsjs-${kind}-${phase}-${headSha.slice(0, 12)}-${timestamp}-${randomSuffix}`; + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/u.test(evidenceId)) { + fail("generated evidence id is invalid"); + } + return evidenceId; +} + +function workflowRun(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return undefined; + if ( + !Number.isSafeInteger(value.databaseId) || + value.databaseId < 1 || + typeof value.displayTitle !== "string" || + typeof value.headSha !== "string" || + typeof value.createdAt !== "string" + ) { + return undefined; + } + return value; +} + +export function selectUniqueWorkflowRun(runs, expected) { + if (!Array.isArray(runs)) fail("GitHub run list must be an array"); + const matches = runs + .map(workflowRun) + .filter( + (run) => + run !== undefined && + run.headSha === expected.headSha && + run.displayTitle === expected.displayTitle, + ); + if (matches.length > 1) + fail("multiple workflow runs match the exact dispatch"); + const match = matches[0]; + if (!match) return undefined; + const createdAt = new Date(match.createdAt); + if ( + !Number.isFinite(createdAt.valueOf()) || + createdAt.valueOf() < expected.dispatchStartedAt.valueOf() + ) { + fail("stale workflow run predates the dispatch"); + } + return match; +} + +async function runCommand(runner, command, args, options) { + const output = await runner(command, args, options); + if (typeof output !== "string") fail(`${command} returned non-text output`); + return output; +} + +async function resolveLocalAndRemoteHead(ref, runner) { + const status = await runCommand(runner, "git", [ + "status", + "--porcelain=v1", + "--untracked-files=all", + ]); + if (status !== "") fail("worktree must be clean before dispatch"); + const headSha = exactSha( + (await runCommand(runner, "git", ["rev-parse", "HEAD"])).trim(), + "local HEAD", + ); + const remote = ( + await runCommand(runner, "git", [ + "ls-remote", + "--heads", + "origin", + `refs/heads/${ref}`, + ]) + ).trim(); + const rows = remote === "" ? [] : remote.split("\n"); + if (rows.length !== 1) fail("remote branch must resolve exactly once"); + const [remoteSha, remoteRef, ...extra] = rows[0].split(/\s+/u); + if ( + extra.length !== 0 || + remoteRef !== `refs/heads/${ref}` || + remoteSha !== headSha + ) { + fail("remote branch must resolve to local HEAD"); + } + return headSha; +} + +async function waitForWorkflowRun({ + workflow, + ref, + evidenceId, + headSha, + displayTitle, + dispatchStartedAt, + runner, + sleep, +}) { + for (let attempt = 0; attempt < 30; attempt += 1) { + const raw = await runCommand(runner, "gh", [ + "run", + "list", + "--workflow", + workflow, + "--branch", + ref, + "--event", + "workflow_dispatch", + "--limit", + "100", + "--json", + RUN_LIST_FIELDS, + ]); + let runs; + try { + runs = JSON.parse(raw); + } catch { + fail("GitHub run list returned invalid JSON"); + } + const match = selectUniqueWorkflowRun(runs, { + evidenceId, + headSha, + displayTitle, + dispatchStartedAt, + }); + if (match) return match; + if (attempt < 29) await sleep(2_000); + } + fail("zero workflow runs matched the exact dispatch"); +} + +export async function dispatchGate(input, dependencies = {}) { + const runner = dependencies.runner ?? defaultRunner; + const now = dependencies.now ?? (() => new Date()); + const randomSuffix = + dependencies.randomSuffix ?? (() => randomBytes(4).toString("hex")); + const sleep = dependencies.sleep ?? defaultSleep; + const pathExists = dependencies.pathExists ?? existsSync; + const kind = input.kind; + const workflow = WORKFLOWS[kind]; + if (!workflow) fail("gate kind is invalid"); + const ref = exactRef(input.ref); + const outputDir = input.outputDir; + if (typeof outputDir !== "string" || outputDir.length === 0) + fail("output directory is required"); + if (pathExists(outputDir)) fail("output directory must not already exist"); + + let mode; + let baseSha; + let previousArtifactId; + if (kind === "performance") { + mode = input.mode; + if (mode !== "preswitch" && mode !== "postswitch") + fail("performance mode is invalid"); + baseSha = exactSha(input.baseSha, "base SHA"); + } else { + previousArtifactId = exactToken( + input.previousArtifactId, + "previous artifact id", + ); + } + + const headSha = await resolveLocalAndRemoteHead(ref, runner); + let releaseId; + if (kind === "real-gam") { + releaseId = ( + await runCommand(runner, "npm", [ + "--prefix", + "crates/trusted-server-js/lib", + "run", + "--silent", + "print:release-id", + ]) + ).trim(); + if (!RELEASE_PATTERN.test(releaseId)) + fail("generated release id is invalid"); + } + + const observedDispatchTime = now(); + if ( + !(observedDispatchTime instanceof Date) || + !Number.isFinite(observedDispatchTime.valueOf()) + ) { + fail("dispatch time is invalid"); + } + const dispatchStartedAt = new Date( + Math.floor(observedDispatchTime.valueOf() / 1_000) * 1_000, + ); + const evidenceId = createEvidenceId({ + kind, + mode, + headSha, + now: dispatchStartedAt, + randomSuffix: randomSuffix(), + }); + const displayTitle = + kind === "performance" + ? `TSJS Performance Gate / ${evidenceId} / ${mode}` + : `APS real-GAM / ${evidenceId} / ${releaseId}`; + const fields = + kind === "performance" + ? [ + "-f", + `evidence_id=${evidenceId}`, + "-f", + `mode=${mode}`, + "-f", + `base_sha=${baseSha}`, + ] + : [ + "-f", + `evidence_id=${evidenceId}`, + "-f", + `release_id=${releaseId}`, + "-f", + `previous_artifact_id=${previousArtifactId}`, + ]; + await runCommand(runner, "gh", [ + "workflow", + "run", + workflow, + "--ref", + ref, + ...fields, + ]); + const run = await waitForWorkflowRun({ + workflow, + ref, + evidenceId, + headSha, + displayTitle, + dispatchStartedAt, + runner, + sleep, + }); + const runId = run.databaseId; + await runCommand(runner, "gh", [ + "run", + "watch", + String(runId), + "--exit-status", + ]); + const artifactName = + kind === "performance" + ? `tsjs-performance-${evidenceId}` + : `aps-real-gam-${runId}`; + await runCommand(runner, "gh", [ + "run", + "download", + String(runId), + "--name", + artifactName, + "--dir", + outputDir, + ]); + + if (kind === "performance") { + await runCommand(runner, "node", [ + "scripts/validate-tsjs-performance-evidence.mjs", + "--file", + `${outputDir}/tsjs-performance-${mode}.json`, + "--evidence-id", + evidenceId, + "--head-sha", + headSha, + "--base-sha", + baseSha, + "--mode", + mode, + ]); + } else { + await runCommand(runner, "node", [ + "scripts/ci/aps-tsjs-evidence.mjs", + "validate-real-gam", + outputDir, + evidenceId, + releaseId, + headSha, + String(runId), + previousArtifactId, + ]); + } + return { evidenceId, headSha, runId }; +} + +function parseCli(argv) { + const kind = argv[0]; + if (kind !== "performance" && kind !== "real-gam") + fail("first argument must be performance or real-gam"); + const values = new Map(); + for (let index = 1; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || value === undefined || values.has(name)) + fail("invalid CLI arguments"); + values.set(name, value); + } + const allowed = + kind === "performance" + ? new Set(["--ref", "--base-sha", "--mode", "--output-dir"]) + : new Set(["--ref", "--previous-artifact-id", "--output-dir"]); + if ( + values.size !== allowed.size || + [...values.keys()].some((name) => !allowed.has(name)) + ) { + fail("missing or unknown CLI arguments"); + } + return kind === "performance" + ? { + kind, + ref: values.get("--ref"), + baseSha: values.get("--base-sha"), + mode: values.get("--mode"), + outputDir: values.get("--output-dir"), + } + : { + kind, + ref: values.get("--ref"), + previousArtifactId: values.get("--previous-artifact-id"), + outputDir: values.get("--output-dir"), + }; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + const result = await dispatchGate(parseCli(process.argv.slice(2))); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + } +} diff --git a/scripts/ci/dispatch-aps-tsjs-gate.test.mjs b/scripts/ci/dispatch-aps-tsjs-gate.test.mjs new file mode 100644 index 000000000..1e1d57da0 --- /dev/null +++ b/scripts/ci/dispatch-aps-tsjs-gate.test.mjs @@ -0,0 +1,340 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createEvidenceId, + dispatchGate, + selectUniqueWorkflowRun, +} from "./dispatch-aps-tsjs-gate.mjs"; + +const HEAD = "a".repeat(40); +const BASE = "b".repeat(40); +const START = new Date("2026-08-20T12:00:00.000Z"); + +function fakeRunner(responses) { + const calls = []; + return { + calls, + async run(command, args, options = {}) { + calls.push({ command, args, options }); + const key = [command, ...args].join(" "); + const response = responses.get(key); + if (response instanceof Error) throw response; + if (response === undefined) return ""; + return typeof response === "function" ? response(calls) : response; + }, + }; +} + +test("evidence ids bind gate, mode, head, UTC time, and random suffix", () => { + const first = createEvidenceId({ + kind: "performance", + mode: "postswitch", + headSha: HEAD, + now: START, + randomSuffix: "0123abcd", + }); + const second = createEvidenceId({ + kind: "performance", + mode: "postswitch", + headSha: HEAD, + now: START, + randomSuffix: "fedcba98", + }); + assert.equal( + first, + "aps-tsjs-performance-postswitch-aaaaaaaaaaaa-20260820T120000Z-0123abcd", + ); + assert.notEqual(first, second); + assert.match(first, /^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$/u); +}); + +test("run selection rejects stale, ambiguous, and wrong-head evidence", () => { + const expected = { + evidenceId: + "aps-tsjs-performance-postswitch-aaaaaaaaaaaa-20260820T120000Z-0123abcd", + headSha: HEAD, + displayTitle: + "TSJS Performance Gate / aps-tsjs-performance-postswitch-aaaaaaaaaaaa-20260820T120000Z-0123abcd / postswitch", + dispatchStartedAt: START, + }; + assert.equal( + selectUniqueWorkflowRun( + [ + { + databaseId: 1, + displayTitle: "unrelated", + headSha: HEAD, + createdAt: START.toISOString(), + }, + ], + expected, + ), + undefined, + ); + assert.equal( + selectUniqueWorkflowRun( + [ + { + databaseId: 2, + displayTitle: expected.displayTitle, + headSha: BASE, + createdAt: START.toISOString(), + }, + ], + expected, + ), + undefined, + ); + assert.equal( + selectUniqueWorkflowRun( + [ + { + databaseId: 22, + displayTitle: `${expected.displayTitle}-not-exact`, + headSha: HEAD, + createdAt: START.toISOString(), + }, + ], + expected, + ), + undefined, + ); + assert.throws( + () => + selectUniqueWorkflowRun( + [ + { + databaseId: 3, + displayTitle: expected.displayTitle, + headSha: HEAD, + createdAt: "2026-08-20T11:59:59.999Z", + }, + ], + expected, + ), + /stale/u, + ); + assert.throws( + () => + selectUniqueWorkflowRun( + [4, 5].map((databaseId) => ({ + databaseId, + displayTitle: expected.displayTitle, + headSha: HEAD, + createdAt: START.toISOString(), + })), + expected, + ), + /multiple/u, + ); +}); + +test("performance dispatch binds the remote head, exact base, artifact, and validator", async () => { + const ref = "feature/aps-tsjs-resilience-rc202608"; + const evidenceId = + "aps-tsjs-performance-postswitch-aaaaaaaaaaaa-20260820T120000Z-0123abcd"; + const run = { + databaseId: 42, + displayTitle: `TSJS Performance Gate / ${evidenceId} / postswitch`, + headSha: HEAD, + createdAt: START.toISOString(), + }; + const runner = fakeRunner( + new Map([ + ["git status --porcelain=v1 --untracked-files=all", ""], + ["git rev-parse HEAD", `${HEAD}\n`], + [ + `git ls-remote --heads origin refs/heads/${ref}`, + `${HEAD}\trefs/heads/${ref}\n`, + ], + [ + "gh run list --workflow .github/workflows/tsjs-performance-gate.yml --branch feature/aps-tsjs-resilience-rc202608 --event workflow_dispatch --limit 100 --json databaseId,displayTitle,headSha,createdAt", + JSON.stringify([run]), + ], + ]), + ); + + const result = await dispatchGate( + { + kind: "performance", + ref, + baseSha: BASE, + mode: "postswitch", + outputDir: "target/performance-evidence", + }, + { + runner: runner.run, + now: () => new Date("2026-08-20T12:00:00.999Z"), + randomSuffix: () => "0123abcd", + sleep: async () => {}, + pathExists: () => false, + }, + ); + + assert.deepEqual(result, { evidenceId, headSha: HEAD, runId: 42 }); + const commands = runner.calls.map(({ command, args }) => [command, ...args]); + assert.ok( + commands.some( + (command) => + command.join(" ") === + `gh workflow run .github/workflows/tsjs-performance-gate.yml --ref ${ref} -f evidence_id=${evidenceId} -f mode=postswitch -f base_sha=${BASE}`, + ), + ); + assert.ok( + commands.some( + (command) => + command.join(" ") === + `gh run download 42 --name tsjs-performance-${evidenceId} --dir target/performance-evidence`, + ), + ); + assert.ok( + commands.some( + (command) => + command.join(" ") === + `node scripts/validate-tsjs-performance-evidence.mjs --file target/performance-evidence/tsjs-performance-postswitch.json --evidence-id ${evidenceId} --head-sha ${HEAD} --base-sha ${BASE} --mode postswitch`, + ), + ); +}); + +test("remote-head mismatch fails before any workflow dispatch", async () => { + const ref = "feature/aps-tsjs-resilience-rc202608"; + const runner = fakeRunner( + new Map([ + ["git status --porcelain=v1 --untracked-files=all", ""], + ["git rev-parse HEAD", `${HEAD}\n`], + [ + `git ls-remote --heads origin refs/heads/${ref}`, + `${BASE}\trefs/heads/${ref}\n`, + ], + ]), + ); + await assert.rejects( + dispatchGate( + { + kind: "performance", + ref, + baseSha: BASE, + mode: "postswitch", + outputDir: "target/performance-evidence", + }, + { runner: runner.run, pathExists: () => false }, + ), + /remote branch must resolve to local HEAD/u, + ); + assert.equal( + runner.calls.some(({ command }) => command === "gh"), + false, + ); +}); + +test("zero matching workflow runs are rejected after bounded polling", async () => { + const ref = "feature/aps-tsjs-resilience-rc202608"; + const runner = fakeRunner( + new Map([ + ["git status --porcelain=v1 --untracked-files=all", ""], + ["git rev-parse HEAD", `${HEAD}\n`], + [ + `git ls-remote --heads origin refs/heads/${ref}`, + `${HEAD}\trefs/heads/${ref}\n`, + ], + [ + "gh run list --workflow .github/workflows/tsjs-performance-gate.yml --branch feature/aps-tsjs-resilience-rc202608 --event workflow_dispatch --limit 100 --json databaseId,displayTitle,headSha,createdAt", + "[]", + ], + ]), + ); + await assert.rejects( + dispatchGate( + { + kind: "performance", + ref, + baseSha: BASE, + mode: "postswitch", + outputDir: "target/performance-evidence", + }, + { + runner: runner.run, + now: () => START, + randomSuffix: () => "0123abcd", + sleep: async () => {}, + pathExists: () => false, + }, + ), + /zero workflow runs/u, + ); + assert.equal( + runner.calls.filter( + ({ command, args }) => + command === "gh" && args[0] === "run" && args[1] === "list", + ).length, + 30, + ); +}); + +test("real-GAM dispatch derives release id and validates the exact run artifact", async () => { + const ref = "feature/aps-tsjs-resilience-rc202608"; + const releaseId = "c".repeat(64); + const previousArtifactId = "rollback-artifact-123"; + const evidenceId = + "aps-tsjs-real-gam-cutover-aaaaaaaaaaaa-20260820T120000Z-0123abcd"; + const runner = fakeRunner( + new Map([ + ["git status --porcelain=v1 --untracked-files=all", ""], + ["git rev-parse HEAD", `${HEAD}\n`], + [ + `git ls-remote --heads origin refs/heads/${ref}`, + `${HEAD}\trefs/heads/${ref}\n`, + ], + [ + "npm --prefix crates/trusted-server-js/lib run --silent print:release-id", + `${releaseId}\n`, + ], + [ + "gh run list --workflow .github/workflows/aps-real-gam.yml --branch feature/aps-tsjs-resilience-rc202608 --event workflow_dispatch --limit 100 --json databaseId,displayTitle,headSha,createdAt", + JSON.stringify([ + { + databaseId: 77, + displayTitle: `APS real-GAM / ${evidenceId} / ${releaseId}`, + headSha: HEAD, + createdAt: START.toISOString(), + }, + ]), + ], + ]), + ); + + await dispatchGate( + { + kind: "real-gam", + ref, + previousArtifactId, + outputDir: "target/real-gam-evidence", + }, + { + runner: runner.run, + now: () => START, + randomSuffix: () => "0123abcd", + sleep: async () => {}, + pathExists: () => false, + }, + ); + const commands = runner.calls.map(({ command, args }) => + [command, ...args].join(" "), + ); + assert.ok( + commands.includes( + `gh workflow run .github/workflows/aps-real-gam.yml --ref ${ref} -f evidence_id=${evidenceId} -f release_id=${releaseId} -f previous_artifact_id=${previousArtifactId}`, + ), + ); + assert.ok( + commands.includes( + "gh run download 77 --name aps-real-gam-77 --dir target/real-gam-evidence", + ), + ); + assert.ok( + commands.includes( + `node scripts/ci/aps-tsjs-evidence.mjs validate-real-gam target/real-gam-evidence ${evidenceId} ${releaseId} ${HEAD} 77 ${previousArtifactId}`, + ), + ); +}); diff --git a/scripts/ci/tsjs-performance.sh b/scripts/ci/tsjs-performance.sh index 1fd7f9915..4475c4cbb 100755 --- a/scripts/ci/tsjs-performance.sh +++ b/scripts/ci/tsjs-performance.sh @@ -14,6 +14,9 @@ validate_inputs() { *) return 1 ;; esac [[ "${TSJS_EVIDENCE_ID:-}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{7,127}$ ]] + [[ "${TSJS_PERF_HEAD_SHA:-}" =~ ^[0-9a-f]{40}$ ]] + [[ "${TSJS_PERF_BASE_SHA:-}" =~ ^[0-9a-f]{40}$ ]] + test "$(git -C "$repository_root" rev-parse HEAD)" = "$TSJS_PERF_HEAD_SHA" } verify_toolchains() { @@ -34,20 +37,22 @@ build_candidate() { node "$repository_root/scripts/validate-tsjs-performance-evidence.mjs" --self-test } -build_main() { +build_baseline() { test -n "${RUNNER_TEMP:-}" test -n "${GITHUB_ENV:-}" + base_sha="${TSJS_PERF_BASE_SHA:-}" + [[ "$base_sha" =~ ^[0-9a-f]{40}$ ]] cd "$repository_root" - git fetch origin main - main_sha="$(git rev-parse origin/main)" - main_root="$RUNNER_TEMP/tsjs-performance-main" - [[ "$main_sha" =~ ^[0-9a-f]{40}$ ]] - test "$(git cat-file -t "$main_sha")" = commit - git worktree add --detach "$main_root" "$main_sha" - npm --prefix "$main_root/crates/trusted-server-js/lib" ci - npm --prefix "$main_root/crates/trusted-server-js/lib" run build - printf 'TSJS_PERF_MAIN_SHA=%s\n' "$main_sha" >> "$GITHUB_ENV" - printf 'TSJS_PERF_MAIN_ROOT=%s\n' "$main_root" >> "$GITHUB_ENV" + git fetch origin refs/heads/rc/202608:refs/remotes/origin/rc/202608 + test "$(git cat-file -t "$base_sha")" = commit + git merge-base --is-ancestor "$base_sha" origin/rc/202608 + baseline_root="$RUNNER_TEMP/tsjs-performance-baseline" + test ! -e "$baseline_root" + git worktree add --detach "$baseline_root" "$base_sha" + npm --prefix "$baseline_root/crates/trusted-server-js/lib" ci + npm --prefix "$baseline_root/crates/trusted-server-js/lib" run build + printf 'TSJS_PERF_BASE_SHA=%s\n' "$base_sha" >> "$GITHUB_ENV" + printf 'TSJS_PERF_BASE_ROOT=%s\n' "$baseline_root" >> "$GITHUB_ENV" } install_browser() { @@ -71,13 +76,13 @@ validate_evidence() { test -n "${TSJS_PERF_OUTPUT:-}" test -n "${TSJS_EVIDENCE_ID:-}" test -n "${TSJS_PERF_HEAD_SHA:-}" - test -n "${TSJS_PERF_MAIN_SHA:-}" + test -n "${TSJS_PERF_BASE_SHA:-}" test -n "${TSJS_PERF_MODE:-}" node "$repository_root/scripts/validate-tsjs-performance-evidence.mjs" \ --file "$TSJS_PERF_OUTPUT" \ --evidence-id "$TSJS_EVIDENCE_ID" \ --head-sha "$TSJS_PERF_HEAD_SHA" \ - --main-sha "$TSJS_PERF_MAIN_SHA" \ + --base-sha "$TSJS_PERF_BASE_SHA" \ --mode "$TSJS_PERF_MODE" } @@ -91,8 +96,8 @@ case "${1:-}" in build-candidate) build_candidate ;; - build-main) - build_main + build-baseline) + build_baseline ;; install-browser) install_browser @@ -104,7 +109,7 @@ case "${1:-}" in validate_evidence ;; *) - printf 'usage: %s {validate-inputs|verify-toolchains|build-candidate|build-main|install-browser|run-sample|validate-evidence}\n' "$0" >&2 + printf 'usage: %s {validate-inputs|verify-toolchains|build-candidate|build-baseline|install-browser|run-sample|validate-evidence}\n' "$0" >&2 exit 64 ;; esac diff --git a/scripts/validate-tsjs-performance-evidence.mjs b/scripts/validate-tsjs-performance-evidence.mjs index b97bc6950..12e8df4f8 100644 --- a/scripts/validate-tsjs-performance-evidence.mjs +++ b/scripts/validate-tsjs-performance-evidence.mjs @@ -5,19 +5,19 @@ import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const EXPECTED = Object.freeze({ - schemaVersion: 5, + schemaVersion: 6, chromium: "145.0.7632.6", machineClass: "github-hosted:ubuntu-24.04", runnerImage: "ubuntu-24.04", - fixture: "tsjs-main-paired-network-v2", - controller: "generated-server-v1+production-main-v1", + fixture: "tsjs-baseline-paired-network-v3", + controller: "generated-server-v1+production-rc-v1", node: "v24.12.0", npm: "11.6.2", typescript: "6.0.3", warmupsPerVariant: 5, samplesPerVariant: 50, percentile: 90, - interleaving: "alternating-main-candidate", + interleaving: "alternating-baseline-candidate", networkProfile: Object.freeze({ mechanism: "cdp-Network.emulateNetworkConditions", appliedBeforeNavigation: true, @@ -34,6 +34,14 @@ const EXPECTED = Object.freeze({ "afterSpaNavigation", ]), heapHardCeilingBytes: 4 * 1024 * 1024, + aps: Object.freeze({ + actionCeilingMs: 900, + actionToCompletionCeilingMs: 1_500, + completionToPaintCeilingMs: 250, + totalToPaintCeilingMs: 2_500, + afterProtectedPaintHeapCeilingBytes: 3 * 1024 * 1024, + afterTakeoverQueueDrainHeapCeilingBytes: 3_932_160, + }), workflowName: "TSJS Performance Gate", workflowFile: ".github/workflows/tsjs-performance-gate.yml", }); @@ -87,7 +95,13 @@ function nearestRank(values, percentile) { return ordered[Math.ceil((percentile / 100) * ordered.length) - 1]; } -function validateReferenceTransfer(value, expectedSha, expectedModel, path) { +function validateReferenceTransfer( + value, + expectedSha, + expectedModel, + path, + firstDisplayMask = "0045", +) { exactKeys( value, ["sha", "artifactModel", "sources", "rawBytes", "gzipBytes", "brotliBytes"], @@ -97,7 +111,7 @@ function validateReferenceTransfer(value, expectedSha, expectedModel, path) { exactString(value.artifactModel, expectedModel, `${path}.artifactModel`); if (!Array.isArray(value.sources)) fail(`${path}.sources must be an array`); const expectedInlineEndpoints = - expectedModel === "legacy-main-v1" + expectedModel === "legacy-rc-v1" ? ["inline:legacy-boot", "inline:legacy-ad-init"] : ["inline:boot-controller"]; if (value.sources.length !== expectedInlineEndpoints.length + 1) { @@ -138,7 +152,7 @@ function validateReferenceTransfer(value, expectedSha, expectedModel, path) { } else if (source.delivery === "external") { const expectedExternalEndpoint = expectedModel === "release-v1" - ? `external:/static/tsjs=tsjs-first-display.min.js?m=0045&v=${source.sha256}` + ? `external:/static/tsjs=tsjs-first-display.min.js?m=${firstDisplayMask}&v=${source.sha256}` : `external:/static/tsjs=tsjs-unified.min.js?v=${source.sha256}`; if (source.semanticEndpoint !== expectedExternalEndpoint) { fail(`${sourcePath} has a mismatched external endpoint`); @@ -195,8 +209,8 @@ export function validateEvidence(evidence, expected) { } if (!/^[0-9a-f]{40}$/.test(expected.headSha ?? "")) fail("expected head SHA is invalid"); - if (!/^[0-9a-f]{40}$/.test(expected.mainSha ?? "")) - fail("expected main SHA is invalid"); + if (!/^[0-9a-f]{40}$/.test(expected.baseSha ?? "")) + fail("expected base SHA is invalid"); exactKeys( evidence, @@ -213,6 +227,7 @@ export function validateEvidence(evidence, expected) { "transfer", "heap", "requests", + "aps", "assertions", "provenance", "result", @@ -334,7 +349,7 @@ export function validateEvidence(evidence, expected) { const timing = evidence.performance.requestToFirstActionMs; exactKeys( timing, - ["main", "candidate", "percentile", "maximumRatio", "observedRatio"], + ["baseline", "candidate", "percentile", "maximumRatio", "observedRatio"], "performance timing", ); if (timing.percentile !== EXPECTED.percentile) @@ -342,20 +357,20 @@ export function validateEvidence(evidence, expected) { if (timing.maximumRatio !== EXPECTED.maximumRatio) fail("performance ratio limit drifted"); exactKeys( - timing.main, + timing.baseline, ["sha", "artifactModel", "selectedTransferBytes", "samples", "p90"], - "main performance timing", + "baseline performance timing", ); - exactString(timing.main.sha, expected.mainSha, "performance main SHA"); + exactString(timing.baseline.sha, expected.baseSha, "performance base SHA"); if ( - timing.main.artifactModel !== "legacy-main-v1" && - timing.main.artifactModel !== "release-v1" + timing.baseline.artifactModel !== "legacy-rc-v1" && + timing.baseline.artifactModel !== "release-v1" ) { - fail("performance main artifact model is invalid"); + fail("performance baseline artifact model is invalid"); } finiteNumber( - timing.main.selectedTransferBytes, - "main selected transfer bytes", + timing.baseline.selectedTransferBytes, + "baseline selected transfer bytes", { integer: true, minimum: 1 }, ); exactKeys( @@ -389,22 +404,22 @@ export function validateEvidence(evidence, expected) { } return variantP90; }; - const mainP90 = validateVariant(timing.main, "main"); + const baselineP90 = validateVariant(timing.baseline, "baseline"); const candidateP90 = validateVariant(timing.candidate, "candidate"); - if (mainP90 <= 0) fail("main performance p90 must be positive"); + if (baselineP90 <= 0) fail("baseline performance p90 must be positive"); const observedRatio = finiteNumber( timing.observedRatio, "performance observed ratio", ); - if (Math.abs(observedRatio - candidateP90 / mainP90) > Number.EPSILON) { + if (Math.abs(observedRatio - candidateP90 / baselineP90) > Number.EPSILON) { fail("performance observed ratio is inconsistent with the p90 values"); } - if (candidateP90 > mainP90 * EXPECTED.maximumRatio) + if (candidateP90 > baselineP90 * EXPECTED.maximumRatio) fail("candidate performance p90 exceeds the paired 10% limit"); exactKeys( evidence.transfer, - ["algorithm", "mainReferenceTransfer", "candidateReferenceTransfer"], + ["algorithm", "baselineReferenceTransfer", "candidateReferenceTransfer"], "transfer", ); exactString( @@ -412,11 +427,11 @@ export function validateEvidence(evidence, expected) { "semantic-tsjs-transfer-v1", "transfer.algorithm", ); - const mainTransfer = validateReferenceTransfer( - evidence.transfer.mainReferenceTransfer, - expected.mainSha, - timing.main.artifactModel, - "transfer.mainReferenceTransfer", + const baselineTransfer = validateReferenceTransfer( + evidence.transfer.baselineReferenceTransfer, + expected.baseSha, + timing.baseline.artifactModel, + "transfer.baselineReferenceTransfer", ); const candidateTransfer = validateReferenceTransfer( evidence.transfer.candidateReferenceTransfer, @@ -424,8 +439,10 @@ export function validateEvidence(evidence, expected) { timing.candidate.artifactModel, "transfer.candidateReferenceTransfer", ); - if (timing.main.selectedTransferBytes !== mainTransfer.externalRawBytes) { - fail("main selected transfer bytes disagree with semantic transfer"); + if ( + timing.baseline.selectedTransferBytes !== baselineTransfer.externalRawBytes + ) { + fail("baseline selected transfer bytes disagree with semantic transfer"); } if ( timing.candidate.selectedTransferBytes !== @@ -436,15 +453,15 @@ export function validateEvidence(evidence, expected) { for (const metric of ["rawBytes", "gzipBytes", "brotliBytes"]) { if ( evidence.transfer.candidateReferenceTransfer[metric] > - evidence.transfer.mainReferenceTransfer[metric] + evidence.transfer.baselineReferenceTransfer[metric] ) { - fail(`candidate reference ${metric} exceeds current main`); + fail(`candidate reference ${metric} exceeds the rc baseline`); } } exactKeys( evidence.heap, - ["collection", "hardCeilingBytes", "main", "candidate"], + ["collection", "hardCeilingBytes", "baseline", "candidate"], "heap", ); exactString( @@ -454,13 +471,13 @@ export function validateEvidence(evidence, expected) { ); if (evidence.heap.hardCeilingBytes !== EXPECTED.heapHardCeilingBytes) fail("heap hard ceiling drifted"); - exactKeys(evidence.heap.main, ["sha", "checkpoints"], "heap.main"); - exactString(evidence.heap.main.sha, expected.mainSha, "heap main SHA"); + exactKeys(evidence.heap.baseline, ["sha", "checkpoints"], "heap.baseline"); + exactString(evidence.heap.baseline.sha, expected.baseSha, "heap base SHA"); exactKeys(evidence.heap.candidate, ["checkpoints"], "heap.candidate"); exactKeys( - evidence.heap.main.checkpoints, + evidence.heap.baseline.checkpoints, EXPECTED.heapCheckpoints, - "heap.main.checkpoints", + "heap.baseline.checkpoints", ); exactKeys( evidence.heap.candidate.checkpoints, @@ -468,9 +485,9 @@ export function validateEvidence(evidence, expected) { "heap.candidate.checkpoints", ); for (const name of EXPECTED.heapCheckpoints) { - const mainUsedSize = finiteNumber( - evidence.heap.main.checkpoints[name], - `heap.main.checkpoints.${name}`, + const baselineUsedSize = finiteNumber( + evidence.heap.baseline.checkpoints[name], + `heap.baseline.checkpoints.${name}`, { integer: true, minimum: 1 }, ); const candidateUsedSize = finiteNumber( @@ -479,7 +496,7 @@ export function validateEvidence(evidence, expected) { { integer: true, minimum: 1 }, ); if ( - mainUsedSize > EXPECTED.heapHardCeilingBytes || + baselineUsedSize > EXPECTED.heapHardCeilingBytes || candidateUsedSize > EXPECTED.heapHardCeilingBytes ) { fail(`${name} retained heap exceeds the hard ceiling`); @@ -528,6 +545,265 @@ export function validateEvidence(evidence, expected) { "deferred headOfLineBlocking", ); + exactKeys( + evidence.aps, + ["marks", "performance", "transfer", "heap", "requests", "assertions"], + "aps", + ); + exactKeys( + evidence.aps.marks, + [ + "source", + "candidateBidsScript", + "candidateFirstDisplay", + "candidateFirstDisplayTerminal", + "candidateFirstDisplayPaint", + ], + "aps.marks", + ); + exactString( + evidence.aps.marks.source, + "performance-entry", + "aps.marks.source", + ); + for (const name of [ + "candidateBidsScript", + "candidateFirstDisplay", + "candidateFirstDisplayTerminal", + "candidateFirstDisplayPaint", + ]) { + boolean(evidence.aps.marks[name], true, `aps.marks.${name}`); + } + + exactKeys( + evidence.aps.performance, + ["firstAction", "actionToCompletion", "completionToPaint", "totalToPaint"], + "aps.performance", + ); + const apsFirstAction = evidence.aps.performance.firstAction; + exactKeys( + apsFirstAction, + [ + "baseline", + "candidate", + "percentile", + "maximumRatio", + "absoluteCeilingMs", + "observedRatio", + ], + "aps.performance.firstAction", + ); + if ( + apsFirstAction.percentile !== EXPECTED.percentile || + apsFirstAction.maximumRatio !== EXPECTED.maximumRatio || + apsFirstAction.absoluteCeilingMs !== EXPECTED.aps.actionCeilingMs + ) { + fail("APS first-action policy drifted"); + } + exactKeys( + apsFirstAction.baseline, + ["sha", "artifactModel", "selectedTransferBytes", "samples", "p90"], + "aps.performance.firstAction.baseline", + ); + exactString( + apsFirstAction.baseline.sha, + expected.baseSha, + "APS first-action base SHA", + ); + if ( + apsFirstAction.baseline.artifactModel !== "legacy-rc-v1" && + apsFirstAction.baseline.artifactModel !== "release-v1" + ) { + fail("APS baseline artifact model is invalid"); + } + finiteNumber( + apsFirstAction.baseline.selectedTransferBytes, + "APS baseline selected transfer bytes", + { integer: true, minimum: 1 }, + ); + exactKeys( + apsFirstAction.candidate, + ["artifactModel", "selectedTransferBytes", "samples", "p90"], + "aps.performance.firstAction.candidate", + ); + exactString( + apsFirstAction.candidate.artifactModel, + "release-v1", + "APS candidate artifact model", + ); + finiteNumber( + apsFirstAction.candidate.selectedTransferBytes, + "APS candidate selected transfer bytes", + { integer: true, minimum: 1 }, + ); + const apsBaselineActionP90 = validateVariant( + apsFirstAction.baseline, + "APS baseline first action", + ); + const apsCandidateActionP90 = validateVariant( + apsFirstAction.candidate, + "APS candidate first action", + ); + if (apsBaselineActionP90 <= 0) + fail("APS baseline first-action p90 must be positive"); + const apsObservedRatio = finiteNumber( + apsFirstAction.observedRatio, + "APS first-action observed ratio", + ); + if ( + Math.abs(apsObservedRatio - apsCandidateActionP90 / apsBaselineActionP90) > + Number.EPSILON + ) { + fail("APS first-action ratio is inconsistent"); + } + if (apsCandidateActionP90 > apsBaselineActionP90 * EXPECTED.maximumRatio) { + fail("APS candidate first-action p90 exceeds the paired 10% limit"); + } + if (apsCandidateActionP90 > EXPECTED.aps.actionCeilingMs) { + fail("APS candidate first-action p90 exceeds its absolute ceiling"); + } + + const validateApsInterval = (name, ceiling) => { + const interval = evidence.aps.performance[name]; + exactKeys( + interval, + ["samples", "p90", "ceilingMs"], + `aps.performance.${name}`, + ); + if (interval.ceilingMs !== ceiling) fail(`APS ${name} ceiling drifted`); + const intervalP90 = validateVariant(interval, `APS ${name}`); + if (intervalP90 > ceiling) fail(`APS ${name} p90 exceeds its ceiling`); + }; + validateApsInterval( + "actionToCompletion", + EXPECTED.aps.actionToCompletionCeilingMs, + ); + validateApsInterval( + "completionToPaint", + EXPECTED.aps.completionToPaintCeilingMs, + ); + validateApsInterval("totalToPaint", EXPECTED.aps.totalToPaintCeilingMs); + + exactKeys( + evidence.aps.transfer, + [ + "algorithm", + "maximumRatio", + "baselineReferenceTransfer", + "candidateReferenceTransfer", + ], + "aps.transfer", + ); + exactString( + evidence.aps.transfer.algorithm, + "semantic-tsjs-transfer-v1", + "aps.transfer.algorithm", + ); + if (evidence.aps.transfer.maximumRatio !== EXPECTED.maximumRatio) { + fail("APS transfer ratio limit drifted"); + } + const apsBaselineTransfer = validateReferenceTransfer( + evidence.aps.transfer.baselineReferenceTransfer, + expected.baseSha, + apsFirstAction.baseline.artifactModel, + "aps.transfer.baselineReferenceTransfer", + "0047", + ); + const apsCandidateTransfer = validateReferenceTransfer( + evidence.aps.transfer.candidateReferenceTransfer, + expected.headSha, + apsFirstAction.candidate.artifactModel, + "aps.transfer.candidateReferenceTransfer", + "0047", + ); + if ( + apsFirstAction.baseline.selectedTransferBytes !== + apsBaselineTransfer.externalRawBytes || + apsFirstAction.candidate.selectedTransferBytes !== + apsCandidateTransfer.externalRawBytes + ) { + fail("APS selected transfer bytes disagree with semantic transfer"); + } + for (const metric of ["rawBytes", "gzipBytes", "brotliBytes"]) { + if ( + evidence.aps.transfer.candidateReferenceTransfer[metric] > + evidence.aps.transfer.baselineReferenceTransfer[metric] * + EXPECTED.maximumRatio + ) { + fail(`APS candidate ${metric} transfer exceeds the paired 10% limit`); + } + } + + exactKeys( + evidence.aps.heap, + ["collection", "afterProtectedPaint", "afterTakeoverQueueDrain"], + "aps.heap", + ); + exactString( + evidence.aps.heap.collection, + "playwright-requestGC-then-immediate-cdp-getHeapUsage", + "aps.heap.collection", + ); + for (const [name, ceiling] of [ + ["afterProtectedPaint", EXPECTED.aps.afterProtectedPaintHeapCeilingBytes], + [ + "afterTakeoverQueueDrain", + EXPECTED.aps.afterTakeoverQueueDrainHeapCeilingBytes, + ], + ]) { + const checkpoint = evidence.aps.heap[name]; + exactKeys(checkpoint, ["usedSize", "ceilingBytes"], `aps.heap.${name}`); + if (checkpoint.ceilingBytes !== ceiling) + fail(`APS ${name} heap ceiling drifted`); + if ( + finiteNumber(checkpoint.usedSize, `aps.heap.${name}.usedSize`, { + integer: true, + minimum: 1, + }) > ceiling + ) { + fail(`APS ${name} retained heap exceeds its ceiling`); + } + } + + exactKeys(evidence.aps.requests, ["selected", "deferred"], "aps.requests"); + exactKeys(evidence.aps.requests.selected, ["count"], "aps.requests.selected"); + if (evidence.aps.requests.selected.count !== 1) + fail("APS selected request count must be one"); + exactKeys( + evidence.aps.requests.deferred, + [ + "count", + "requestBeforePaintCount", + "preloadBeforePaintCount", + "preparationBeforePaintCount", + "executionBeforePaintCount", + ], + "aps.requests.deferred", + ); + if (evidence.aps.requests.deferred.count !== 2) + fail("APS deferred module count must be two"); + for (const name of [ + "requestBeforePaintCount", + "preloadBeforePaintCount", + "preparationBeforePaintCount", + "executionBeforePaintCount", + ]) { + if (evidence.aps.requests.deferred[name] !== 0) { + fail(`APS deferred ${name} must be zero`); + } + } + exactKeys( + evidence.aps.assertions, + ["correctness", "loadOrder"], + "aps.assertions", + ); + boolean( + evidence.aps.assertions.correctness, + true, + "aps.assertions.correctness", + ); + boolean(evidence.aps.assertions.loadOrder, true, "aps.assertions.loadOrder"); + exactKeys(evidence.assertions, ["correctness", "loadOrder"], "assertions"); boolean(evidence.assertions.correctness, true, "assertions.correctness"); boolean(evidence.assertions.loadOrder, true, "assertions.loadOrder"); @@ -578,22 +854,22 @@ export function validateEvidence(evidence, expected) { function validFixture() { const evidenceId = "aps-tsjs-preswitch-12345678"; const headSha = "a".repeat(40); - const mainSha = "c".repeat(40); - const mainSamples = Array.from({ length: 50 }, () => 200); + const baseSha = "c".repeat(40); + const baselineSamples = Array.from({ length: 50 }, () => 200); const candidateSamples = Array.from({ length: 50 }, () => 210); return { - expected: { evidenceId, headSha, mainSha, mode: "preswitch" }, + expected: { evidenceId, headSha, baseSha, mode: "preswitch" }, evidence: { - schemaVersion: 5, + schemaVersion: 6, evidenceId, mode: "preswitch", headSha, environment: { chromium: "145.0.7632.6", - controller: "generated-server-v1+production-main-v1", + controller: "generated-server-v1+production-rc-v1", machineClass: "github-hosted:ubuntu-24.04", runnerImage: "ubuntu-24.04", - fixture: "tsjs-main-paired-network-v2", + fixture: "tsjs-baseline-paired-network-v3", node: "v24.12.0", npm: "11.6.2", typescript: "6.0.3", @@ -602,7 +878,7 @@ function validFixture() { warmupsPerVariant: 5, samplesPerVariant: 50, percentile: 90, - interleaving: "alternating-main-candidate", + interleaving: "alternating-baseline-candidate", }, networkProfile: { mechanism: "cdp-Network.emulateNetworkConditions", @@ -622,11 +898,11 @@ function validFixture() { }, performance: { requestToFirstActionMs: { - main: { - sha: mainSha, - artifactModel: "legacy-main-v1", + baseline: { + sha: baseSha, + artifactModel: "legacy-rc-v1", selectedTransferBytes: 80_000, - samples: mainSamples, + samples: baselineSamples, p90: 200, }, candidate: { @@ -642,9 +918,9 @@ function validFixture() { }, transfer: { algorithm: "semantic-tsjs-transfer-v1", - mainReferenceTransfer: { - sha: mainSha, - artifactModel: "legacy-main-v1", + baselineReferenceTransfer: { + sha: baseSha, + artifactModel: "legacy-rc-v1", sources: [ { semanticEndpoint: "inline:legacy-boot", @@ -704,8 +980,8 @@ function validFixture() { heap: { collection: "playwright-requestGC-then-immediate-cdp-getHeapUsage", hardCeilingBytes: 4 * 1024 * 1024, - main: { - sha: mainSha, + baseline: { + sha: baseSha, checkpoints: Object.fromEntries( EXPECTED.heapCheckpoints.map((name) => [name, 1_600_000]), ), @@ -728,6 +1004,135 @@ function validFixture() { headOfLineBlocking: false, }, }, + aps: { + marks: { + source: "performance-entry", + candidateBidsScript: true, + candidateFirstDisplay: true, + candidateFirstDisplayTerminal: true, + candidateFirstDisplayPaint: true, + }, + performance: { + firstAction: { + baseline: { + sha: baseSha, + artifactModel: "legacy-rc-v1", + selectedTransferBytes: 80_000, + samples: Array.from({ length: 50 }, () => 300), + p90: 300, + }, + candidate: { + artifactModel: "release-v1", + selectedTransferBytes: 79_500, + samples: Array.from({ length: 50 }, () => 310), + p90: 310, + }, + percentile: 90, + maximumRatio: 1.1, + absoluteCeilingMs: 900, + observedRatio: 310 / 300, + }, + actionToCompletion: { + samples: Array.from({ length: 50 }, () => 60), + p90: 60, + ceilingMs: 1_500, + }, + completionToPaint: { + samples: Array.from({ length: 50 }, () => 20), + p90: 20, + ceilingMs: 250, + }, + totalToPaint: { + samples: Array.from({ length: 50 }, () => 400), + p90: 400, + ceilingMs: 2_500, + }, + }, + transfer: { + algorithm: "semantic-tsjs-transfer-v1", + maximumRatio: 1.1, + baselineReferenceTransfer: { + sha: baseSha, + artifactModel: "legacy-rc-v1", + sources: [ + { + semanticEndpoint: "inline:legacy-boot", + delivery: "inline", + rawBytes: 1_000, + gzipBytes: 400, + brotliBytes: 300, + sha256: "6".repeat(64), + }, + { + semanticEndpoint: "inline:legacy-ad-init", + delivery: "inline", + rawBytes: 200, + gzipBytes: 100, + brotliBytes: 80, + sha256: "7".repeat(64), + }, + { + semanticEndpoint: `external:/static/tsjs=tsjs-unified.min.js?v=${"8".repeat(64)}`, + delivery: "external", + rawBytes: 80_000, + gzipBytes: 20_000, + brotliBytes: 15_000, + sha256: "8".repeat(64), + }, + ], + rawBytes: 81_200, + gzipBytes: 20_500, + brotliBytes: 15_380, + }, + candidateReferenceTransfer: { + sha: headSha, + artifactModel: "release-v1", + sources: [ + { + semanticEndpoint: "inline:boot-controller", + delivery: "inline", + rawBytes: 1_000, + gzipBytes: 400, + brotliBytes: 300, + sha256: "9".repeat(64), + }, + { + semanticEndpoint: `external:/static/tsjs=tsjs-first-display.min.js?m=0047&v=${"a".repeat(64)}`, + delivery: "external", + rawBytes: 79_500, + gzipBytes: 19_500, + brotliBytes: 14_500, + sha256: "a".repeat(64), + }, + ], + rawBytes: 80_500, + gzipBytes: 19_900, + brotliBytes: 14_800, + }, + }, + heap: { + collection: "playwright-requestGC-then-immediate-cdp-getHeapUsage", + afterProtectedPaint: { + usedSize: 2_000_000, + ceilingBytes: 3 * 1024 * 1024, + }, + afterTakeoverQueueDrain: { + usedSize: 2_500_000, + ceilingBytes: 3_932_160, + }, + }, + requests: { + selected: { count: 1 }, + deferred: { + count: 2, + requestBeforePaintCount: 0, + preloadBeforePaintCount: 0, + preparationBeforePaintCount: 0, + executionBeforePaintCount: 0, + }, + }, + assertions: { correctness: true, loadOrder: true }, + }, assertions: { correctness: true, loadOrder: true }, provenance: { workflowName: "TSJS Performance Gate", @@ -745,21 +1150,24 @@ function validFixture() { function runSelfTest() { const fixture = validFixture(); validateEvidence(fixture.evidence, fixture.expected); - const releaseMainFixture = structuredClone(fixture.evidence); - releaseMainFixture.performance.requestToFirstActionMs.main.artifactModel = + const releaseBaselineFixture = structuredClone(fixture.evidence); + releaseBaselineFixture.performance.requestToFirstActionMs.baseline.artifactModel = "release-v1"; - releaseMainFixture.transfer.mainReferenceTransfer.artifactModel = + releaseBaselineFixture.transfer.baselineReferenceTransfer.artifactModel = "release-v1"; const removedLegacyInit = - releaseMainFixture.transfer.mainReferenceTransfer.sources.splice(1, 1)[0]; - releaseMainFixture.transfer.mainReferenceTransfer.sources[0].semanticEndpoint = + releaseBaselineFixture.transfer.baselineReferenceTransfer.sources.splice( + 1, + 1, + )[0]; + releaseBaselineFixture.transfer.baselineReferenceTransfer.sources[0].semanticEndpoint = "inline:boot-controller"; - releaseMainFixture.transfer.mainReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=0045&v=${"3".repeat(64)}`; + releaseBaselineFixture.transfer.baselineReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=0045&v=${"3".repeat(64)}`; for (const metric of ["rawBytes", "gzipBytes", "brotliBytes"]) { - releaseMainFixture.transfer.mainReferenceTransfer[metric] -= + releaseBaselineFixture.transfer.baselineReferenceTransfer[metric] -= removedLegacyInit[metric]; } - validateEvidence(releaseMainFixture, fixture.expected); + validateEvidence(releaseBaselineFixture, fixture.expected); const mutations = [ ["evidence id", (value) => (value.evidenceId = "wrong-evidence")], ["head SHA", (value) => (value.headSha = "b".repeat(40))], @@ -788,17 +1196,19 @@ function runSelfTest() { (value.performance.requestToFirstActionMs.candidate.selectedTransferBytes = 0), ], [ - "main sample count", - (value) => value.performance.requestToFirstActionMs.main.samples.pop(), + "baseline sample count", + (value) => + value.performance.requestToFirstActionMs.baseline.samples.pop(), ], [ - "main transfer bytes", + "baseline transfer bytes", (value) => - (value.performance.requestToFirstActionMs.main.selectedTransferBytes = 0), + (value.performance.requestToFirstActionMs.baseline.selectedTransferBytes = 0), ], [ - "stale transfer main SHA", - (value) => (value.transfer.mainReferenceTransfer.sha = "b".repeat(40)), + "stale transfer base SHA", + (value) => + (value.transfer.baselineReferenceTransfer.sha = "b".repeat(40)), ], [ "stale transfer candidate SHA", @@ -849,7 +1259,9 @@ function runSelfTest() { (value) => { const transfer = value.transfer.candidateReferenceTransfer; const increase = - value.transfer.mainReferenceTransfer.rawBytes - transfer.rawBytes + 1; + value.transfer.baselineReferenceTransfer.rawBytes - + transfer.rawBytes + + 1; transfer.sources[1].rawBytes += increase; transfer.rawBytes += increase; value.performance.requestToFirstActionMs.candidate.selectedTransferBytes += @@ -861,7 +1273,7 @@ function runSelfTest() { (value) => { const transfer = value.transfer.candidateReferenceTransfer; const increase = - value.transfer.mainReferenceTransfer.gzipBytes - + value.transfer.baselineReferenceTransfer.gzipBytes - transfer.gzipBytes + 1; transfer.sources[1].gzipBytes += increase; @@ -873,7 +1285,7 @@ function runSelfTest() { (value) => { const transfer = value.transfer.candidateReferenceTransfer; const increase = - value.transfer.mainReferenceTransfer.brotliBytes - + value.transfer.baselineReferenceTransfer.brotliBytes - transfer.brotliBytes + 1; transfer.sources[1].brotliBytes += increase; @@ -901,21 +1313,23 @@ function runSelfTest() { (value.performance.requestToFirstActionMs.candidate.samples[0] = null), ], [ - "main SHA", + "base SHA", (value) => - (value.performance.requestToFirstActionMs.main.sha = "b".repeat(40)), + (value.performance.requestToFirstActionMs.baseline.sha = "b".repeat( + 40, + )), ], [ - "main artifact model", + "baseline artifact model", (value) => - (value.performance.requestToFirstActionMs.main.artifactModel = + (value.performance.requestToFirstActionMs.baseline.artifactModel = "unknown-v1"), ], [ "candidate artifact model", (value) => (value.performance.requestToFirstActionMs.candidate.artifactModel = - "legacy-main-v1"), + "legacy-rc-v1"), ], [ "observed ratio", @@ -924,7 +1338,7 @@ function runSelfTest() { [ "heap hard ceiling", (value) => { - value.heap.main.checkpoints.afterBoot = 4 * 1024 * 1024 + 1; + value.heap.baseline.checkpoints.afterBoot = 4 * 1024 * 1024 + 1; value.heap.candidate.checkpoints.afterBoot = 4 * 1024 * 1024 + 1; }, ], @@ -933,7 +1347,7 @@ function runSelfTest() { (value) => (value.heap.collection = "direct-cdp-collection"), ], ["retired heap ratio policy", (value) => (value.heap.maximumRatio = 1.1)], - ["heap main SHA", (value) => (value.heap.main.sha = "b".repeat(40))], + ["heap base SHA", (value) => (value.heap.baseline.sha = "b".repeat(40))], ["selected count", (value) => (value.requests.selected.count = 2)], ["deferred count", (value) => (value.requests.deferred.count = 1)], ["excess deferred count", (value) => (value.requests.deferred.count = 3)], @@ -959,6 +1373,74 @@ function runSelfTest() { (value) => (value.requests.deferred.independentlyTriggered = false), ], ["correctness", (value) => (value.assertions.correctness = false)], + [ + "APS real terminal mark", + (value) => (value.aps.marks.candidateFirstDisplayTerminal = false), + ], + [ + "APS paired action ratio", + (value) => { + value.aps.performance.firstAction.candidate.samples.fill(331); + value.aps.performance.firstAction.candidate.p90 = 331; + value.aps.performance.firstAction.observedRatio = 331 / 300; + }, + ], + [ + "APS action absolute ceiling", + (value) => { + value.aps.performance.firstAction.candidate.samples.fill(901); + value.aps.performance.firstAction.candidate.p90 = 901; + value.aps.performance.firstAction.observedRatio = 901 / 300; + }, + ], + [ + "APS completion ceiling", + (value) => { + value.aps.performance.actionToCompletion.samples.fill(1_501); + value.aps.performance.actionToCompletion.p90 = 1_501; + }, + ], + [ + "APS paint ceiling", + (value) => { + value.aps.performance.completionToPaint.samples.fill(251); + value.aps.performance.completionToPaint.p90 = 251; + }, + ], + [ + "APS total ceiling", + (value) => { + value.aps.performance.totalToPaint.samples.fill(2_501); + value.aps.performance.totalToPaint.p90 = 2_501; + }, + ], + [ + "APS transfer ratio", + (value) => { + const transfer = value.aps.transfer.candidateReferenceTransfer; + const limit = Math.floor( + value.aps.transfer.baselineReferenceTransfer.rawBytes * 1.1, + ); + const increase = limit - transfer.rawBytes + 1; + transfer.sources[1].rawBytes += increase; + transfer.rawBytes += increase; + value.aps.performance.firstAction.candidate.selectedTransferBytes += + increase; + }, + ], + [ + "APS protected-paint heap", + (value) => + (value.aps.heap.afterProtectedPaint.usedSize = 3 * 1024 * 1024 + 1), + ], + [ + "APS takeover heap", + (value) => (value.aps.heap.afterTakeoverQueueDrain.usedSize = 3_932_161), + ], + [ + "APS deferred execution before paint", + (value) => (value.aps.requests.deferred.executionBeforePaintCount = 1), + ], ["load order", (value) => (value.assertions.loadOrder = false)], ["incomplete", (value) => (value.result = "failed")], [ @@ -998,10 +1480,10 @@ function runSelfTest() { () => validateEvidence(fixture.evidence, { ...fixture.expected, - mainSha: undefined, + baseSha: undefined, }), /invalid TSJS performance evidence/u, - "missing current-main identity must make the relative comparison unavailable", + "missing exact-rc identity must make the relative comparison unavailable", ); const repositoryRoot = new URL("../", import.meta.url); const performanceTest = readFileSync( @@ -1053,12 +1535,12 @@ function runSelfTest() { ); assert.match( performanceTest, - /tsjs-first-display\.min\.js\?m=0045&v=/u, - "the release-v1 semantic transfer must measure the exact admitted reference agent", + /performanceCase === "aps" \? "0047" : "0045"/u, + "the release-v1 semantic transfer must measure the exact admitted GPT and APS agents", ); assert.match( generatorSource, - /tsjs_bootstrap_fragment_v1/u, + /tsjs_bootstrap_fixture_fragment_v1/u, "the production server fixture must emit the admitted first-display transport", ); assert.match( @@ -1083,7 +1565,7 @@ function runSelfTest() { ); assert.match( performanceTest, - /FIXTURE_ID = "tsjs-main-paired-network-v2"/u, + /FIXTURE_ID = "tsjs-baseline-paired-network-v3"/u, "the browser gate must identify the paired network-shaped fixture", ); assert.doesNotMatch( @@ -1104,8 +1586,8 @@ function runSelfTest() { ); assert.match( performanceTest, - /loadLegacyMainFixtureResources[\s\S]*tsjs-core\.js[\s\S]*tsjs-creative\.js[\s\S]*tsjs-gpt\.js/u, - "the browser gate must consume main's actual legacy core, creative, and GPT artifact shape", + /loadLegacyBaselineFixtureResources[\s\S]*tsjs-core\.js[\s\S]*tsjs-creative\.js[\s\S]*tsjs-gpt\.js/u, + "the browser gate must consume the rc base's actual legacy core, creative, and GPT artifact shape", ); assert.match( performanceTest, @@ -1114,13 +1596,13 @@ function runSelfTest() { ); assert.match( generatorSource, - /creative:[\s\S]*enabled: true/u, - "the generated candidate controller must enable main's default creative policy", + /CreativeBootConfigV1 \{[\s\S]*enabled: true/u, + "the generated candidate controller must enable the rc baseline's default creative policy", ); assert.match( performanceTest, - /loadMainFixtureResources[\s\S]*tsjs-release-v1\.json[\s\S]*loadReleaseFixtureResources[\s\S]*loadLegacyMainFixtureResources/u, - "the browser gate must detect main's actual legacy or release-v1 artifact shape", + /loadBaselineFixtureResources[\s\S]*tsjs-release-v1\.json[\s\S]*loadReleaseFixtureResources[\s\S]*loadLegacyBaselineFixtureResources/u, + "the browser gate must detect the rc base's actual legacy or release-v1 artifact shape", ); assert.match( performanceTest, @@ -1129,7 +1611,7 @@ function runSelfTest() { ); assert.match( performanceTest, - /mainReferenceTransfer:[\s\S]*mainResources\.referenceTransfer[\s\S]*candidateReferenceTransfer:[\s\S]*candidateResources\.referenceTransfer/u, + /baselineReferenceTransfer:[\s\S]*baselineResources\.referenceTransfer[\s\S]*candidateReferenceTransfer:[\s\S]*candidateResources\.referenceTransfer/u, "the evidence must record both exact semantic reference transfers", ); assert.match( @@ -1150,13 +1632,28 @@ function runSelfTest() { } assert.match( performanceTest, - /candidateResources\.referenceTransfer\[metric\][\s\S]*mainResources\.referenceTransfer\[metric\]/u, + /candidateResources\.referenceTransfer\[metric\][\s\S]*baselineResources\.referenceTransfer\[metric\]/u, "the browser gate must softly collect all evidence while enforcing every transfer encoding", ); assert.match( performanceWorkflowScript, - /git fetch origin main[\s\S]*main_sha="\$\(git rev-parse origin\/main\)"[\s\S]*TSJS_PERF_MAIN_SHA/u, - "the performance workflow script must resolve and export the exact current main SHA", + /git fetch origin refs\/heads\/rc\/202608:refs\/remotes\/origin\/rc\/202608[\s\S]*merge-base --is-ancestor "\$base_sha" origin\/rc\/202608[\s\S]*TSJS_PERF_BASE_SHA/u, + "the performance workflow script must validate and export the exact rc base SHA", + ); + assert.match( + performanceWorkflow, + /base_sha:[\s\S]*required: true/u, + "manual and called performance runs must require an exact base SHA", + ); + assert.match( + performanceWorkflow, + /TSJS_PERF_BASE_SHA: \$\{\{ github\.event_name == 'pull_request' && github\.event\.pull_request\.base\.sha \|\| inputs\.base_sha \}\}/u, + "PR performance runs must bind the exact pull-request base SHA", + ); + assert.doesNotMatch( + performanceWorkflowScript, + /origin\/main|TSJS_PERF_MAIN/u, + "the performance scripts must not use a moving main baseline", ); assert.match( performanceWorkflow, @@ -1208,7 +1705,7 @@ function runSelfTest() { assert.doesNotMatch( `${performanceWorkflow}\n${performanceWorkflowScript}`, /62421ee44c62f24534ea8782a46dfa5bfbcea950/u, - "the performance workflow must never build a frozen reference instead of current main", + "the performance workflow must never build a frozen reference instead of the exact rc base", ); assert.doesNotMatch( performanceTest, @@ -1315,7 +1812,7 @@ function runSelfTest() { } assert.match( performanceTest, - /const heapBrowser = await chromium\.launch\([\s\S]*collectHeapCheckpoints\([\s\S]*heapBrowser,[\s\S]*mainServer[\s\S]*collectHeapCheckpoints\([\s\S]*heapBrowser,[\s\S]*candidateServer/u, + /const heapBrowser = await chromium\.launch\([\s\S]*collectHeapCheckpoints\([\s\S]*heapBrowser,[\s\S]*baselineServer[\s\S]*collectHeapCheckpoints\([\s\S]*heapBrowser,[\s\S]*candidateServer/u, "paired retained-heap checkpoints must run in one fresh, explicitly closed Chromium process", ); assert.match( @@ -1331,12 +1828,12 @@ function runSelfTest() { assert.match( performanceTest, /history\.pushState\(\{\}, "", "\/fixture\/navigation"\)/u, - "the paired SPA heap checkpoint must change pathname so current main and candidate both observe the same navigation", + "the paired SPA heap checkpoint must change pathname so the rc base and candidate both observe the same navigation", ); assert.doesNotMatch( performanceTest, /history\.pushState\(\{\}, "", "\/fixture\?navigation=/u, - "the paired SPA heap checkpoint must not use a query-only transition ignored by current main", + "the paired SPA heap checkpoint must not use a query-only transition ignored by the rc base", ); assert.match( performanceTest, @@ -1433,7 +1930,7 @@ function runSelfTest() { assert.match( generalTestWorkflow, /test-typescript:[\s\S]*?uses: actions\/checkout@v4\n with:\n fetch-depth: 0/u, - "the current-main concept audit must receive the pinned baseline commit", + "the exact-rc concept audit must receive the pinned baseline commit", ); console.log( `TSJS performance evidence self-test passed (${mutations.length} mutations)`, @@ -1454,7 +1951,7 @@ function parseArguments(arguments_) { "--file", "--evidence-id", "--head-sha", - "--main-sha", + "--base-sha", "--mode", ]) { if (!values.has(name)) fail(`missing ${name}`); @@ -1480,7 +1977,7 @@ function main() { validateEvidence(evidence, { evidenceId: arguments_.get("--evidence-id"), headSha: arguments_.get("--head-sha"), - mainSha: arguments_.get("--main-sha"), + baseSha: arguments_.get("--base-sha"), mode: arguments_.get("--mode"), }); console.log("TSJS performance evidence is valid"); From 12dba1d225b841b33b5539f47545e560ce606d46 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:31:36 -0700 Subject: [PATCH 832/844] Enforce final TSJS cutover boundaries --- .../trusted-server-core/src/auction/types.rs | 2 +- .../trusted-server-core/src/html_processor.rs | 6 +- crates/trusted-server-core/src/publisher.rs | 11 +- .../lib/build-prebid-external.mjs | 21 ++- .../lib/eslint-rules/no-adtech-globals.js | 5 +- .../scripts/check-hard-cutover-absence.mjs | 139 +++++++++++++++++- .../lib/test/build-prebid-external.test.mjs | 22 +++ .../lib/test/build/release-v1.test.mjs | 67 +++++++++ .../test/eslint/no-adtech-globals.test.mjs | 20 ++- docs/guide/integrations/aps.md | 8 +- docs/guide/integrations/gpt-diagnostics.md | 10 +- 11 files changed, 286 insertions(+), 25 deletions(-) diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index f137f9ddf..55b69534d 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -962,7 +962,7 @@ pub struct Bid { /// Prebid Cache UUID for this bid. /// /// Populated from `ext.prebid.cache.bids.cacheId` in the PBS response. - /// Used as `hb_adid` targeting value in `window.tsjs.bids`. `None` for + /// Used as the `hb_adid` value in the initial browser auction projection. `None` for /// non-PBS providers (e.g., APS) and PBS bids without Prebid Cache enabled. pub cache_id: Option, /// Prebid Cache host (e.g., `"openads.adsrvr.org"`). diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 6d3617efd..130f39468 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -181,12 +181,12 @@ pub struct HtmlProcessorConfig { pub request_host: String, pub request_scheme: String, pub integrations: IntegrationRegistry, - /// Pre-computed ``. - /// Injected at `` open. `None` when no slots matched. + /// Pre-computed canonical browser-slot JSON used to build the immutable boot + /// auction projection. `None` when no slots matched. pub ad_slots_script: Option, /// Shared auction result — written by auction task before HTML processing begins. /// Handler reads this in `el.on_end_tag()` on the body element. - /// `None` means no auction ran; inject empty `tsjs.bids = {}` as fallback. + /// `None` means no auction ran; inject an empty boot auction projection. pub ad_bids_state: std::sync::Arc>>, /// Maximum bytes the post-processing accumulator may buffer before the /// processor aborts. Mirrors `publisher.max_buffered_body_bytes` so the diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index a19588b9b..b15bca824 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1337,7 +1337,7 @@ pub struct OwnedProcessResponseParams { /// The streaming phase collects these and writes bids to `ad_bids_state` /// before processing the last body chunk, so `` injection sees live bids. pub(crate) dispatched_auction: Option, - /// Price granularity used to bucket bids when building `tsjs.bids`. + /// Price granularity used to bucket bids in the browser auction projection. pub(crate) price_granularity: PriceGranularity, /// Request-scoped conditional diagnostics delivery decision. pub(crate) gpt_diagnostics: @@ -3956,10 +3956,11 @@ pub async fn handle_publisher_request( // stamp. // 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, - // no per-user `tsjs.adSlots`/`tsjs.bids` are injected. Applies regardless of - // the auction *outcome* (empty bids still inject per-user slot state). The - // separate EC-cookie cache net in the adapter's `finalize_response` keeps - // first-visit identity responses private. + // no per-navigation browser auction projection is injected, so forcing private + // here would needlessly strip shared cacheability from ordinary publisher + // HTML. Applies regardless of the auction *outcome* (empty bids still inject + // per-user slot state). The separate EC-cookie cache net in the adapter's + // `finalize_response` keeps first-visit identity responses private. let origin_content_type = response .headers() .get(header::CONTENT_TYPE) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index f8a370d18..1a77b9b70 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -42,7 +42,25 @@ const LEGACY_RUNTIME_FLAG_PREFIX = ['__', 'tsjs', '_'].join(''); /** Refuse to publish an external Prebid artifact carrying a retired TSJS runtime flag. */ export function assertNoLegacyRuntimeFlags(bundleCode) { if (bundleCode.includes(LEGACY_RUNTIME_FLAG_PREFIX)) { - throw new Error('[build-prebid-external] Generated artifact contains a legacy TSJS runtime flag'); + throw new Error( + '[build-prebid-external] Generated artifact contains a legacy TSJS runtime flag' + ); + } +} + +const TS_OWNED_PREBID_ARTIFACT_MARKERS = [ + /\bTS (?:APS|ADM|Owner|Render Owner)\b/u, + /\/_ts\/(?:auction|page-bids)\b/u, + /\b(?:rendererReservationId|lifecycleTicket)\b/u, + /\btsjs\.(?:addAdUnits|diagnostics|requestAds)\b/u, +]; + +/** Keep the independently useful external bundle free of TS-owned behavior. */ +export function assertPurePrebidArtifact(bundleCode) { + if (TS_OWNED_PREBID_ARTIFACT_MARKERS.some((expression) => expression.test(bundleCode))) { + throw new Error( + '[build-prebid-external] Generated artifact contains TS-owned auction or render behavior' + ); } } @@ -404,6 +422,7 @@ async function buildExternalBundle(outDir, generatedModules, stamp) { throw new Error('[build-prebid-external] Artifact release sentinel remained after stamping'); } assertNoLegacyRuntimeFlags(finalBundle); + assertPurePrebidArtifact(finalBundle); const bundleBytes = Buffer.from(finalBundle, 'utf8'); const metadata = deriveBundleMetadata(bundleBytes); const finalPath = path.join(outDir, metadata.filename); 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 00e253512..07de1d026 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 @@ -2,8 +2,9 @@ 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. +// and function-returned roots (`getWin().googletag`). Exact adapter boundaries, +// restricted imports, bundle/source scans, and browser ownership tests remain the +// defense-in-depth layers for those exotic forms. function normalizeFilename(filename, rootDirectory) { const normalized = filename.replaceAll('\\', '/'); diff --git a/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs index 9629e4691..d1f490894 100644 --- a/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs +++ b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs @@ -6,15 +6,21 @@ const repositoryRoot = path.resolve(packageRoot, '../../..'); const extensions = new Set([ '.css', '.html', + '.integrity', '.js', '.json', + '.log', + '.map', '.md', '.mjs', '.rs', '.sh', + '.sha256', + '.sri', '.toml', '.ts', '.tsx', + '.txt', '.yaml', '.yml', ]); @@ -45,6 +51,9 @@ function lineNumber(source, offset) { const jsPackageFiles = collect(packageRoot); const shippedTsjsFiles = collect(path.join(packageRoot, 'src')); +const productionTsjsFiles = shippedTsjsFiles.filter( + (file) => !/(?:_test|\.test)\.[cm]?[jt]sx?$/u.test(file) +); const generatedTsjsFiles = collect(path.join(packageRoot, 'dist')); const currentGuideFiles = collect(path.join(repositoryRoot, 'docs/guide')); const browserTestFiles = collect( @@ -87,6 +96,115 @@ function token(...parts) { return parts.join(''); } +const retiredRuntimeFiles = new Set([ + 'src/composition/browser.ts', + 'src/composition/critical_transport.ts', + 'src/composition/index.ts', + 'src/core/bootstrap_controller.ts', +]); +const retiredCutoverExpressions = [ + [ + 'non-canonical APS renderer route', + /\/integrations\/aps\/renderer(?!\/v2(?![A-Za-z0-9_./-]))/gu, + ], + [ + 'rendererUrl in a v4 Prebid response', + /(?:message\s*:\s*['"]Prebid Response['"]|\[['"]message['"]\]\s*=\s*[^;\n]*prebidResponse)[\s\S]{0,512}(?:rendererVersion\s*:\s*['"]4['"]|\[['"]rendererVersion['"]\]\s*=)[\s\S]{0,512}\brendererUrl\b/gu, + ], + ['retired APS owner start payload', /['"]TS APS Start['"]/gu], + ['raw TSJS integration global', /__tsjs_[A-Za-z0-9_]+/gu], + ['retired bundle activation attribute', /data-ts-gam-attribution/gu], + [ + 'retired TSJS public surface', + /\b(?:LegacyTsjsApi|TsjsApiV1|apsPrebidRenderers|renderAllAdUnits|renderAdUnit|renderLog|renderSeq|registerContextProvider|collectContext|installGuards)\b|tsjs:adRendered/gu, + ], + [ + 'retired TSJS namespace member', + /\btsjs(?:\.|\?\.)(?:adSlots|bids|getConfig|gptDiagnostics|renders|setConfig)\b/gu, + ], + ['retired TSJS public version', /\btsjs(?:\.|\?\.)version\s*=\s*['"]0\.1\.0['"]/gu], + ['retired creative global', /\b(?:tscreative|tsCreativeConfig)\b/gu], +]; + +export function findCutoverTextViolations(file, source) { + const normalized = file + .replaceAll('\\', '/') + .replace(/^.*\/crates\/trusted-server-js\/lib\//u, ''); + const found = []; + if (retiredRuntimeFiles.has(normalized)) found.push('retired second runtime file'); + for (const [label, expression] of retiredCutoverExpressions) { + expression.lastIndex = 0; + if (expression.test(source)) found.push(label); + } + return found; +} + +const forbiddenVendorBasename = + /^(?:gpt|pubads_impl|prebid-creative|prebid-universal-creative(?:[._-].*)?)\.(?:[cm]?js|html)(?:\.(?:integrity|map|sha256|sri))?$/iu; +const vendorBodyExpressions = [ + /\/[*!]\s*(?:@license\s+)?[^\n]{0,160}\bPrebid Universal Creative\b/giu, + /\/[*!][^\n]{0,160}@license[^\n]{0,160}\bGoogle Publisher Tag\b/giu, + /\/[*!][^\n]{0,160}(?:Copyright|@license)[^\n]{0,160}\bAmazon Publisher Services\b/giu, + /\b(?:apsRunner|gpt|puc)(?:Digest|Integrity|Sha(?:256|384|512)|Sri|Checksum|Version)\b/giu, + /\b(?:digest|integrity|sha(?:256|384|512)|sri|checksum)\b[^\n]{0,160}\b(?:prebid-creative\.js|Google Publisher Tag|Prebid Universal Creative|Amazon Publisher Services)\b/giu, + /\b(?:prebid-creative\.js|Google Publisher Tag|Prebid Universal Creative|Amazon Publisher Services)\b[^\n]{0,160}\b(?:digest|integrity|sha(?:256|384|512)|sri|checksum)\b/giu, +]; + +export function findVendorBoundaryViolations(file, source) { + const normalized = file.replaceAll('\\', '/'); + const basename = path.posix.basename(normalized); + const found = []; + if (forbiddenVendorBasename.test(basename)) found.push('vendor distributable filename'); + for (const expression of vendorBodyExpressions) { + expression.lastIndex = 0; + if (expression.test(source)) found.push('stored vendor body, checksum, or version metadata'); + } + return found; +} + +for (const file of uniqueFiles([ + ...productionTsjsFiles, + ...generatedTsjsFiles, + ...currentGuideFiles, + ...productionRustFiles, +])) { + const source = fs.readFileSync(file, 'utf8'); + const productionSource = file.endsWith('.rs') + ? (source.split('\n#[cfg(test)]')[0] ?? source) + : source; + for (const label of findCutoverTextViolations(relative(file), productionSource)) { + violations.push(`${relative(file)}:1: ${label}`); + } +} + +const vendorBoundaryFiles = [ + ...productionTsjsFiles, + ...generatedTsjsFiles, + ...productionRustFiles, + ...collect(path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/fixtures')), + ...collect(path.join(repositoryRoot, 'crates/trusted-server-integration-tests/fixtures')), + ...collect(path.join(repositoryRoot, 'target/aps-tsjs-quality-evidence')), + ...collect(path.join(repositoryRoot, 'target/aps-tsjs-cutover-evidence')), + ...collect( + path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/real-gam-evidence') + ), + ...collect( + path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/playwright-report') + ), + ...collect( + path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser/test-results') + ), +]; +for (const file of uniqueFiles(vendorBoundaryFiles)) { + const source = fs.readFileSync(file, 'utf8'); + const productionSource = file.endsWith('.rs') + ? (source.split('\n#[cfg(test)]')[0] ?? source) + : source; + for (const label of findVendorBoundaryViolations(relative(file), productionSource)) { + violations.push(`${relative(file)}:1: ${label}`); + } +} + const oldRuntimePrefix = token('__', 'tsjs', '_'); const oldCreativeGlobal = token('ts', 'creative'); const oldIntegrationConfigTransport = token('_', 'integration', 'Config'); @@ -230,6 +348,22 @@ for (const manifest of browserPackageManifests) { violations.push(`${manifest}:1: PUC package is vendored into the local harness`); } } +const realGamNetworkFile = path.join( + repositoryRoot, + 'crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts' +); +const realGamNetworkSource = fs.readFileSync(realGamNetworkFile, 'utf8'); +if (!/export const REAL_GAM_PUC_RELEASE = ['"]1\.17\.2['"];/u.test(realGamNetworkSource)) { + violations.push( + `${relative(realGamNetworkFile)}:1: protected conformance metadata must pin PUC 1.17.2` + ); +} +forbidSource( + realGamNetworkFile, + realGamNetworkSource, + 'PUC conformance metadata must not carry vendor bytes, URLs, or checksums', + /\bPUC_(?:ASSET|BODY|DIGEST|INTEGRITY|SCRIPT|SRI|URL|VERSION)\b/gu +); for (const manifest of [ 'crates/trusted-server-adapter-fastly/Cargo.toml', @@ -285,10 +419,13 @@ for (const [file, required] of requiredReplacements) { } } +const executedAsMain = + process.argv[1] !== undefined && + path.resolve(process.argv[1]) === path.resolve(import.meta.filename); if (violations.length > 0) { console.error(`Hard-cutover absence check failed (${violations.length} violations):`); for (const violation of violations.sort()) console.error(`- ${violation}`); process.exitCode = 1; -} else { +} else if (executedAsMain) { console.log('Hard-cutover absence check passed.'); } 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 0e8a7e89a..8176cf91c 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 @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest'; import { ARTIFACT_RELEASE_SENTINEL, assertNoLegacyRuntimeFlags, + assertPurePrebidArtifact, deriveBundleMetadata, main, parseArgs, @@ -26,6 +27,27 @@ describe('build-prebid-external metadata', () => { expect(() => assertNoLegacyRuntimeFlags('window.pbjs = { que: [] };')).not.toThrow(); }); + it('rejects Trusted Server auction, render, and PUC behavior in the external Prebid artifact', () => { + for (const marker of [ + 'TS APS Top Mount Started', + 'TS ADM Start', + 'TS Render Owner Register', + '/_ts/auction', + 'rendererReservationId', + 'lifecycleTicket', + 'tsjs.requestAds', + ]) { + expect(() => assertPurePrebidArtifact(`/* prebid */ ${marker}`)).toThrow( + /TS-owned auction or render behavior/ + ); + } + expect(() => + assertPurePrebidArtifact( + 'window.pbjs={que:[],cmd:[]};window.__prebidProtocol="Prebid Request";Object.defineProperty(window.pbjs,"__trustedServerArtifactV1",{});' + ) + ).not.toThrow(); + }); + it('derives filename, sha256, and SRI from exact bundle bytes', () => { const bundleBytes = Buffer.from('console.log("trusted prebid");\n', 'utf8'); const sha256 = crypto.createHash('sha256').update(bundleBytes).digest('hex'); 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 1350cc077..4737842eb 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 @@ -20,6 +20,10 @@ import { } from '../../scripts/check-bundle-budgets.mjs'; import * as bundleBudgets from '../../scripts/check-bundle-budgets.mjs'; import * as bundleMetrics from '../../scripts/bundle-metrics.mjs'; +import { + findCutoverTextViolations, + findVendorBoundaryViolations, +} from '../../scripts/check-hard-cutover-absence.mjs'; const testDirectory = path.dirname(fileURLToPath(import.meta.url)); const libDirectory = path.resolve(testDirectory, '../..'); @@ -1978,6 +1982,69 @@ test('hard-cutover absence is exposed once and enforced after both production bu assert.ok(absenceStep > externalPrebidStep, 'absence must run after both production builds'); }); +test('hard-cutover policy rejects every retired wire, runtime, and public surface', () => { + const retired = [ + ['src/legacy.ts', '"/integrations/aps/renderer"'], + [ + 'src/legacy.ts', + "JSON.stringify({message:'Prebid Response',rendererVersion:'4',rendererUrl})", + ], + ['src/legacy.ts', "port.postMessage({message:'TS APS Start'})"], + ['src/legacy.ts', 'window.__tsjs_gpt_enabled = true'], + ['src/legacy.ts', 'script.setAttribute("data-ts-gam-attribution", "1")'], + ['src/legacy.ts', 'interface TsjsApiV1 {}'], + ['src/legacy.ts', 'tsjs.renderAdUnit("slot")'], + ['src/legacy.ts', 'tsjs.setConfig({debug: true})'], + ['src/legacy.ts', 'const value = tsjs.getConfig()'], + ['src/legacy.ts', 'const slots = tsjs.adSlots'], + ['src/legacy.ts', 'const trace = tsjs.renders'], + ['src/legacy.ts', 'const diagnostics = tsjs.gptDiagnostics'], + ['src/legacy.ts', 'tsjs.version = "0.1.0"'], + ['src/legacy.ts', 'window.dispatchEvent(new Event("tsjs:adRendered"))'], + ['src/composition/browser.ts', 'export function createBrowserRuntime() {}'], + ]; + for (const [file, source] of retired) { + assert.ok( + findCutoverTextViolations(file, source).length > 0, + `retired cutover surface should be rejected: ${source}` + ); + } +}); + +test('vendor boundary rejects APS, GPT, and PUC artifacts but permits conformance metadata', () => { + const vendored = [ + ['fixtures/prebid-creative.js', 'window._aps = new Map();'], + ['fixtures/gpt.js', 'window.googletag = window.googletag || {};'], + ['fixtures/gpt.js.map', '{"sources":["gpt.js"]}'], + ['fixtures/gpt.js.sha256', 'deadbeef'], + ['fixtures/prebid-universal-creative-1.17.2.js', 'window.renderAd = function() {};'], + ['fixtures/renamed.js', '/*! Prebid Universal Creative v1.17.2 */'], + ['fixtures/renamed.js', '/*! @license Google Publisher Tag */'], + ['fixtures/renamed.js', '/*! Copyright Amazon Publisher Services */'], + ['fixtures/vendor.json', '{"pucIntegrity":"sha384-deadbeef"}'], + ]; + for (const [file, source] of vendored) { + assert.ok( + findVendorBoundaryViolations(file, source).length > 0, + `vendored upstream artifact should be rejected: ${file}` + ); + } + assert.deepEqual( + findVendorBoundaryViolations( + 'browser/helpers/gam-test-network.ts', + 'export const REAL_GAM_PUC_RELEASE = "1.17.2"; Object.freeze({pucRelease: REAL_GAM_PUC_RELEASE});' + ), + [] + ); + assert.deepEqual( + findVendorBoundaryViolations( + 'browser/fixtures/fictional-aps-runner.js', + '// Fictional hermetic APS runner fixture; not copied from APS.\nwindow._aps = new Map();' + ), + [] + ); +}); + test('registered integration dispatch selects post-switch evidence without changing the instrument', () => { const workflow = fs.readFileSync( path.join(repositoryRoot, '.github/workflows/integration-tests.yml'), 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 40b788091..396781a4d 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 @@ -278,8 +278,12 @@ test('restricted paths enforce dependency direction and exact target-file exempt (await restrictedMessages("import '../core/types';", 'src/services/new-service.ts')).length > 0 ); assert.ok( - (await restrictedMessages("import '../composition/runtime_transport';", 'src/kernel/new-runtime.ts')) - .length > 0 + ( + await restrictedMessages( + "import '../composition/runtime_transport';", + 'src/kernel/new-runtime.ts' + ) + ).length > 0 ); assert.ok( ( @@ -333,3 +337,15 @@ test('every current integration directory participates in cross-integration isol assert.deepEqual(actual, [...ARCHITECTURE_INTEGRATION_DIRECTORIES].sort()); }); + +test('documents exotic global-analysis blind spots and the defense-in-depth layers', async () => { + const source = await readFile( + path.join(packageRoot, 'eslint-rules/no-adtech-globals.js'), + 'utf8' + ); + + assert.match(source, /computed composition/u); + assert.match(source, /function-returned roots/u); + assert.match(source, /bundle\/source scans/u); + assert.match(source, /browser ownership tests/u); +}); diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index d0c27b82e..eee2815b0 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -272,7 +272,7 @@ allow-scripts allow-top-navigation-by-user-activation ``` -It deliberately omits `allow-same-origin` during bootstrap, so APS and bidder execution remains below an opaque-origin boundary. The renderer response repeats these restrictions with a CSP `sandbox` directive, preventing another embedding path from restoring publisher-origin execution by omitting the iframe attribute. Trusted Server generates independent 128-bit bootstrap and renderer nonces, binds each to its own phase, and accepts each exactly once. No descriptor or data-document URL crosses the bootstrap configuration channel. Existing slot content is retained until the inner renderer has accepted the descriptor and APS reports render completion. +It deliberately omits `allow-same-origin` during bootstrap, so APS and bidder execution remains below an opaque-origin boundary. The renderer response is an intentional transport-CSP superset with no CSP `sandbox` directive. The generated outer and inner meta CSPs narrow it to the exact Trusted Server and creative origins, while the iframe `sandbox` attributes enforce the exact phase-specific restrictions. Trusted Server generates independent 128-bit bootstrap and renderer nonces, binds each to its own phase, and accepts each exactly once. No descriptor or data-document URL crosses the bootstrap configuration channel. Existing slot content is retained until the inner renderer has accepted the descriptor and APS reports render completion. ### Direct `/auction` @@ -280,11 +280,7 @@ In `trusted_server` mode, the TSJS auction client validates the typed renderer d ### GAM and Universal Creative -For initial navigation and page-bids, Trusted Server publishes the same descriptor in `window.tsjs.bids`. The source-checked Prebid Universal Creative bridge accepts requests only from the iframe that owns the matching `hb_adid` and validates the complete envelope. In `trusted_server` mode it returns a static dynamic-renderer program that creates the same opaque renderer iframe. After the response is delivered, the bridge expands an authenticated ordinary display iframe only when its width and height attributes and computed geometry are still 1x1. It resizes that source iframe and its immediate collapsed shell parent to the validated winning dimensions. Ambiguous sources, stale navigation or refresh completions, anchors, interstitials, fixed or sticky frames, invalid dimensions, and already-expanded frames remain unchanged. The same guard applies to APS capabilities, inline `adm`, and PBS Cache responses. - -In `publisher_native` mode the bridge instead resolves the publisher div and starts the friendly-frame runner without sending a Universal Creative renderer response. That renderer replaces the slot through a different owner and does not run the collapsed-shell helper. - -After the native runner loads, Trusted Server replaces the existing children of the resolved publisher div with the friendly frame. This removes the GAM or Universal Creative iframe when it is inside that div. If the runner fails, the existing iframe remains, but its Universal Creative request receives no response because Trusted Server has already claimed the selected bid. This one-owner behavior avoids a second render path, but GAM impression and viewability reporting must be validated with the APS account team for the controlled cohort. +For initial navigation, Trusted Server publishes the descriptor in the immutable `tsjs.boot.auctionProjection`. A later SPA page-bids response replaces only that navigation session's internal projection and never mutates `tsjs.boot`. The source-checked Prebid Universal Creative bridge accepts requests only from the iframe that owns the matching `hb_adid`, validates the complete envelope, and returns a static dynamic-renderer program that creates the same opaque renderer iframe. For client-side `trustedServer` adapter auctions, Prebid generates its own `hb_adid`. Trusted Server binds that generated ID to the validated APS descriptor in a bounded, expiring browser registry before GAM refresh. The bridge verifies that the requesting Universal Creative iframe belongs to the same ad unit, consumes the capability once, and passes the APS bid ID separately to the Amazon runner. diff --git a/docs/guide/integrations/gpt-diagnostics.md b/docs/guide/integrations/gpt-diagnostics.md index 9fedba975..9d0199bc7 100644 --- a/docs/guide/integrations/gpt-diagnostics.md +++ b/docs/guide/integrations/gpt-diagnostics.md @@ -45,10 +45,12 @@ and non-storeable. ### Auction correlation token -Enabling the integration has one further server-side effect, beyond module -availability, that does not depend on browser activation. For each server-side auction -that produced winning bids, Trusted Server mints a fresh correlation token and publishes -it as `hb_auction_id` on each winning bid in `window.tsjs.bids`: +Enabling the integration has one server-side effect that does not depend on browser +activation. For each server-side auction that produced winning bids, Trusted Server +mints a fresh correlation token and publishes it as `hb_auction_id` on each winning +bid targeting record in the initial immutable `tsjs.boot.auctionProjection`. A later +SPA page-bids response keeps its replacement projection internal to that navigation +session: ```text ts-auc-2f8c1d5a4b7e4c0f9a3d6b1e8c5f2a7d From 99348a3fade717ee4d6d78989af81c3152dedff9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:34:38 -0700 Subject: [PATCH 833/844] Fix APS cutover browser gate --- .../browser/fixtures/fictional-aps-runner.js | 5 +- .../browser/helpers/gpt-stub.ts | 9 +-- .../tests/shared/aps-puc-lifecycle.spec.ts | 16 +++++- .../browser/tests/shared/aps-renderer.spec.ts | 55 ++++++++++++------- .../tests/shared/creative-sandbox.spec.ts | 16 +++++- .../browser/tests/shared/tsjs-runtime.spec.ts | 2 - .../lib/src/kernel/release_catalog.ts | 10 +++- .../lib/test/build/release-v1.test.mjs | 16 ++++++ .../test/integrations/phase-slices.test.ts | 2 +- .../lib/test/kernel/release_catalog.test.ts | 10 +++- ...s-render-fix-and-tsjs-resilience-design.md | 44 +++++++-------- scripts/ci/aps-tsjs-cutover.sh | 3 +- 12 files changed, 132 insertions(+), 56 deletions(-) diff --git a/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js b/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js index 2aa37be01..750d51a01 100644 --- a/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js +++ b/crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js @@ -92,7 +92,10 @@ creative.style.margin = "0"; creative.style.overflow = "hidden"; } - creative.src = bid.ext.creativeurl; + creative.src = + bidId.indexOf("creative-cross-origin-") === 0 + ? "https://redirect.example/final.js" + : bid.ext.creativeurl; document.body.appendChild(creative); return; } 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 a7351ab71..0e9285df8 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts @@ -165,6 +165,8 @@ export async function installGptStub(page: Page): Promise { refresh: pubadsService.refresh, fetch: window.fetch, xhrOpen: window.XMLHttpRequest.prototype.open, + }; + const publisherHistoryReferences = { pushState: window.history.pushState, replaceState: window.history.replaceState, }; @@ -433,14 +435,13 @@ export async function installGptStub(page: Page): Promise { references.refresh = pubadsService.refresh; references.fetch = window.fetch; references.xhrOpen = window.XMLHttpRequest.prototype.open; - references.pushState = window.history.pushState; - references.replaceState = window.history.replaceState; }, referenceOwnership() { const pushStateChanged = - window.history.pushState !== references.pushState; + window.history.pushState !== publisherHistoryReferences.pushState; const replaceStateChanged = - window.history.replaceState !== references.replaceState; + window.history.replaceState !== + publisherHistoryReferences.replaceState; return { diagnosticsSafe: commandQueue.push === references.commandPush && diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts index d9d990557..9d8d543b3 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts @@ -84,6 +84,19 @@ function boot() { }, ], }, + integrations: { + version: 1, + entries: [ + { id: "aps", config: {} }, + { + id: "gpt", + config: { + gamAttributionEnabled: false, + pageBidsEnabled: true, + }, + }, + ], + }, creative: { version: 1, enabled: false, @@ -152,7 +165,6 @@ async function openLifecyclePage( browserWindow.tsjs = { boot: initialBoot, que: [], - _integrationConfig: { aps: {}, gpt: {} }, }; }, { @@ -355,7 +367,7 @@ test.describe("APS/PUC lifecycle over the hard-cutover runtime", () => { ) .toMatchObject({ lifecycle: ["accepted", "failed:fictional PUC response refused"], - ownerFrames: [1, 0], + ownerFrames: [0, 0], pucFrames: 2, }); 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 bb3196848..62680bafa 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 @@ -9,7 +9,8 @@ const APS_TEST_RENDERER_URL = `${APS_TEST_ORIGIN}/integrations/aps/renderer/v2`; const APS_TEST_RUNNER_URL = `${APS_TEST_ORIGIN}/integrations/aps/runner.js`; const SANDBOX = "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation"; -const PERMANENT_SANDBOX = `${SANDBOX} allow-same-origin`; +const PERMANENT_SANDBOX = + "allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation"; const FICTIONAL_APS_RUNNER = readFileSync( resolve(__dirname, "../../fixtures/fictional-aps-runner.js"), "utf8", @@ -91,6 +92,7 @@ test.describe("APS renderer v2 protocol", () => { test("uses one port, reports ordered progress, and fails closed", async ({ page, + browserName, }) => { test.setTimeout(60_000); const rendererResponse = await page.request.get( @@ -615,7 +617,15 @@ window.startApsV2 = function(options) { })), ).toEqual({ allowed: "executed", unrelated: undefined }); - await start("creative-redirect", "creative-redirect-bid", { + // WebKit's Playwright interception backend cannot synthesize a redirect + // response. Exercise the same exact-origin CSP boundary there by asking the + // fictional runner to attempt the redirected final URL directly; Chromium + // and Firefox retain the end-to-end redirect case. + const redirectBidId = + browserName === "webkit" + ? "creative-cross-origin-bid" + : "creative-redirect-bid"; + await start("creative-redirect", redirectBidId, { creativeUrl: "https://creative.example/script-redirect.js", tagType: "script", }); @@ -675,29 +685,36 @@ window.startApsV2 = function(options) { await expect(frame).toHaveAttribute("width", String(width)); await expect(frame).toHaveAttribute("height", String(height)); } - for (const frame of [outerFrame, innerFrame, creativeFrame]) { - expect( - await frame.evaluate((element) => { - const value = element as HTMLElement; - const box = value.getBoundingClientRect(); - const computed = getComputedStyle(value); - return { - width: box.width, - height: box.height, - clientWidth: value.clientWidth, - clientHeight: value.clientHeight, - margin: computed.margin, - overflow: computed.overflow, - }; - }), - ).toEqual({ + for (const [frame, declaredOverflow] of [ + [outerFrame, "hidden"], + [innerFrame, ""], + [creativeFrame, "hidden"], + ] as const) { + const frameBox = await frame.evaluate((element) => { + const value = element as HTMLElement; + const box = value.getBoundingClientRect(); + const computed = getComputedStyle(value); + return { + width: box.width, + height: box.height, + clientWidth: value.clientWidth, + clientHeight: value.clientHeight, + margin: computed.margin, + declaredOverflow: value.style.overflow, + computedOverflow: computed.overflow, + }; + }); + expect(frameBox).toMatchObject({ width, height, clientWidth: width, clientHeight: height, margin: "0px", - overflow: "hidden", + declaredOverflow, }); + // Chromium normalizes overflow on replaced iframe elements to `clip`; + // Firefox/WebKit may preserve the declared `hidden` computed value. + expect(["clip", "hidden"]).toContain(frameBox.computedOverflow); } for (const frame of [outer, inner, creative]) { expect( 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 0db0377f4..d3035fd43 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 @@ -44,6 +44,7 @@ function creativeDocument( slots: [], bids: [], }, + integrations: { version: 1, entries: [] }, creative: { version: 1, enabled: true, @@ -60,8 +61,19 @@ function creativeDocument( ", + let transport = format!( + r#"{{"version":1,"boot":{{"abi":1,"releaseId":"{}","manifest":{},"auctionProjection":{},"integrations":{},"creative":{{"version":1,"enabled":{},"clickGuard":{},"renderGuard":{}}},"diagnostics":{{"version":1,"renderTraceOverlay":{},"gpt":{{"active":{}}}}}}},"integrity":{},"outline":{}}}"#, release_id(), manifest, projection, - integration_configs, + integration_configs_json, config.creative.enabled, config.creative.click_guard, config.creative.render_guard, config.render_trace_overlay, config.gpt_diagnostics_active, + integrity, outline, - bootstrap, + ); + if transport.len() > SERVER_BOOT_TRANSPORT_MAX_BYTES_V1 { + return Err(boot_manifest_error( + "server boot transport exceeds 10 MiB UTF-8", + )); + } + let transport_literal = serde_json::to_string(&transport) + .map_err(|_| boot_manifest_error("server boot transport serialization failed"))?; + let transport_literal = escape_json_for_inline_script(&transport_literal); + let bootstrap = trusted_server_js::bootstrap_bundle(); + if bootstrap.to_ascii_lowercase().contains("const __TSJS_SERVER_BOOT_TRANSPORT_V1__={transport_literal};{bootstrap}" ); let selected_src = agent .as_ref() @@ -989,10 +1249,34 @@ pub fn tsjs_deferred_script_tags(module_ids: &[&str]) -> String { #[cfg(test)] mod tests { use super::*; + use crate::test_support::tests::bootstrap_transport; const VALID_BROWSER_AUCTION_PROJECTION_JSON: &str = r#"{"version":1,"auction":{"version":1,"auctionId":"initial","results":[]},"slots":[],"bids":[]}"#; const PERFORMANCE_ORIGIN: &str = "https://performance.example"; + #[test] + fn ecmascript_json_corpus_matches_the_browser_stringify_contract() { + let corpus: serde_json::Value = serde_json::from_str(include_str!( + "../../trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json" + )) + .expect("shared ECMAScript JSON corpus should parse"); + for case in corpus + .as_array() + .expect("shared ECMAScript JSON corpus should be an array") + { + let name = case["name"].as_str().expect("case should have a name"); + let input = case["input"].as_str().expect("case should have input"); + let expected = case["expected"] + .as_str() + .expect("case should have expected bytes"); + assert_eq!( + ecmascript_json_stringify_v1(input).as_deref(), + Some(expected), + "shared case should match: {name}" + ); + } + } + #[test] fn integration_configs_serialize_once_in_canonical_product_order() { let configs = IntegrationConfigsV1::new(vec![ @@ -1161,8 +1445,27 @@ mod tests { PERFORMANCE_ORIGIN, ) .expect("matching carrier and manifest should serialize"); + let transport = bootstrap_transport(&script); + assert_eq!( + transport + .as_object() + .map(|record| record.keys().cloned().collect::>()), + Some(vec![ + "boot".to_owned(), + "integrity".to_owned(), + "outline".to_owned(), + "version".to_owned(), + ]), + "production transport must have one exact four-key root" + ); + assert!(transport["integrity"]["projectionDigest"].is_string()); + assert!(transport["integrity"]["integrationConfigDigest"].is_string()); + assert!(transport["outline"].is_null()); + assert!(!script.contains("__TSJS_SERVER_BOOT_INPUT_V1__")); + assert!(!script.contains(r#""target""#)); assert!( - script.contains(r#""integrations":{"version":1,"entries":[{"id":"aps","config":{}}]}"#), + transport["boot"]["integrations"] + == serde_json::json!({"version":1,"entries":[{"id":"aps","config":{}}]}), "boot must contain the sole typed integration carrier: {script}" ); @@ -1293,9 +1596,15 @@ mod tests { .expect("first-display bootstrap should serialize"); assert!( - script.contains(&format!(r#""integrationConfigDigest":"{expected_digest}""#)), + bootstrap_transport(&script)["integrity"]["integrationConfigDigest"].as_str() + == Some(expected_digest.as_str()), "outline must bind the exact canonical carrier: {script}" ); + assert_eq!( + bootstrap_transport(&script)["outline"]["integrationConfigDigest"], + expected_digest, + "outline must copy the always-present integrity value" + ); } #[test] @@ -1329,7 +1638,8 @@ mod tests { .expect("attribution-only first-display bootstrap should serialize"); assert!( - script.contains(r#""slices":["first_display","gpt_initial"]"#), + bootstrap_transport(&script)["boot"]["manifest"]["firstDisplay"]["slices"] + == serde_json::json!(["first_display", "gpt_initial"]), "typed GAM attribution must select the GPT parser-time owner: {script}" ); assert!( @@ -1404,11 +1714,12 @@ mod tests { ) .expect("should serialize the production bootstrap"); - assert!( - controller.contains(&format!( - r#""runtimeSrc":"/static/tsjs=tsjs-unified.min.js?v={}""#, + assert_eq!( + bootstrap_transport(&controller)["boot"]["manifest"]["runtimeSrc"], + format!( + "/static/tsjs=tsjs-unified.min.js?v={}", concatenated_hash(&["render_runtime", "gpt"]) - )), + ), "should carry the exact runtime artifact source in BootManifestV1" ); assert!( @@ -1592,25 +1903,32 @@ mod tests { .expect("the default creative integration should be enabled"); let expected_src = tsjs_script_src(&["render_runtime", "creative"]); - assert!( - bootstrap.contains("__TSJS_SERVER_BOOT_INPUT_V1__"), + assert!(bootstrap.contains("__TSJS_SERVER_BOOT_TRANSPORT_V1__")); + assert!(!bootstrap.contains("__TSJS_SERVER_BOOT_INPUT_V1__")); + let transport = bootstrap_transport(&bootstrap); + assert_eq!( + transport["boot"]["manifest"]["integrations"], + serde_json::json!([ + {"id":"render_runtime","phase":"takeover"}, + {"id":"creative","phase":"takeover"} + ]), "{bootstrap}" ); assert!( - bootstrap.contains(r#"{"id":"render_runtime","phase":"takeover"}"#), + bootstrap.contains(&format!(r#"src="{expected_src}""#)), "{bootstrap}" ); + assert_eq!(bootstrap.matches("id=\"trustedserver-js\"").count(), 1); assert!( - bootstrap.contains(r#"{"id":"creative","phase":"takeover"}"#), - "{bootstrap}" + transport["boot"]["manifest"]["integrations"] + .as_array() + .is_some_and(|entries| entries.iter().all(|entry| entry["id"] != "gpt")) ); assert!( - bootstrap.contains(&format!(r#"src="{expected_src}""#)), - "{bootstrap}" + transport["boot"]["manifest"]["integrations"] + .as_array() + .is_some_and(|entries| entries.iter().all(|entry| entry["phase"] != "deferred")) ); - assert_eq!(bootstrap.matches("id=\"trustedserver-js\"").count(), 1); - assert!(!bootstrap.contains(r#""id":"gpt""#), "{bootstrap}"); - assert!(!bootstrap.contains(r#""phase":"deferred""#), "{bootstrap}"); } #[test] diff --git a/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts b/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts index 81b4c2dd0..b7ebc8ecc 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts @@ -33,6 +33,39 @@ export interface RuntimeTsjsFixture { }; } +interface ServerBootDigestInputV1 { + readonly auctionProjection: unknown; + readonly integrations: unknown; + readonly [key: string]: unknown; +} + +/** Serialize the exact string-only server boot carrier consumed by bootstrap. */ +export function serverBootTransportLiteralV1( + boot: ServerBootDigestInputV1, + outline: unknown = null, +): string { + const projection = JSON.stringify(boot.auctionProjection); + const integrationConfigs = JSON.stringify(boot.integrations); + if (projection === undefined || integrationConfigs === undefined) { + throw new Error("TSJS server boot digest input is not JSON"); + } + const transport = JSON.stringify({ + version: 1, + boot, + integrity: { + version: 1, + projectionDigest: createHash("sha256") + .update(projection, "utf8") + .digest("hex"), + integrationConfigDigest: createHash("sha256") + .update(integrationConfigs, "utf8") + .digest("hex"), + }, + outline, + }); + return JSON.stringify(transport); +} + function exactArtifact(release: Release, id: string): ReleaseArtifact { const matches = release.artifacts.filter((artifact) => artifact.id === id); if (matches.length !== 1) @@ -102,37 +135,18 @@ export async function loadRuntimeTsjsFixture( fixture: RuntimeTsjsFixture, ): Promise { await routeRuntimeTsjsFixture(page, fixture); + const boot = await page.evaluate( + () => (window as unknown as { tsjs: Record }).tsjs.boot, + ); + if (typeof boot !== "object" || boot === null || Array.isArray(boot)) { + throw new Error("TSJS runtime fixture boot is unavailable"); + } + await page.addScriptTag({ + content: `const __TSJS_SERVER_BOOT_TRANSPORT_V1__=${serverBootTransportLiteralV1(boot as ServerBootDigestInputV1)};${fixture.bootstrapBody}`, + }); await page.evaluate( (src) => new Promise((resolveLoad, rejectLoad) => { - const target = (window as unknown as { tsjs: Record }) - .tsjs; - const freeze = (value: unknown): void => { - if ( - typeof value !== "object" || - value === null || - Object.isFrozen(value) - ) - return; - for (const key of Reflect.ownKeys(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor && "value" in descriptor) freeze(descriptor.value); - } - Object.freeze(value); - }; - const retainedBoot = target.boot; - freeze(retainedBoot); - const claim = (source: unknown): unknown => { - if (source !== document.currentScript) return undefined; - Reflect.deleteProperty(target, "_claimBootSnapshot"); - return retainedBoot; - }; - Object.defineProperty(target, "_claimBootSnapshot", { - configurable: true, - enumerable: false, - value: claim, - writable: false, - }); const script = document.createElement("script"); script.id = "trustedserver-js"; script.src = src; 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 d3035fd43..6081a6aeb 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 @@ -3,6 +3,7 @@ import { runtimeUrl } from "../../helpers/state.js"; import { routeRuntimeTsjsFixture, runtimeTsjsFixture, + serverBootTransportLiteralV1, type RuntimeTsjsFixture, } from "../../helpers/tsjs-fixture.js"; @@ -32,9 +33,7 @@ function creativeDocument( fixture: RuntimeTsjsFixture, signedClick: string, ): string { - const signedClick = - "/first-party/click?tsurl=https%3A%2F%2Fadvertiser.example%2Flanding&foo=1&tstoken=browser-test-token"; - const boot = JSON.stringify({ + const transport = serverBootTransportLiteralV1({ abi: 1, releaseId: fixture.releaseId, manifest: fixture.manifest, @@ -68,11 +67,7 @@ function creativeDocument( enumerable: false, }); window.tsjs = { que: [] }; - const __TSJS_SERVER_BOOT_INPUT_V1__ = { - target: window.tsjs, - boot: ${boot}, - outline: null, - }; + const __TSJS_SERVER_BOOT_TRANSPORT_V1__ = ${transport}; ${fixture.bootstrapBody} `, + body: ``, }), ); @@ -205,37 +206,31 @@ test.describe("TSJS hard-cutover runtime", () => { page, }) => { await openRuntimePage(page); - await page.evaluate( - ({ initialBoot, releaseId }) => { - 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, manifest: { version: 2, releaseId } }, - que: [], - }; - }, - { - initialBoot: boot(FALLBACK_FIXTURE), - releaseId: FALLBACK_FIXTURE.releaseId, - }, - ); + 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: [], + }; + }, boot(FALLBACK_FIXTURE)); await loadRuntimeTsjsFixture(page, FALLBACK_FIXTURE); await waitForRuntime(page, "fallback"); @@ -302,7 +297,7 @@ test.describe("TSJS hard-cutover runtime", () => { ); expect(after.effects.timeouts).toBe(before.effects.timeouts); expect(after.frames).toBe(0); - expect(after.scripts).toBe(2); + expect(after.scripts).toBe(3); expect(after.hasGoogletag).toBe(false); }); }); diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs index 188939fba..758542484 100644 --- a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -107,9 +107,11 @@ const CURRENT_PROVIDER_SOURCE_OWNERS = Object.freeze({ const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ 'src/core/adapters/gam_attribution.ts': 'bootstrap', 'src/core/bootstrap.ts': 'bootstrap', - 'src/core/contracts/boot.ts': 'bootstrap', - 'src/core/contracts/integration_configs.ts': 'bootstrap', - 'src/core/types.ts': 'bootstrap', + 'src/core/contracts/server_boot_transport.ts': 'bootstrap', + 'src/core/contracts/boot.ts': 'core', + 'src/core/contracts/integration_configs.ts': 'core', + 'src/core/contracts/sha256.ts': 'core', + 'src/core/types.ts': 'core', 'src/adapters/googletag.ts': 'gpt', 'src/adapters/messaging.ts': 'core', 'src/composition/runtime_transport.ts': 'core', @@ -180,12 +182,13 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/auction.ts': Object.freeze(['core']), 'src/core/config.ts': Object.freeze(['bootstrap', 'core']), 'src/core/contracts/aps_renderer.ts': Object.freeze(['bootstrap', 'core', 'aps']), - 'src/core/contracts/auction_projection.ts': Object.freeze([ + 'src/core/contracts/bounded_string.ts': Object.freeze([ 'bootstrap', 'core', 'prebid', 'prebid_later', ]), + 'src/core/contracts/auction_projection.ts': Object.freeze(['core', 'prebid', 'prebid_later']), 'src/core/contracts/generated/renderer_validator_v1.ts': Object.freeze([ 'bootstrap', 'core', @@ -263,6 +266,7 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/templates/iframe.html?raw': Object.freeze(['core']), 'src/core/trace.ts': Object.freeze(['core', 'gpt_diagnostics']), 'src/kernel/contracts/message_protocol.ts': Object.freeze(['core', 'gpt']), + 'src/kernel/contracts/release_capacity.ts': Object.freeze(['core']), 'src/kernel/contracts/puc_dynamic_owner.ts': Object.freeze(['aps_initial', 'gpt']), 'src/kernel/diagnostics.ts': Object.freeze(['core']), 'src/kernel/disposable.ts': Object.freeze(['core', 'gpt']), diff --git a/crates/trusted-server-js/lib/src/core/bootstrap.ts b/crates/trusted-server-js/lib/src/core/bootstrap.ts index c8eef9683..acf383b25 100644 --- a/crates/trusted-server-js/lib/src/core/bootstrap.ts +++ b/crates/trusted-server-js/lib/src/core/bootstrap.ts @@ -1,4 +1,4 @@ -declare const __TSJS_SERVER_BOOT_INPUT_V1__: unknown; +declare const __TSJS_SERVER_BOOT_TRANSPORT_V1__: unknown; import type { FirstDisplayAgent, @@ -10,8 +10,12 @@ import type { FirstDisplaySliceId } from '../kernel/release_catalog'; import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; import { enqueueFirstDisplayGamAttribution } from './adapters/gam_attribution'; -import { snapshotBootstrapInputV1, type BootstrapInputSnapshotV1 } from './contracts/boot'; -import { integrationConfigValueV1 } from './contracts/integration_configs'; +import { validateRequestAdsOptions } from './contracts/request_ads'; +import { + snapshotServerBootTransportV1, + type ServerBootTransportSnapshotV1, +} from './contracts/server_boot_transport'; +import { validateProgrammaticAdUnits } from './registry'; import { EMBEDDED_RELEASE_ID } from './release_id'; const RELEASE = EMBEDDED_RELEASE_ID; @@ -21,7 +25,14 @@ interface ComponentRegistration { readonly prepare: (host: unknown) => unknown; } +type BootstrapTarget = object & { boot?: unknown; que?: unknown }; + +interface BootstrapInputSnapshotV1 extends ServerBootTransportSnapshotV1 { + readonly target: BootstrapTarget; +} + type BootFailureReason = 'abi_mismatch' | 'bundle_partial'; +type BootClaimOutcome = 'kernel' | BootFailureReason; class RuntimeUnavailableError extends Error { public readonly code = 'runtime_unavailable'; @@ -44,7 +55,7 @@ function deepFreeze(value: unknown): void { Object.freeze(value); } -function prepareIngress(target: BootstrapInputSnapshotV1['target']): unknown[] { +function prepareIngress(target: BootstrapTarget): unknown[] { const descriptor = Object.getOwnPropertyDescriptor(target, 'que'); const previous = descriptor && 'value' in descriptor && Array.isArray(descriptor.value) ? descriptor.value : []; @@ -78,12 +89,18 @@ function fallbackFields( runtimeSrc: boot.manifest.runtimeSrc, integrations: [], }, - auctionProjection: boot.auctionProjection, + auctionProjection: { + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }, integrations: { version: 1, entries: [] }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, }; deepFreeze(safeBoot); + const knownSlots = new Set(); let level = 'warn'; const levels = ['silent', 'error', 'warn', 'info', 'debug']; const observe = (..._values: readonly unknown[]): void => undefined; @@ -103,11 +120,22 @@ function fallbackFields( debug: observe, }), _registerIntegration: () => false, - addAdUnits: (_units: unknown): never => { + addAdUnits: (units: unknown): never => { + validateProgrammaticAdUnits(units, knownSlots); throw new RuntimeUnavailableError(RELEASE, reason); }, - requestAds: async (): Promise => { - throw new RuntimeUnavailableError(RELEASE, reason); + requestAds: async (options?: unknown): Promise => { + const validated = validateRequestAdsOptions(options); + const selected = validated.slots ?? []; + const result = { + slots: selected.map((slot) => + validated.aborted + ? { slot, path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' } + : { slot, path: 'primary', outcome: 'failed', reason: 'slot_unresolved' } + ), + }; + deepFreeze(result); + return result; }, }; Object.defineProperty(fields, '_internal', { @@ -170,7 +198,7 @@ function publishFallback( } } -function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): void { +function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSnapshotV1): void { const ingress = prepareIngress(target); const firstDisplay = boot.manifest.firstDisplay; const trustedOrigin = (): string | undefined => { @@ -219,7 +247,9 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): return false; } }; - let bootClaimed = false; + let bootClaimState: 'available' | 'claiming' | 'claimed' = 'available'; + let bootClaimCompleted = false; + let completeClaimedBoot: ((outcome: BootClaimOutcome) => void) | undefined; const removeBootClaim = (): void => { try { const descriptor = Object.getOwnPropertyDescriptor(target, '_claimBootSnapshot'); @@ -230,19 +260,43 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): // The closure-retained snapshot remains authoritative after hostile replacement. } }; - const claimBoot = (source: unknown): Readonly | undefined => { - const expectedId = firstDisplay === null ? 'trustedserver-js' : 'trustedserver-js-runtime'; + const completeBootClaim = (outcome: BootClaimOutcome): void => { + const acceptedOutcome = + outcome === 'kernel' || outcome === 'abi_mismatch' || outcome === 'bundle_partial'; if ( - bootClaimed || - !(source instanceof HTMLScriptElement) || - document.currentScript !== source || - !authentic(source, boot.manifest.runtimeSrc, expectedId) + bootClaimState !== 'claimed' || + bootClaimCompleted || + !completeClaimedBoot || + !acceptedOutcome ) { + return; + } + bootClaimCompleted = true; + const complete = completeClaimedBoot; + completeClaimedBoot = undefined; + complete(outcome); + }; + const claimedBoot = Object.freeze({ boot, integrity, complete: completeBootClaim }); + const claimBoot = (source: unknown): Readonly | undefined => { + if (bootClaimState !== 'available') return undefined; + bootClaimState = 'claiming'; + let accepted = false; + try { + const expectedId = firstDisplay === null ? 'trustedserver-js' : 'trustedserver-js-runtime'; + accepted = + source instanceof HTMLScriptElement && + document.currentScript === source && + authentic(source, boot.manifest.runtimeSrc, expectedId); + } catch { + // The claim stays reserved only until this authentication attempt fails. + } + if (!accepted) { + bootClaimState = 'available'; return undefined; } - bootClaimed = true; + bootClaimState = 'claimed'; removeBootClaim(); - return boot; + return claimedBoot; }; const installBootClaim = (): void => { Object.defineProperty(target, '_claimBootSnapshot', { @@ -264,7 +318,7 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): // A hostile replacement cannot make the captured claim callable again. } }; - const fallback = (): void => { + const fallback = (reason: BootFailureReason = 'bundle_partial'): void => { if (terminal) return; terminal = true; if (timer !== undefined) window.clearTimeout(timer); @@ -275,7 +329,10 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): } removeClaim(); removeBootClaim(); - publishFallback(target, ingress, fallbackFields(boot, 'bundle_partial', false)); + publishFallback(target, ingress, fallbackFields(boot, reason, false)); + }; + completeClaimedBoot = (outcome) => { + if (outcome === 'abi_mismatch') fallback(outcome); }; const claim = ( source: unknown, @@ -383,6 +440,9 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): disposeAgent(); publishFallback(target, ingress, fallbackFields(boot, reason, initialDisplayCommitted)); }; + completeClaimedBoot = (outcome) => { + if (outcome !== 'kernel') commitFallback(outcome); + }; const now = (): number => performance.now(); const startedAtMs = now(); const controller: FirstDisplayBootstrapController = Object.freeze({ @@ -529,27 +589,15 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): }; const binding = (id: string): unknown => { const product = id.endsWith('_initial') ? id.slice(0, -'_initial'.length) : ''; - const config = - id === 'creative_initial' - ? boot.creative - : [ - 'aps', - 'datadome', - 'didomi', - 'google_tag_manager', - 'gpt', - 'lockr', - 'osano', - 'permutive', - 'prebid', - 'sourcepoint', - 'testlight', - ].includes(product) - ? integrationConfigValueV1( - boot.integrations, - product as Parameters[1] - ) - : undefined; + let config: unknown = id === 'creative_initial' ? boot.creative : undefined; + if (config === undefined && product !== '') { + for (const entry of boot.integrations.entries) { + if (entry.id === product) { + config = entry.config; + break; + } + } + } return Object.freeze({ bindings: rawBinding(id), config }); }; const host: FirstDisplayAgentRegistrationHostV1 = Object.freeze({ @@ -689,8 +737,27 @@ function installBootstrap({ target, boot, outline }: BootstrapInputSnapshotV1): } } -const input = snapshotBootstrapInputV1( - typeof __TSJS_SERVER_BOOT_INPUT_V1__ === 'undefined' ? undefined : __TSJS_SERVER_BOOT_INPUT_V1__, +function bootstrapTarget(): BootstrapTarget | undefined { + try { + const namespace = window as unknown as { tsjs?: unknown }; + const current = namespace.tsjs; + if ((typeof current === 'object' || typeof current === 'function') && current !== null) { + return current as BootstrapTarget; + } + if (current) return undefined; + const target: BootstrapTarget = {}; + namespace.tsjs = target; + return target; + } catch { + return undefined; + } +} + +const transport = snapshotServerBootTransportV1( + typeof __TSJS_SERVER_BOOT_TRANSPORT_V1__ === 'undefined' + ? undefined + : __TSJS_SERVER_BOOT_TRANSPORT_V1__, RELEASE ); -if (input) installBootstrap(input); +const target = transport && bootstrapTarget(); +if (transport && target) installBootstrap(Object.freeze({ ...transport, target })); 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 372225f34..5a8676529 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 @@ -11,6 +11,9 @@ import type { } from '../types'; import { validateApsRenderer } from './aps_renderer'; +import { validBoundedString } from './bounded_string'; + +export { validBoundedString }; export const MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024; @@ -22,8 +25,6 @@ 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; const candidateIdPattern = /^[A-Za-z0-9_-]{12}$/; const reservationIdPattern = /^r1_[A-Za-z0-9_-]{22}$/; @@ -103,46 +104,6 @@ export function ownDataArray(value: unknown, maximum: number): unknown[] | undef } } -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 undefined; - index += 1; - } else if (code >= 0xdc00 && code <= 0xdfff) { - return undefined; - } - scalars += 1; - } - return scalars; -} - -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; -} - -export function validBoundedString( - value: unknown, - maximumBytes: number, - options: { allowControls?: boolean; maximumScalars?: number } = {} -): value is string { - if (typeof value !== 'string' || value.length === 0) return false; - const scalarCount = unicodeScalarCount(value); - return ( - scalarCount !== undefined && - (options.allowControls === true || !hasAsciiControl(value)) && - (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; } diff --git a/crates/trusted-server-js/lib/src/core/contracts/boot.ts b/crates/trusted-server-js/lib/src/core/contracts/boot.ts index f88eb261b..ba67e4e6d 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/boot.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/boot.ts @@ -318,23 +318,33 @@ export function snapshotTsjsBootV1(candidate: unknown, releaseId: string): TsjsB } } -/** Revalidate a bootstrap snapshot while retaining its exact object identity. */ -export function retainTsjsBootSnapshotV1( +/** Revalidate one recursively frozen boot and return the independent accepted snapshot. */ +export function snapshotFrozenTsjsBootV1( candidate: unknown, releaseId: string -): Readonly | undefined { +): TsjsBootV1 | undefined { try { const accepted = snapshotTsjsBootV1(candidate, releaseId); return accepted && recursivelyFrozenPlainData(candidate) && JSON.stringify(candidate) === JSON.stringify(accepted) - ? (candidate as Readonly) + ? accepted : undefined; } catch { return undefined; } } +/** Revalidate a bootstrap snapshot while retaining its exact object identity. */ +export function retainTsjsBootSnapshotV1( + candidate: unknown, + releaseId: string +): Readonly | undefined { + return snapshotFrozenTsjsBootV1(candidate, releaseId) + ? (candidate as Readonly) + : undefined; +} + /** Capture the exact server lexical, including the one retained immutable boot copy. */ export function snapshotBootstrapInputV1( candidate: unknown, diff --git a/crates/trusted-server-js/lib/src/core/contracts/bounded_string.ts b/crates/trusted-server-js/lib/src/core/contracts/bounded_string.ts new file mode 100644 index 000000000..ef89f69e5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/bounded_string.ts @@ -0,0 +1,44 @@ +const reflectApplyIntrinsic = Reflect.apply; +const textEncoder = new TextEncoder(); +const textEncoderEncodeIntrinsic = TextEncoder.prototype.encode; + +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 undefined; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return undefined; + } + scalars += 1; + } + return scalars; +} + +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 a nonempty, well-formed UTF-16 string against byte and scalar bounds. */ +export function validBoundedString( + value: unknown, + maximumBytes: number, + options: { allowControls?: boolean; maximumScalars?: number } = {} +): value is string { + if (typeof value !== 'string' || value.length === 0) return false; + const scalarCount = unicodeScalarCount(value); + return ( + scalarCount !== undefined && + (options.allowControls === true || !hasAsciiControl(value)) && + (reflectApplyIntrinsic(textEncoderEncodeIntrinsic, textEncoder, [value]) as Uint8Array) + .length <= maximumBytes && + (options.maximumScalars === undefined || scalarCount <= options.maximumScalars) + ); +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts b/crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts index 353bc7301..692e33d27 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts @@ -6,24 +6,15 @@ import { type IntegrationConfigsV1, } from '../types'; +import { sha256HexUtf8V1 } from './sha256'; + +export { sha256HexUtf8V1 } from './sha256'; + const MAX_DEPTH = 16; const MAX_VALUES = 4_096; const MAX_STRING_BYTES = 4_096; const MAX_ENTRY_BYTES = 65_536; const MAX_CARRIER_BYTES = 524_288; -const SHA256_INITIAL = Object.freeze([ - 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, -]); -const SHA256_ROUNDS = Object.freeze([ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, -]); const CONFIG_ORDER = new Map(INTEGRATION_CONFIG_IDS_V1.map((id, index) => [id, index] as const)); @@ -31,65 +22,6 @@ function utf8Length(value: string): number { return new TextEncoder().encode(value).byteLength; } -function rotateRight(value: number, count: number): number { - return (value >>> count) | (value << (32 - count)); -} - -/** Calculate SHA-256 synchronously so parser-time boot validation remains effect-inert. */ -export function sha256HexUtf8V1(value: string): string { - const source = new TextEncoder().encode(value); - const paddedLength = Math.ceil((source.length + 9) / 64) * 64; - const padded = new Uint8Array(paddedLength); - padded.set(source); - padded[source.length] = 0x80; - const bitLength = source.length * 8; - const view = new DataView(padded.buffer); - view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x1_0000_0000)); - view.setUint32(paddedLength - 4, bitLength >>> 0); - - const hash = [...SHA256_INITIAL]; - const words = new Uint32Array(64); - for (let offset = 0; offset < paddedLength; offset += 64) { - for (let index = 0; index < 16; index += 1) { - words[index] = view.getUint32(offset + index * 4); - } - for (let index = 16; index < 64; index += 1) { - const left = words[index - 15]!; - const right = words[index - 2]!; - const sigma0 = rotateRight(left, 7) ^ rotateRight(left, 18) ^ (left >>> 3); - const sigma1 = rotateRight(right, 17) ^ rotateRight(right, 19) ^ (right >>> 10); - words[index] = (words[index - 16]! + sigma0 + words[index - 7]! + sigma1) >>> 0; - } - - let [a, b, c, d, e, f, g, h] = hash; - for (let index = 0; index < 64; index += 1) { - const sum1 = rotateRight(e!, 6) ^ rotateRight(e!, 11) ^ rotateRight(e!, 25); - const choose = (e! & f!) ^ (~e! & g!); - const temporary1 = (h! + sum1 + choose + SHA256_ROUNDS[index]! + words[index]!) >>> 0; - const sum0 = rotateRight(a!, 2) ^ rotateRight(a!, 13) ^ rotateRight(a!, 22); - const majority = (a! & b!) ^ (a! & c!) ^ (b! & c!); - const temporary2 = (sum0 + majority) >>> 0; - h = g; - g = f; - f = e; - e = (d! + temporary1) >>> 0; - d = c; - c = b; - b = a; - a = (temporary1 + temporary2) >>> 0; - } - hash[0] = (hash[0]! + a!) >>> 0; - hash[1] = (hash[1]! + b!) >>> 0; - hash[2] = (hash[2]! + c!) >>> 0; - hash[3] = (hash[3]! + d!) >>> 0; - hash[4] = (hash[4]! + e!) >>> 0; - hash[5] = (hash[5]! + f!) >>> 0; - hash[6] = (hash[6]! + g!) >>> 0; - hash[7] = (hash[7]! + h!) >>> 0; - } - return hash.map((word) => word.toString(16).padStart(8, '0')).join(''); -} - function ownPlainDataRecord(value: unknown): Record | undefined { if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; diff --git a/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts new file mode 100644 index 000000000..49917c005 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts @@ -0,0 +1,325 @@ +import type { TakeoverOutlineV1 } from '../../shared/first_display_contracts'; +import type { BootManifestV1, TsjsBootV1 } from '../types'; + +const MAX_TRANSPORT_BYTES = 10 * 1024 * 1024; +const MAX_TAKEOVER_INTEGRATIONS = 14; +const RELEASE_ID = /^[0-9a-f]{64}$/; +const HASH = /^[0-9a-f]{64}$/; +const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const FIRST_DISPLAY_SRC = + /^\/static\/tsjs=tsjs-first-display\.min\.js\?m=([0-9a-f]{4})&v=[0-9a-f]{64}$/; +const RUNTIME_SRC = /^\/static\/tsjs=tsjs-unified\.min\.js\?v=[0-9a-f]{64}$/; +const DEFERRED_SRC = /^\/static\/tsjs=tsjs-([a-z0-9][a-z0-9_-]{0,63})\.min\.js\?v=[0-9a-f]{64}$/; +const FIRST_DISPLAY_IDS = Object.freeze([ + 'first_display', + 'aps_initial', + 'creative_initial', + 'datadome_initial', + 'didomi_initial', + 'google_tag_manager_initial', + 'gpt_initial', + 'lockr_initial', + 'osano_initial', + 'permutive_initial', + 'sourcepoint_initial', + 'prebid_initial', + 'testlight_initial', +] as const); +const CONFIG_IDS = Object.freeze([ + 'aps', + 'datadome', + 'didomi', + 'google_tag_manager', + 'gpt', + 'lockr', + 'osano', + 'permutive', + 'prebid', + 'sourcepoint', + 'testlight', +] as const); + +export interface ServerBootIntegrityV1 { + readonly version: 1; + readonly projectionDigest: string; + readonly integrationConfigDigest: string; +} + +export interface ServerBootTransportSnapshotV1 { + readonly version: 1; + readonly boot: Readonly; + readonly integrity: Readonly; + readonly outline: TakeoverOutlineV1 | null; +} + +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function exact(value: unknown, keys: readonly string[]): Record | undefined { + const candidate = record(value); + if (!candidate || Object.getPrototypeOf(candidate) !== Object.prototype) return undefined; + const actual = Object.keys(candidate); + return actual.length === keys.length && actual.every((key) => keys.includes(key)) + ? candidate + : undefined; +} + +function deepFreeze(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return; + for (const key of Object.keys(value)) deepFreeze((value as Record)[key]); + Object.freeze(value); +} + +function validManifest(candidate: unknown, releaseId: string): BootManifestV1 | undefined { + const manifest = exact(candidate, [ + 'version', + 'releaseId', + 'firstDisplay', + 'runtimeSrc', + 'integrations', + ]); + if ( + !manifest || + manifest.version !== 1 || + manifest.releaseId !== releaseId || + typeof manifest.runtimeSrc !== 'string' || + !RUNTIME_SRC.test(manifest.runtimeSrc) || + !Array.isArray(manifest.integrations) || + manifest.integrations.length > 20 + ) { + return undefined; + } + const seen = new Set(); + let deferred = false; + let takeoverCount = 0; + for (const value of manifest.integrations) { + const integration = record(value); + if ( + !integration || + typeof integration.id !== 'string' || + !INTEGRATION_ID.test(integration.id) || + seen.has(integration.id) + ) { + return undefined; + } + seen.add(integration.id); + if (integration.phase === 'takeover') { + takeoverCount += 1; + if ( + deferred || + takeoverCount > MAX_TAKEOVER_INTEGRATIONS || + !exact(integration, ['id', 'phase']) + ) { + return undefined; + } + continue; + } + const deferredSource = + typeof integration.src === 'string' ? DEFERRED_SRC.exec(integration.src) : null; + if ( + integration.phase !== 'deferred' || + !exact(integration, ['id', 'phase', 'trigger', 'src']) || + integration.trigger !== 'first_display_or_idle' || + deferredSource?.[1] !== integration.id + ) { + return undefined; + } + deferred = true; + } + if (manifest.firstDisplay === null) return manifest as unknown as BootManifestV1; + const firstDisplay = exact(manifest.firstDisplay, ['src', 'slices']); + const slices = firstDisplay?.slices; + if ( + !firstDisplay || + typeof firstDisplay.src !== 'string' || + !Array.isArray(slices) || + slices.length === 0 || + slices.length > FIRST_DISPLAY_IDS.length + ) { + return undefined; + } + const match = FIRST_DISPLAY_SRC.exec(firstDisplay.src); + if (!match) return undefined; + const mask = Number.parseInt(match[1]!, 16); + const selected = FIRST_DISPLAY_IDS.filter((_id, index) => (mask & (1 << index)) !== 0); + if ( + (mask & 1) === 0 || + mask >>> FIRST_DISPLAY_IDS.length !== 0 || + selected.length !== slices.length || + selected.some((id, index) => slices[index] !== id) + ) { + return undefined; + } + return manifest as unknown as BootManifestV1; +} + +function validBoot(candidate: unknown, releaseId: string): Readonly | undefined { + const boot = exact(candidate, [ + 'abi', + 'releaseId', + 'manifest', + 'auctionProjection', + 'integrations', + 'creative', + 'diagnostics', + ]); + if (!boot || boot.abi !== 1 || boot.releaseId !== releaseId) return undefined; + if (!validManifest(boot.manifest, releaseId)) return undefined; + + const projection = exact(boot.auctionProjection, ['version', 'auction', 'slots', 'bids']); + const auction = exact(projection?.auction, ['version', 'auctionId', 'results']); + if ( + !projection || + projection.version !== 1 || + !auction || + auction.version !== 1 || + typeof auction.auctionId !== 'string' || + !Array.isArray(auction.results) || + auction.results.length > 256 || + !Array.isArray(projection.slots) || + projection.slots.length > 256 || + !Array.isArray(projection.bids) + ) { + return undefined; + } + + const integrations = exact(boot.integrations, ['version', 'entries']); + if ( + !integrations || + integrations.version !== 1 || + !Array.isArray(integrations.entries) || + integrations.entries.length > CONFIG_IDS.length + ) { + return undefined; + } + let previous = -1; + for (const value of integrations.entries) { + const entry = exact(value, ['id', 'config']); + const order = + entry && typeof entry.id === 'string' + ? CONFIG_IDS.indexOf(entry.id as (typeof CONFIG_IDS)[number]) + : -1; + if (!entry || order <= previous || !record(entry.config)) return undefined; + previous = order; + } + + const creative = exact(boot.creative, ['version', 'enabled', 'clickGuard', 'renderGuard']); + const diagnostics = exact(boot.diagnostics, ['version', 'renderTraceOverlay', 'gpt']); + const gpt = exact(diagnostics?.gpt, ['active']); + if ( + !creative || + creative.version !== 1 || + typeof creative.enabled !== 'boolean' || + typeof creative.clickGuard !== 'boolean' || + typeof creative.renderGuard !== 'boolean' || + (!creative.enabled && (creative.clickGuard || creative.renderGuard)) || + !diagnostics || + diagnostics.version !== 1 || + typeof diagnostics.renderTraceOverlay !== 'boolean' || + !gpt || + typeof gpt.active !== 'boolean' + ) { + return undefined; + } + return boot as unknown as Readonly; +} + +function validIntegrity(candidate: unknown): Readonly | undefined { + const integrity = exact(candidate, ['version', 'projectionDigest', 'integrationConfigDigest']); + return integrity && + integrity.version === 1 && + typeof integrity.projectionDigest === 'string' && + HASH.test(integrity.projectionDigest) && + typeof integrity.integrationConfigDigest === 'string' && + HASH.test(integrity.integrationConfigDigest) + ? (integrity as unknown as Readonly) + : undefined; +} + +function validOutline( + candidate: unknown, + releaseId: string, + integrity: Readonly, + boot: Readonly +): TakeoverOutlineV1 | null | undefined { + if (candidate === null) return boot.manifest.firstDisplay === null ? null : undefined; + const outline = exact(candidate, [ + 'version', + 'releaseId', + 'generation', + 'projectionDigest', + 'integrationConfigDigest', + 'slices', + 'slotCount', + 'outcomeCount', + 'capabilities', + 'objectKinds', + ]); + const firstDisplay = boot.manifest.firstDisplay; + const projection = boot.auctionProjection; + if ( + !outline || + !firstDisplay || + outline.version !== 1 || + outline.releaseId !== releaseId || + !Number.isInteger(outline.generation) || + (outline.generation as number) < 1 || + (outline.generation as number) > 4_294_967_295 || + outline.projectionDigest !== integrity.projectionDigest || + outline.integrationConfigDigest !== integrity.integrationConfigDigest || + !Array.isArray(outline.slices) || + outline.slices.length !== firstDisplay.slices.length || + outline.slices.some((id, index) => id !== firstDisplay.slices[index]) || + !Number.isInteger(outline.slotCount) || + outline.slotCount !== projection.slots.length || + (outline.slotCount as number) < 1 || + !Number.isInteger(outline.outcomeCount) || + outline.outcomeCount !== projection.auction.results.length || + outline.outcomeCount !== outline.slotCount || + !Array.isArray(outline.capabilities) || + outline.capabilities.length !== 0 || + !Array.isArray(outline.objectKinds) + ) { + return undefined; + } + const expectedKinds = projection.bids.length === 0 ? [] : ['gpt_slot', 'dom_artifact']; + if ( + outline.objectKinds.length !== expectedKinds.length || + outline.objectKinds.some((kind, index) => kind !== expectedKinds[index]) + ) { + return undefined; + } + return outline as unknown as TakeoverOutlineV1; +} + +/** Parse the one server-sealed lexical boot value without importing domain validators. */ +export function snapshotServerBootTransportV1( + payload: unknown, + releaseId: string +): ServerBootTransportSnapshotV1 | undefined { + try { + if ( + typeof payload !== 'string' || + typeof releaseId !== 'string' || + !RELEASE_ID.test(releaseId) || + payload.length > MAX_TRANSPORT_BYTES || + new TextEncoder().encode(payload).byteLength > MAX_TRANSPORT_BYTES + ) { + return undefined; + } + const root = exact(JSON.parse(payload), ['version', 'boot', 'integrity', 'outline']); + if (!root || root.version !== 1) return undefined; + const boot = validBoot(root.boot, releaseId); + const integrity = validIntegrity(root.integrity); + if (!boot || !integrity) return undefined; + const outline = validOutline(root.outline, releaseId, integrity, boot); + if (outline === undefined) return undefined; + deepFreeze(root); + return root as unknown as ServerBootTransportSnapshotV1; + } catch { + return undefined; + } +} diff --git a/crates/trusted-server-js/lib/src/core/contracts/sha256.ts b/crates/trusted-server-js/lib/src/core/contracts/sha256.ts new file mode 100644 index 000000000..49887c590 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/contracts/sha256.ts @@ -0,0 +1,75 @@ +const H: number[] = []; +const K: number[] = []; +const ENCODER = new TextEncoder(); +for (let n = 2; K.length < 64; n += 1) { + let prime = true; + for (let d = 2; d * d <= n; d += 1) { + if (n % d === 0) { + prime = false; + break; + } + } + if (!prime) continue; + if (H.length < 8) H.push((Math.sqrt(n) * 0x1_0000_0000) >>> 0); + K.push((Math.cbrt(n) * 0x1_0000_0000) >>> 0); +} + +function rotr(word: number, bits: number): number { + return (word >>> bits) | (word << (32 - bits)); +} + +/** Calculate SHA-256 synchronously so boot validation remains effect-inert. */ +export function sha256HexUtf8V1(text: string): string { + const bytes = ENCODER.encode(text); + const length = Math.ceil((bytes.length + 9) / 64) * 64; + const data = new Uint8Array(length); + data.set(bytes); + data[bytes.length] = 0x80; + const bits = bytes.length * 8; + const view = new DataView(data.buffer); + view.setUint32(length - 8, Math.floor(bits / 0x1_0000_0000)); + view.setUint32(length - 4, bits >>> 0); + const state = [...H]; + const w = new Uint32Array(64); + for (let offset = 0; offset < length; offset += 64) { + for (let i = 0; i < 16; i += 1) w[i] = view.getUint32(offset + i * 4); + for (let i = 16; i < 64; i += 1) { + const left = w[i - 15]!; + const right = w[i - 2]!; + const s0 = rotr(left, 7) ^ rotr(left, 18) ^ (left >>> 3); + const s1 = rotr(right, 17) ^ rotr(right, 19) ^ (right >>> 10); + w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0; + } + let [a, b, c, d, e, f, g, h] = state; + for (let i = 0; i < 64; i += 1) { + const s1 = rotr(e!, 6) ^ rotr(e!, 11) ^ rotr(e!, 25); + const ch = (e! & f!) ^ (~e! & g!); + const t1 = (h! + s1 + ch + K[i]! + w[i]!) >>> 0; + const s0 = rotr(a!, 2) ^ rotr(a!, 13) ^ rotr(a!, 22); + const maj = (a! & b!) ^ (a! & c!) ^ (b! & c!); + const t2 = (s0 + maj) >>> 0; + h = g; + g = f; + f = e; + e = (d! + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + state[0] = (state[0]! + a!) >>> 0; + state[1] = (state[1]! + b!) >>> 0; + state[2] = (state[2]! + c!) >>> 0; + state[3] = (state[3]! + d!) >>> 0; + state[4] = (state[4]! + e!) >>> 0; + state[5] = (state[5]! + f!) >>> 0; + state[6] = (state[6]! + g!) >>> 0; + state[7] = (state[7]! + h!) >>> 0; + } + return state.map((word) => word.toString(16).padStart(8, '0')).join(''); +} + +/** Measure UTF-8 with the same intrinsic used by the synchronous digest. */ +export function utf8LengthV1(text: string): number { + return ENCODER.encode(text).byteLength; +} diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 6dbce226d..1d38e5250 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -14,10 +14,15 @@ export type { export type { Runtime, RuntimeOptions, RuntimeState } from '../kernel/runtime'; import type { Runtime, RuntimeOptions } from '../kernel/runtime'; +import { validateRuntimeManifestV1 } from '../kernel/integration_registry'; import { consumeFirstDisplayTakeoverTransport } from '../shared/takeover'; -import type { TsjsBootV1 } from './types'; +import { ownDataObject } from './contracts/auction_projection'; +import { snapshotFrozenTsjsBootV1 } from './contracts/boot'; +import type { ServerBootIntegrityV1 } from './contracts/server_boot_transport'; +import { sha256HexUtf8V1 } from './contracts/sha256'; import { EMBEDDED_INTEGRATION_IDS, EMBEDDED_RELEASE_ID, EMBEDDED_RUNTIME_CATALOG } from './release'; +import type { TsjsBootV1 } from './types'; type BootstrapTarget = object & { boot?: unknown; @@ -26,6 +31,30 @@ type BootstrapTarget = object & { type DirectRuntimeCompletion = (outcome: 'kernel' | 'runtime_fallback' | 'failed_start') => void; +interface ClaimedServerBootV1 { + readonly boot: Readonly; + readonly integrity: Readonly; + readonly complete: (outcome: 'kernel' | 'abi_mismatch' | 'bundle_partial') => void; +} + +function retainServerBoot(candidate: unknown, releaseId: string): Readonly | undefined { + try { + const boot = snapshotFrozenTsjsBootV1(candidate, releaseId); + if (!boot) return undefined; + const manifest = validateRuntimeManifestV1( + boot.manifest, + releaseId, + EMBEDDED_RUNTIME_CATALOG, + true + ); + return manifest && JSON.stringify(boot.manifest) === JSON.stringify(manifest) + ? boot + : undefined; + } catch { + return undefined; + } +} + function directRuntimeClaim( target: BootstrapTarget ): ((source: unknown, cancel: unknown) => DirectRuntimeCompletion | undefined) | null | undefined { @@ -71,7 +100,7 @@ function bootstrapTarget(): BootstrapTarget | undefined { function bootSnapshotClaim( target: BootstrapTarget -): ((source: unknown) => Readonly | undefined) | null | undefined { +): ((source: unknown) => Readonly | undefined) | null | undefined { try { const descriptor = Object.getOwnPropertyDescriptor(target, '_claimBootSnapshot'); if (!descriptor) return undefined; @@ -84,20 +113,72 @@ function bootSnapshotClaim( ) { return null; } - return descriptor.value as (source: unknown) => Readonly | undefined; + return descriptor.value as (source: unknown) => Readonly | undefined; } catch { return null; } } +function retainClaimedServerBootV1(candidate: unknown): ClaimedServerBootV1 | undefined { + try { + const fields = ownDataObject(candidate, ['boot', 'integrity', 'complete']); + if (!fields || !Object.isFrozen(candidate)) return undefined; + const boot = retainServerBoot(fields['boot'], EMBEDDED_RELEASE_ID); + const integrity = fields['integrity']; + const acceptedIntegrity = ownDataObject(integrity, [ + 'version', + 'projectionDigest', + 'integrationConfigDigest', + ]); + if ( + !boot || + !acceptedIntegrity || + !Object.isFrozen(integrity) || + typeof fields['complete'] !== 'function' + ) { + return undefined; + } + if ( + acceptedIntegrity['version'] !== 1 || + acceptedIntegrity['projectionDigest'] !== + sha256HexUtf8V1(JSON.stringify(boot.auctionProjection)) || + acceptedIntegrity['integrationConfigDigest'] !== + sha256HexUtf8V1(JSON.stringify(boot.integrations)) + ) { + return undefined; + } + return candidate as ClaimedServerBootV1; + } catch { + return undefined; + } +} + +function claimedBootCompletion(candidate: unknown): ClaimedServerBootV1['complete'] | undefined { + const fields = ownDataObject(candidate, ['boot', 'integrity', 'complete']); + return fields && typeof fields['complete'] === 'function' + ? (fields['complete'] as ClaimedServerBootV1['complete']) + : 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 claimBoot = bootSnapshotClaim(target); if (!claimBoot) return; - const boot = claimBoot(document.currentScript); - if (!boot) return; + const source = document.currentScript; + const candidate = claimBoot(source); + const completeBoot = claimedBootCompletion(candidate); + const claimed = retainClaimedServerBootV1(candidate); + if (!claimed) { + try { + completeBoot?.('abi_mismatch'); + } catch { + // The authenticated bootstrap watchdog remains the terminal fallback owner. + } + return; + } + const { boot } = claimed; const takeover = consumeFirstDisplayTakeoverTransport(target); if (takeover.status === 'invalid') return; const claimDirect = takeover.status === 'absent' ? directRuntimeClaim(target) : undefined; @@ -113,6 +194,11 @@ export function startProductionRuntime(createComposition: BrowserRuntimeComposit ...(takeover.status === 'accepted' ? { coordinateTakeover: takeover.coordinate } : {}), autoInstall: true, onInstallComplete: (result) => { + try { + claimed.complete(result.state === 'kernel' ? 'kernel' : result.reason); + } catch { + // Direct completion still closes its independently authenticated watchdog. + } completeDirect?.(result.state === 'kernel' ? 'kernel' : 'runtime_fallback'); }, kernel: { diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts index 42b8f3603..8250628bb 100644 --- a/crates/trusted-server-js/lib/src/core/registry.ts +++ b/crates/trusted-server-js/lib/src/core/registry.ts @@ -1,6 +1,6 @@ // Programmatic ad-unit validation for the hard-cutover runtime. import type { AddAdUnitsResult, ProgrammaticAdUnit } from './types'; -import { validBoundedString } from './contracts/auction_projection'; +import { validBoundedString } from './contracts/bounded_string'; const MAX_AUCTION_BODY_BYTES = 256 * 1024; const MAX_PROGRAMMATIC_UNITS = 256; diff --git a/crates/trusted-server-js/lib/src/core/release_capacity.ts b/crates/trusted-server-js/lib/src/core/release_capacity.ts deleted file mode 100644 index 93b3c92e2..000000000 --- a/crates/trusted-server-js/lib/src/core/release_capacity.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare const __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__: number; - -/** Build-generated bounded manifest capacity without importing the product catalog. */ -export const EMBEDDED_MAX_MANIFEST_MODULES = __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__; diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 5c7869e3a..ea47b0464 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -564,6 +564,7 @@ export interface TsjsFallbackApi extends TsjsApiBase { state: 'fallback'; releaseId: string; reason: 'abi_mismatch' | 'bundle_partial'; + initialDisplayCommitted: boolean; }>; } diff --git a/crates/trusted-server-js/lib/src/kernel/contracts/release_capacity.ts b/crates/trusted-server-js/lib/src/kernel/contracts/release_capacity.ts new file mode 100644 index 000000000..6a2fe10c9 --- /dev/null +++ b/crates/trusted-server-js/lib/src/kernel/contracts/release_capacity.ts @@ -0,0 +1,9 @@ +declare const __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__: number; + +/** Build-generated bounded manifest capacity without importing the product catalog. */ +export const EMBEDDED_MAX_MANIFEST_MODULES = + Number.isSafeInteger(__TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__) && + __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__ >= 1 && + __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__ <= 256 + ? __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__ + : 0; diff --git a/crates/trusted-server-js/lib/src/kernel/fallback.ts b/crates/trusted-server-js/lib/src/kernel/fallback.ts index cc3da291c..576ccd701 100644 --- a/crates/trusted-server-js/lib/src/kernel/fallback.ts +++ b/crates/trusted-server-js/lib/src/kernel/fallback.ts @@ -1,5 +1,5 @@ -import { parseBrowserAuctionProjectionV1 } from '../core/contracts/auction_projection'; import { validateRequestAdsOptions } from '../core/contracts/request_ads'; +import { ownDataObject } from '../core/contracts/auction_projection'; import { prepareProgrammaticAdUnits } from '../core/registry'; import type { BootManifestV1, TsjsBootV1 } from '../core/types'; @@ -16,8 +16,6 @@ const SAFE_PROJECTION = { bids: [], } as const; const RUNTIME_SRC_PATTERN = /^\/static\/tsjs=tsjs-unified\.min\.js\?v=[0-9a-f]{64}$/; -const FIRST_DISPLAY_SRC_PATTERN = - /^\/static\/tsjs=tsjs-first-display\.min\.js\?m=[0-9a-f]{4}&v=[0-9a-f]{64}$/; function exactHttpOrigin(candidate: unknown): string | undefined { if (typeof candidate !== 'string') return undefined; @@ -51,68 +49,6 @@ export function trustedArtifactOrigin(runtimeDocument: Document): string | undef } } -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; - const output: Record = Object.create(null) as Record; - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') return undefined; - 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 exactKeys(record: Record, keys: readonly string[]): boolean { - const actual = Object.keys(record); - return actual.length === keys.length && actual.every((key) => keys.includes(key)); -} - -function snapshotOwnArray(value: unknown, maximum: number): readonly unknown[] | undefined { - try { - if (!Array.isArray(value) || value.length > maximum) { - return undefined; - } - if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; - const names = Object.getOwnPropertyNames(value); - if (names.length !== value.length + 1 || !names.includes('length')) return undefined; - const copy: unknown[] = []; - for (let index = 0; index < value.length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return undefined; - copy.push(descriptor.value); - } - return copy; - } catch { - return undefined; - } -} - -function snapshotFirstDisplay(candidate: unknown): BootManifestV1['firstDisplay'] | undefined { - if (candidate === null) return null; - const fields = ownDataRecord(candidate); - if (!fields || !exactKeys(fields, ['src', 'slices'])) return undefined; - const slices = snapshotOwnArray(fields.slices, 13); - if ( - typeof fields.src !== 'string' || - !FIRST_DISPLAY_SRC_PATTERN.test(fields.src) || - !slices || - slices.length === 0 || - slices[0] !== 'first_display' || - slices.some((id) => typeof id !== 'string') || - new Set(slices).size !== slices.length - ) { - return undefined; - } - return Object.freeze({ src: fields.src, slices: Object.freeze([...slices] as string[]) }); -} - function deepFreeze(value: T): Readonly { if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return value; for (const key of Reflect.ownKeys(value)) { @@ -122,11 +58,6 @@ function deepFreeze(value: T): Readonly { return Object.freeze(value); } -function readBootField(boot: unknown, key: string): unknown { - const record = ownDataRecord(boot); - return record?.[key]; -} - function captureTrustedArtifactSrc( runtimeDocument: Document, script: HTMLScriptElement, @@ -186,20 +117,19 @@ export function buildKernelBoot( candidate: unknown ): Readonly | undefined { try { - const record = ownDataRecord(candidate); + const record = ownDataObject(candidate, [ + 'abi', + 'releaseId', + 'manifest', + 'auctionProjection', + 'integrations', + 'creative', + 'diagnostics', + ]); if ( !record || Object.getPrototypeOf(candidate) !== Object.prototype || !Object.isFrozen(candidate) || - !exactKeys(record, [ - 'abi', - 'releaseId', - 'manifest', - 'auctionProjection', - 'integrations', - 'creative', - 'diagnostics', - ]) || record.abi !== 1 || record.releaseId !== releaseId || record.manifest !== manifest || @@ -220,21 +150,15 @@ export function buildKernelBoot( /** Build the immutable boot snapshot shared by every terminal fallback. */ export function buildFallbackBoot( releaseId: string, - candidate: unknown, trustedRuntimeSrc: string ): Readonly | undefined { if (!RUNTIME_SRC_PATTERN.test(trustedRuntimeSrc)) { return undefined; } - const projection = - parseBrowserAuctionProjectionV1(readBootField(candidate, 'auctionProjection')) ?? - SAFE_PROJECTION; const manifest: BootManifestV1 = { version: 1, releaseId, - firstDisplay: - snapshotFirstDisplay(readBootField(readBootField(candidate, 'manifest'), 'firstDisplay')) ?? - null, + firstDisplay: null, runtimeSrc: trustedRuntimeSrc, integrations: [], }; @@ -242,7 +166,7 @@ export function buildFallbackBoot( abi: 1, releaseId, manifest, - auctionProjection: projection, + auctionProjection: SAFE_PROJECTION, integrations: { version: 1, entries: [] }, creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, @@ -252,7 +176,6 @@ export function buildFallbackBoot( export interface FallbackFieldsOptions { readonly releaseId: string; readonly reason: BootFailureReason; - readonly boot: unknown; readonly trustedRuntimeSrc: string; } @@ -260,17 +183,10 @@ export interface FallbackFieldsOptions { export function createFallbackFields( options: FallbackFieldsOptions ): Readonly> | undefined { - const boot = buildFallbackBoot(options.releaseId, options.boot, options.trustedRuntimeSrc); + const boot = buildFallbackBoot(options.releaseId, options.trustedRuntimeSrc); if (!boot) return undefined; - const projection = boot as { - readonly auctionProjection: { - readonly auction: { readonly results: readonly { slot: string }[] }; - }; - }; - const knownSlots = Object.freeze( - projection.auctionProjection.auction.results.map(({ slot }) => slot) - ); - const known = new Set(knownSlots); + const knownSlots = Object.freeze([] as string[]); + const known = new Set(); const fields: Record = {}; Object.defineProperties(fields, { version: { enumerable: true, value: '1.0.0' }, @@ -292,10 +208,8 @@ export function createFallbackFields( const selected = validated.slots ?? knownSlots; return deepFreeze({ slots: selected.map((slot) => - known.has(slot) - ? validated.aborted - ? { slot, path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' } - : { slot, path: 'primary', outcome: 'failed', reason: options.reason } + validated.aborted + ? { slot, path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' } : { slot, path: 'primary', outcome: 'failed', reason: 'slot_unresolved' } ), }); @@ -307,6 +221,7 @@ export function createFallbackFields( state: 'fallback', releaseId: options.releaseId, reason: options.reason, + initialDisplayCommitted: false, }), }, }); diff --git a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts index 72a682ef6..de77b224b 100644 --- a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts +++ b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts @@ -6,12 +6,13 @@ import { import { snapshotPersistentFirstDisplayAdoptionV1 } from '../shared/takeover'; import type { ReleaseConfigSourceV1 } from './release_catalog'; +import { EMBEDDED_MAX_MANIFEST_MODULES } from './contracts/release_capacity'; import { DisposableStack, type DisposeCallback } from './disposable'; import { trustedArtifactOrigin, type BootFailureReason } from './fallback'; const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; const RELEASE_ID = /^[0-9a-f]{64}$/; -const MAX_INTEGRATIONS = 20; +const MAX_INTEGRATIONS = EMBEDDED_MAX_MANIFEST_MODULES; const MAX_KNOWN_INTEGRATIONS = 256; const BOOT_DEADLINE_MS = 10_000; const EMPTY_BINDING = Object.freeze({}); @@ -420,7 +421,7 @@ function validateCatalog( return Object.freeze(catalog); } -function validateManifest( +export function validateRuntimeManifestV1( candidate: unknown, embeddedReleaseId: string, catalog: readonly IntegrationCatalogEntry[], @@ -687,7 +688,7 @@ class IntegrationRegistryOwner { : undefined; this.catalog = catalog ?? Object.freeze([]); this.manifestValue = catalog - ? validateManifest( + ? validateRuntimeManifestV1( options.manifest, options.releaseId, catalog, diff --git a/crates/trusted-server-js/lib/src/kernel/runtime.ts b/crates/trusted-server-js/lib/src/kernel/runtime.ts index a57249b2b..0b1ba5a5f 100644 --- a/crates/trusted-server-js/lib/src/kernel/runtime.ts +++ b/crates/trusted-server-js/lib/src/kernel/runtime.ts @@ -430,11 +430,7 @@ class RuntimeOwner implements Runtime { if (this.options.autoInstall && !this.installPromise) void this.install(); }, }); - this.fallbackBoot = buildFallbackBoot( - EMBEDDED_RELEASE_ID, - bootCandidate, - this.trustedRuntimeSrc - ); + this.fallbackBoot = buildFallbackBoot(EMBEDDED_RELEASE_ID, this.trustedRuntimeSrc); if (!this.fallbackBoot) throw new Error('Trusted runtime artifact source is unavailable'); if (this.registry.manifest) { this.kernelBoot = buildKernelBoot( @@ -498,11 +494,19 @@ class RuntimeOwner implements Runtime { if (!this.registry || !this.ingress || this.runtimeState !== 'installing') { return Promise.resolve(Object.freeze({ state: 'fallback', reason: 'bundle_partial' })); } + const notifyInstallComplete = (result: IntegrationInstallResult): void => { + try { + this.options.onInstallComplete?.(result); + } catch { + // Completion observation cannot change the already committed terminal state. + } + }; if (!this.kernelBoot) { this.registry.dispose(); const result = Object.freeze({ state: 'fallback' as const, reason: 'abi_mismatch' as const }); this.runtimeState = 'failed'; - this.commitFallback(result.reason); + if (!this.options.coordinateTakeover) this.commitFallback(result.reason); + notifyInstallComplete(result); this.installPromise = Promise.resolve(result); return this.installPromise; } @@ -567,13 +571,9 @@ class RuntimeOwner implements Runtime { this.runtimeState = 'kernel'; } else { this.runtimeState = 'failed'; - this.commitFallback(result.reason); - } - try { - this.options.onInstallComplete?.(result); - } catch { - // Completion observation cannot change the already committed terminal state. + if (!this.options.coordinateTakeover) this.commitFallback(result.reason); } + notifyInstallComplete(result); resolveInstall?.(result); }; if (!this.options.coordinateTakeover) { @@ -668,7 +668,6 @@ class RuntimeOwner implements Runtime { const fields = createFallbackFields({ releaseId: EMBEDDED_RELEASE_ID, reason, - boot: this.fallbackBoot, trustedRuntimeSrc: this.trustedRuntimeSrc, }); if (!fields) return; diff --git a/crates/trusted-server-js/lib/src/services/context.ts b/crates/trusted-server-js/lib/src/services/context.ts index 355aef783..98966c2c2 100644 --- a/crates/trusted-server-js/lib/src/services/context.ts +++ b/crates/trusted-server-js/lib/src/services/context.ts @@ -1,5 +1,5 @@ import type { DisposeCallback } from '../kernel/disposable'; -import { MAX_MANIFEST_MODULES } from '../kernel/release_catalog'; +import { EMBEDDED_MAX_MANIFEST_MODULES } from '../kernel/contracts/release_capacity'; const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; // Context shares the existing /auction request-body ceiling. The structural @@ -120,7 +120,7 @@ interface TraversalFrame { } function snapshotManifest(candidate: readonly string[]): readonly string[] { - if (!Object.isFrozen(candidate) || candidate.length > MAX_MANIFEST_MODULES) { + if (!Object.isFrozen(candidate) || candidate.length > EMBEDDED_MAX_MANIFEST_MODULES) { throw new TypeError('Auction context manifest must be frozen and bounded'); } const seen = new Set(); diff --git a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs index 072987da1..662db2f65 100644 --- a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs +++ b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs @@ -16,6 +16,7 @@ const EMPTY_INTEGRATION_CONFIGS = Object.freeze({ version: 1, entries: Object.fr const EMPTY_INTEGRATION_CONFIG_DIGEST = createHash('sha256') .update(JSON.stringify(EMPTY_INTEGRATION_CONFIGS)) .digest('hex'); +const plain = (value) => JSON.parse(JSON.stringify(value)); function createDocument() { const dom = new JSDOM( @@ -86,14 +87,33 @@ function outline() { }; } -function evaluateWithInput(dom) { +function transport(bootValue = boot(), outlineValue = outline()) { + const integrity = { + version: 1, + projectionDigest: createHash('sha256') + .update(JSON.stringify(bootValue.auctionProjection)) + .digest('hex'), + integrationConfigDigest: createHash('sha256') + .update(JSON.stringify(bootValue.integrations)) + .digest('hex'), + }; + if (outlineValue) { + outlineValue.projectionDigest = integrity.projectionDigest; + outlineValue.integrationConfigDigest = integrity.integrationConfigDigest; + } + return { version: 1, boot: bootValue, integrity, outline: outlineValue }; +} + +function evaluateTransport(dom, value) { dom.window.eval( - `const __TSJS_SERVER_BOOT_INPUT_V1__={target:window.tsjs,boot:${JSON.stringify( - boot() - )},outline:${JSON.stringify(outline())}};${source}` + `const __TSJS_SERVER_BOOT_TRANSPORT_V1__=${JSON.stringify(JSON.stringify(value))};${source}` ); } +function evaluateWithInput(dom) { + evaluateTransport(dom, transport()); +} + test('generated bootstrap bytes are stamped exactly once and expose no callable global', () => { assert.equal(source.includes('__TSJS_RELEASE_ID_SENTINEL_V1__'), false); assert.equal(source.split(manifest.releaseId).length - 1, 1); @@ -109,7 +129,7 @@ test('generated bootstrap bytes are stamped exactly once and expose no callable }); test('generated bootstrap leaves the namespace untouched without exact server input', () => { - for (const input of ['', 'const __TSJS_SERVER_BOOT_INPUT_V1__={};']) { + for (const input of ['', 'const __TSJS_SERVER_BOOT_TRANSPORT_V1__={};']) { const { dom } = createDocument(); const target = { publisher: 'retained' }; dom.window.tsjs = target; @@ -130,11 +150,7 @@ test('generated bootstrap transfers the direct persistent watchdog to the select const directBoot = boot(); directBoot.manifest.firstDisplay = null; - dom.window.eval( - `const __TSJS_SERVER_BOOT_INPUT_V1__={target:window.tsjs,boot:${JSON.stringify( - directBoot - )},outline:null};${source}` - ); + evaluateTransport(dom, transport(directBoot, null)); const cancel = () => assert.fail('committed direct runtime must not be cancelled'); const complete = target._claimDirectRuntime(selected, cancel); @@ -146,6 +162,165 @@ test('generated bootstrap transfers the direct persistent watchdog to the select dom.window.close(); }); +test('generated bootstrap classifies a rejected claimed boot as an ABI mismatch', () => { + const { dom, selected } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + + evaluateTransport(dom, transport(directBoot, null)); + + const claim = target._claimBootSnapshot; + assert.equal(typeof claim, 'function'); + const claimed = claim(selected); + assert.equal(claimed.boot, target.boot); + claimed.complete('abi_mismatch'); + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + initialDisplayCommitted: false, + } + ); + dom.window.close(); +}); + +test('generated bootstrap reserves the boot claim across reentrant DOM authentication', () => { + const { dom, selected } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + + evaluateTransport(dom, transport(directBoot, null)); + + const claim = target._claimBootSnapshot; + const querySelectorAll = dom.window.document.querySelectorAll; + let nested; + let reentered = false; + Object.defineProperty(dom.window.document, 'querySelectorAll', { + configurable: true, + value(selector) { + if (!reentered) { + reentered = true; + nested = claim(selected); + } + return Reflect.apply(querySelectorAll, this, [selector]); + }, + }); + + const claimed = claim(selected); + nested?.complete('kernel'); + claimed.complete('abi_mismatch'); + + assert.equal(nested, undefined); + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + initialDisplayCommitted: false, + } + ); + dom.window.close(); +}); + +test('generated bootstrap completes a claimed boot without a reentrant realm callback', () => { + const { dom, selected } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + + evaluateTransport(dom, transport(directBoot, null)); + + const claimed = target._claimBootSnapshot(selected); + const includes = dom.window.Array.prototype.includes; + let reentered = false; + Object.defineProperty(dom.window.Array.prototype, 'includes', { + configurable: true, + value(value, fromIndex) { + if (!reentered) { + reentered = true; + claimed.complete('kernel'); + } + return Reflect.apply(includes, this, [value, fromIndex]); + }, + writable: true, + }); + + assert.doesNotThrow(() => claimed.complete('abi_mismatch')); + assert.equal(reentered, false); + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + initialDisplayCommitted: false, + } + ); + dom.window.close(); +}); + +test('generated takeover completion leaves terminal fallback ownership with bootstrap', () => { + const { dom, selected } = createDocument(); + const target = { que: [] }; + dom.window.tsjs = target; + evaluateWithInput(dom); + + const accepted = dom.window.document.createElement('iframe'); + accepted.id = 'accepted-first-display'; + dom.window.document.body.append(accepted); + const agent = { + state: 'painted', + snapshot: () => ({ initialDisplayCommitted: true }), + }; + const registration = { + abi: 1, + id: 'first_display', + releaseId: manifest.releaseId, + prepare: (host) => + Object.freeze({ + sliceHost: Object.freeze({}), + activate: () => host.options.onAgentReady(agent), + }), + }; + assert.equal(target._registerFirstDisplay.call(target, registration, selected), true); + + const runtime = dom.window.document.createElement('script'); + runtime.id = 'trustedserver-js-runtime'; + runtime.src = runtimeSrc; + dom.window.document.head.append(runtime); + Object.defineProperty(dom.window.document, 'currentScript', { + configurable: true, + value: runtime, + }); + const claimed = target._claimBootSnapshot(runtime); + assert.equal(claimed.boot, target.boot); + claimed.complete('bundle_partial'); + + assert.deepEqual( + JSON.parse(JSON.stringify(Object.getOwnPropertyDescriptor(target, '_internal')?.value)), + { + state: 'fallback', + releaseId: manifest.releaseId, + reason: 'bundle_partial', + initialDisplayCommitted: true, + } + ); + assert.equal(target.boot.manifest.firstDisplay.src, selectedSrc); + assert.equal(accepted.isConnected, true); + dom.window.close(); +}); + test('generated bootstrap commits one non-rendering terminal shell after registration failure', async () => { const { dom, selected } = createDocument(); const drained = []; @@ -190,18 +365,39 @@ test('generated bootstrap commits one non-rendering terminal shell after registr 0 ); assert.deepEqual(drained, [target, 'late']); - assert.throws(() => target.addAdUnits({}), { - name: 'TsjsUnavailableError', - code: 'runtime_unavailable', - releaseId: manifest.releaseId, - reason: 'abi_mismatch', + const malformedUnit = dom.window.eval("({code:'',mediaTypes:{}})"); + assert.throws(() => target.addAdUnits(malformedUnit), { + name: 'AdUnitRegistrationError', + code: 'invalid_code', }); - await assert.rejects(target.requestAds(), { + const validUnit = dom.window.eval( + "({code:'programmatic',mediaTypes:{banner:{sizes:[[300,250]]}}})" + ); + assert.throws(() => target.addAdUnits(validUnit), { name: 'TsjsUnavailableError', code: 'runtime_unavailable', releaseId: manifest.releaseId, reason: 'abi_mismatch', }); + assert.deepEqual(plain(await target.requestAds()), { slots: [] }); + const explicitOptions = dom.window.eval("({slots:['slot-1']})"); + assert.deepEqual(plain(await target.requestAds(explicitOptions)), { + slots: [{ slot: 'slot-1', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }], + }); + const controller = new dom.window.AbortController(); + controller.abort(); + const abortedOptions = dom.window.eval("({slots:['slot-1']})"); + abortedOptions.signal = controller.signal; + assert.deepEqual(plain(await target.requestAds(abortedOptions)), { + slots: [{ slot: 'slot-1', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + }); + assert.deepEqual(plain(target.boot.auctionProjection), { + version: 1, + auction: { version: 1, auctionId: 'fallback', results: [] }, + slots: [], + bids: [], + }); + assert.deepEqual(plain(target.boot.integrations), { version: 1, entries: [] }); dom.window.close(); }); 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 8c5be3ab6..d0d029694 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 @@ -606,6 +606,50 @@ test('generated takeover transport owns branded render operations without GPT du } }); +test('generated bootstrap uses only the compact sealed transport parser', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + const sources = new Set(metrics.bootstrap.sources.map(({ file }) => file)); + + assert.equal(sources.has('src/core/contracts/server_boot_transport.ts'), true); + for (const forbidden of [ + 'src/core/contracts/boot.ts', + 'src/core/contracts/auction_projection.ts', + 'src/core/contracts/integration_configs.ts', + ]) { + assert.equal(sources.has(forbidden), false, `bootstrap must not reach ${forbidden}`); + } +}); + +test('persistent core consumes generated capacity without bundling the build catalog', () => { + const metrics = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-build-metrics-v1.json'), 'utf8') + ); + const core = metrics.modules.find(({ file }) => file === 'tsjs-core.js'); + const sources = new Set(core.sources.map(({ file }) => file)); + const buildSource = fs.readFileSync(path.resolve(libDirectory, 'build-all.mjs'), 'utf8'); + const registrySource = fs.readFileSync( + path.resolve(libDirectory, 'src/kernel/integration_registry.ts'), + 'utf8' + ); + + assert.match( + buildSource, + /__TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__:\s*JSON\.stringify\(releaseCatalog\.length\)/u + ); + assert.match( + registrySource, + /import \{ EMBEDDED_MAX_MANIFEST_MODULES \} from '\.\/contracts\/release_capacity';/u + ); + assert.match(registrySource, /const MAX_INTEGRATIONS = EMBEDDED_MAX_MANIFEST_MODULES;/u); + assert.doesNotMatch(registrySource, /const MAX_INTEGRATIONS = 20;/u); + // The generated scalar is substituted before bundling, so its declaration is + // intentionally tree-shaken along with the build-only catalog. + assert.equal(sources.has('src/kernel/contracts/release_capacity.ts'), false); + assert.equal(sources.has('src/kernel/release_catalog.ts'), false); +}); + test('messaging protocol binding is declared before schema initialization', () => { const source = fs.readFileSync(path.resolve(libDirectory, 'src/adapters/messaging.ts'), 'utf8'); const binding = @@ -717,6 +761,16 @@ test('co-bundled render_runtime and independent GPT start one branded display fl return freeze({ abi: 1, releaseId: '${release.releaseId}', + manifest: freeze({ + version: 1, + releaseId: '${release.releaseId}', + firstDisplay: null, + runtimeSrc: '/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}', + integrations: freeze([ + freeze({ id: 'render_runtime', phase: 'takeover' }), + freeze({ id: 'gpt', phase: 'takeover' }) + ]) + }), auctionProjection: freeze({ version: 1, auction: freeze({ @@ -748,16 +802,6 @@ test('co-bundled render_runtime and independent GPT start one branded display fl version: 1, renderTraceOverlay: false, gpt: freeze({ active: false }) - }), - manifest: freeze({ - version: 1, - releaseId: '${release.releaseId}', - firstDisplay: null, - runtimeSrc: '/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}', - integrations: freeze([ - freeze({ id: 'render_runtime', phase: 'takeover' }), - freeze({ id: 'gpt', phase: 'takeover' }) - ]) }) }); })()`); @@ -765,6 +809,20 @@ test('co-bundled render_runtime and independent GPT start one branded display fl runtimeScript.id = 'trustedserver-js'; runtimeScript.src = `/static/tsjs=tsjs-unified.min.js?v=${runtimeHash}`; dom.window.document.head.append(runtimeScript); + const integrity = dom.window.Object.create(dom.window.Object.prototype); + dom.window.Object.assign(integrity, { + version: 1, + projectionDigest: createHash('sha256') + .update(JSON.stringify(boot.auctionProjection)) + .digest('hex'), + integrationConfigDigest: createHash('sha256') + .update(JSON.stringify(boot.integrations)) + .digest('hex'), + }); + dom.window.Object.freeze(integrity); + const claimedBoot = dom.window.Object.create(dom.window.Object.prototype); + dom.window.Object.assign(claimedBoot, { boot, integrity, complete: () => undefined }); + dom.window.Object.freeze(claimedBoot); Object.defineProperty(dom.window, 'tsjs', { configurable: true, value: {} }); Object.defineProperties(dom.window.tsjs, { boot: { configurable: true, enumerable: true, value: boot, writable: true }, @@ -775,7 +833,7 @@ test('co-bundled render_runtime and independent GPT start one branded display fl value: (source) => { if (source !== runtimeScript) return undefined; Reflect.deleteProperty(dom.window.tsjs, '_claimBootSnapshot'); - return boot; + return claimedBoot; }, writable: false, }, diff --git a/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs b/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs index 3296bf2e4..9e90c7afa 100644 --- a/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs +++ b/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs @@ -554,7 +554,7 @@ git mv crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs crat test('the implementation plan contains no executable retired-source command', () => { const planSource = readFileSync(planPath, 'utf8'); - assert.equal(countExecutableShellFences(planSource), 54); + assert.equal(countExecutableShellFences(planSource), 61); assert.deepEqual(auditRetiredPlanCommands(planSource), []); const mutatedPlanSource = planSource.replace( diff --git a/crates/trusted-server-js/lib/test/core/bootstrap.test.ts b/crates/trusted-server-js/lib/test/core/bootstrap.test.ts index 8c59cb544..be3e643ca 100644 --- a/crates/trusted-server-js/lib/test/core/bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/core/bootstrap.test.ts @@ -8,6 +8,7 @@ import { snapshotIntegrationConfigsV1, } from '../../src/core/contracts/integration_configs'; import { snapshotBootstrapInputV1 } from '../../src/core/contracts/boot'; +import { snapshotServerBootTransportV1 } from '../../src/core/contracts/server_boot_transport'; const RELEASE = 'a'.repeat(64); @@ -90,6 +91,105 @@ function firstDisplayInput(): Record { return input; } +function transportInput(firstDisplay = false): Record { + const input = firstDisplay ? firstDisplayInput() : bootInput(); + const boot = input.boot as Record; + const integrationConfigDigest = createHash('sha256') + .update(JSON.stringify(boot.integrations)) + .digest('hex'); + const projectionDigest = createHash('sha256') + .update(JSON.stringify(boot.auctionProjection)) + .digest('hex'); + const outline = input.outline as Record | null; + if (outline) { + outline.projectionDigest = projectionDigest; + outline.integrationConfigDigest = integrationConfigDigest; + } + return { + version: 1, + boot, + integrity: { version: 1, projectionDigest, integrationConfigDigest }, + outline, + }; +} + +describe('sealed production boot transport', () => { + it.each([false, true])('parses and recursively freezes a fresh %s transport', (firstDisplay) => { + const original = transportInput(firstDisplay); + const accepted = snapshotServerBootTransportV1(JSON.stringify(original), RELEASE); + + expect(accepted).toBeDefined(); + expect(accepted).not.toBe(original); + expect(Object.isFrozen(accepted)).toBe(true); + expect(Object.isFrozen(accepted?.boot)).toBe(true); + expect(Object.isFrozen(accepted?.boot.auctionProjection)).toBe(true); + expect(Object.isFrozen(accepted?.integrity)).toBe(true); + expect(accepted?.outline === null || Object.isFrozen(accepted?.outline)).toBe(true); + }); + + it('accepts strings only and requires the exact root and always-present integrity', () => { + const valid = transportInput(); + expect(snapshotServerBootTransportV1(valid, RELEASE)).toBeUndefined(); + expect( + snapshotServerBootTransportV1(JSON.stringify({ ...valid, extra: true }), RELEASE) + ).toBeUndefined(); + const { integrity: _integrity, ...missingIntegrity } = valid; + expect( + snapshotServerBootTransportV1(JSON.stringify(missingIntegrity), RELEASE) + ).toBeUndefined(); + }); + + it('binds a non-null outline to the always-present integrity value', () => { + const value = transportInput(true); + (value.outline as Record).projectionDigest = 'f'.repeat(64); + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); + }); + + it('rejects a decoded transport above 10 MiB before parsing it', () => { + const encoded = `${JSON.stringify(transportInput())}${' '.repeat(10 * 1024 * 1024)}`; + expect(snapshotServerBootTransportV1(encoded, RELEASE)).toBeUndefined(); + }); + + it('rejects more than fourteen takeover manifest entries', () => { + const value = transportInput(); + const boot = value.boot as Record; + const manifest = boot.manifest as Record; + const integrations = Array.from({ length: 14 }, (_, index) => ({ + id: `takeover_${index}`, + phase: 'takeover', + })); + manifest.integrations = integrations; + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeDefined(); + + integrations.push({ id: 'takeover_14', phase: 'takeover' }); + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); + }); + + it('binds each deferred manifest source to its declared id', () => { + const value = transportInput(); + const boot = value.boot as Record; + const manifest = boot.manifest as Record; + manifest.integrations = [ + { id: 'render_runtime', phase: 'takeover' }, + { + id: 'gpt_later', + phase: 'deferred', + trigger: 'first_display_or_idle', + src: `/static/tsjs=tsjs-gpt_later.min.js?v=${'e'.repeat(64)}`, + }, + ]; + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeDefined(); + + const deferred = (manifest.integrations as Array>)[1]!; + deferred.src = `/static/tsjs=tsjs-evil.min.js?v=${'e'.repeat(64)}`; + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); + }); +}); + describe('bootstrap integration configuration admission', () => { it('captures one complete immutable boot copy without retaining the server literal', () => { const original = bootInput(); 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 8b2dea43c..71405ab1e 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,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { snapshotTsjsBootV1 } from '../../src/core/contracts/boot'; +import { + canonicalIntegrationConfigDigestV1, + sha256HexUtf8V1, +} from '../../src/core/contracts/integration_configs'; import type { TsjsApi, TsjsBootV1 } from '../../src/core/types'; const RELEASE = 'a'.repeat(64); @@ -32,6 +36,112 @@ function boot() { )!; } +function bootWithHostileConfigSurface() { + return snapshotTsjsBootV1( + { + ...boot(), + manifest: { + ...boot().manifest, + integrations: [ + { id: 'render_runtime', phase: 'takeover' }, + { id: 'aps', phase: 'takeover' }, + ], + }, + integrations: { + version: 1, + entries: [ + { + id: 'aps', + config: { + accessorValue: true, + custom: { value: 1 }, + symbolTarget: { value: 1 }, + sparse: [1, null, 3], + aliasLeft: { value: 1 }, + aliasRight: { value: 1 }, + mutable: { value: 1 }, + }, + }, + ], + }, + }, + RELEASE + )!; +} + +function freezeDataGraph(value: unknown, skipped?: object, seen = new Set()): void { + if (typeof value !== 'object' || value === null || value === skipped || seen.has(value)) return; + seen.add(value); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor && 'value' in descriptor) freezeDataGraph(descriptor.value, skipped, seen); + } + Object.freeze(value); +} + +type HostileBootKind = + | 'accessor' + | 'custom_prototype' + | 'symbol' + | 'sparse_array' + | 'repeated_alias' + | 'mutable_config' + | 'mutable_manifest' + | 'mutable_projection' + | 'mutable_diagnostics'; + +function hostileClaimedBoot(kind: HostileBootKind): { + readonly candidate: Readonly; + readonly integrity: ReturnType; + readonly assertUnobserved: () => void; +} { + const accepted = bootWithHostileConfigSurface(); + const candidate = JSON.parse(JSON.stringify(accepted)) as Record; + const integrations = candidate.integrations as { + entries: Array<{ config: Record }>; + }; + const config = integrations.entries[0]!.config; + let accessorReads = 0; + let skipped: object | undefined; + if (kind === 'accessor') { + Object.defineProperty(config, 'accessorValue', { + configurable: true, + enumerable: true, + get: () => { + accessorReads += 1; + return true; + }, + }); + } else if (kind === 'custom_prototype') { + Object.setPrototypeOf(config.custom as object, { inherited: true }); + } else if (kind === 'symbol') { + Object.defineProperty(config.symbolTarget as object, Symbol('hostile'), { + enumerable: true, + value: true, + }); + } else if (kind === 'sparse_array') { + const sparse = [1, null, 3]; + Reflect.deleteProperty(sparse, '1'); + config.sparse = sparse; + } else if (kind === 'repeated_alias') { + config.aliasRight = config.aliasLeft; + } else if (kind === 'mutable_config') { + skipped = config.mutable as object; + } else if (kind === 'mutable_manifest') { + skipped = (candidate.manifest as { integrations: object }).integrations; + } else if (kind === 'mutable_projection') { + skipped = (candidate.auctionProjection as { slots: object }).slots; + } else { + skipped = (candidate.diagnostics as { gpt: object }).gpt; + } + freezeDataGraph(candidate, skipped); + return { + candidate: candidate as unknown as Readonly, + integrity: bootIntegrity(accepted), + assertUnobserved: () => expect(accessorReads).toBe(0), + }; +} + async function loadMinimalProductionRuntime(): Promise { await import('../../src/composition/runtime_transport'); await import('../../src/integrations/render_runtime/index'); @@ -45,11 +155,24 @@ function installRuntimeScript(): void { Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); } -function installBootClaim(target: object, acceptedBoot: Readonly): void { - const claim = (source: unknown): Readonly | undefined => { +function bootIntegrity(acceptedBoot: Readonly) { + return Object.freeze({ + version: 1 as const, + projectionDigest: sha256HexUtf8V1(JSON.stringify(acceptedBoot.auctionProjection)), + integrationConfigDigest: canonicalIntegrationConfigDigestV1(acceptedBoot.integrations), + }); +} + +function installBootClaim( + target: object, + acceptedBoot: Readonly, + integrity = bootIntegrity(acceptedBoot) +): ReturnType { + const completed = vi.fn(); + const claim = (source: unknown) => { if (source !== document.currentScript) return undefined; Reflect.deleteProperty(target, '_claimBootSnapshot'); - return acceptedBoot; + return Object.freeze({ boot: acceptedBoot, integrity, complete: completed }); }; Object.defineProperty(target, '_claimBootSnapshot', { configurable: true, @@ -57,6 +180,7 @@ function installBootClaim(target: object, acceptedBoot: Readonly): v value: claim, writable: false, }); + return completed; } describe('core production bootstrap', () => { @@ -122,6 +246,102 @@ describe('core production bootstrap', () => { } }); + it.each(['projectionDigest', 'integrationConfigDigest'] as const)( + 'rejects a direct boot whose %s does not match before runtime publication', + async (field) => { + const preload = { boot: boot(), que: [] }; + const rejected = installBootClaim( + preload, + preload.boot, + Object.freeze({ ...bootIntegrity(preload.boot), [field]: 'f'.repeat(64) }) + ); + (window as unknown as { tsjs?: unknown }).tsjs = preload; + + await loadMinimalProductionRuntime(); + await Promise.resolve(); + + expect(preload).not.toHaveProperty('_internal'); + expect(preload).not.toHaveProperty('_registerIntegration'); + expect(preload).not.toHaveProperty('requestAds'); + expect(rejected).toHaveBeenCalledOnce(); + expect(rejected).toHaveBeenCalledWith('abi_mismatch'); + } + ); + + it('rejects mismatched integrity before consuming takeover or preparing runtime owners', async () => { + const script = document.currentScript as HTMLScriptElement; + script.id = 'trustedserver-js-runtime'; + const coordinate = vi.fn(); + const preload = { boot: boot(), que: [] }; + Object.defineProperty(preload, '_firstDisplayTakeover', { + configurable: true, + enumerable: false, + value: coordinate, + writable: false, + }); + const rejected = installBootClaim( + preload, + preload.boot, + Object.freeze({ ...bootIntegrity(preload.boot), projectionDigest: 'f'.repeat(64) }) + ); + (window as unknown as { tsjs?: unknown }).tsjs = preload; + + await loadMinimalProductionRuntime(); + await Promise.resolve(); + + expect(coordinate).not.toHaveBeenCalled(); + expect(preload).toHaveProperty('_firstDisplayTakeover'); + expect(preload).not.toHaveProperty('_registerIntegration'); + expect(preload).not.toHaveProperty('_internal'); + expect(rejected).toHaveBeenCalledOnce(); + expect(rejected).toHaveBeenCalledWith('abi_mismatch'); + }); + + it.each( + (['direct', 'takeover'] as const).flatMap((mode) => + ( + [ + 'accessor', + 'custom_prototype', + 'symbol', + 'sparse_array', + 'repeated_alias', + 'mutable_config', + 'mutable_manifest', + 'mutable_projection', + 'mutable_diagnostics', + ] as const + ).map((kind) => [mode, kind] as const) + ) + )('rejects a %s claimed boot with hostile %s data before effects', async (mode, kind) => { + const { candidate, integrity, assertUnobserved } = hostileClaimedBoot(kind); + const preload = { boot: candidate, que: [] }; + const coordinate = vi.fn(); + if (mode === 'takeover') { + Object.defineProperty(preload, '_firstDisplayTakeover', { + configurable: true, + enumerable: false, + value: coordinate, + writable: false, + }); + } + const rejected = installBootClaim(preload, candidate, integrity); + (window as unknown as { tsjs?: unknown }).tsjs = preload; + const { startProductionRuntime } = await import('../../src/core/index'); + const createComposition = vi.fn(() => ({ + runtime: { start: vi.fn(() => true) }, + })) as unknown as Parameters[0]; + + startProductionRuntime(createComposition); + + assertUnobserved(); + expect(createComposition).not.toHaveBeenCalled(); + expect(coordinate).not.toHaveBeenCalled(); + expect(preload).not.toHaveProperty('_internal'); + expect(rejected).toHaveBeenCalledOnce(); + expect(rejected).toHaveBeenCalledWith('abi_mismatch'); + }); + it('publishes no terminal API when the closure boot claim is malformed', async () => { const preload = { boot: boot(), que: [] }; Object.defineProperty(preload, '_claimBootSnapshot', { 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 b5a5ab793..0c60a8b1e 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -1,11 +1,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { snapshotTsjsBootV1 } from '../../src/core/contracts/boot'; +import { + canonicalIntegrationConfigDigestV1, + sha256HexUtf8V1, +} from '../../src/core/contracts/integration_configs'; import type { TsjsApi, TsjsBootV1 } from '../../src/core/types'; +import stringifyCorpus from '../fixtures/contracts/ecmascript-json-stringify-v1.json'; const RELEASE = 'a'.repeat(64); const RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; +describe('server JSON.stringify canonicalization corpus', () => { + it.each(stringifyCorpus)('$name', ({ input, expected }) => { + const canonical = JSON.stringify(JSON.parse(input)); + expect(canonical).toBe(expected); + expect(sha256HexUtf8V1(canonical)).toMatch(/^[0-9a-f]{64}$/); + }); +}); + function boot() { return snapshotTsjsBootV1( { @@ -52,7 +65,15 @@ function installBootClaim(target: object, acceptedBoot: Readonly): v value: (source: unknown) => { if (source !== document.currentScript) return undefined; Reflect.deleteProperty(target, '_claimBootSnapshot'); - return acceptedBoot; + return Object.freeze({ + boot: acceptedBoot, + complete: () => undefined, + integrity: Object.freeze({ + version: 1, + projectionDigest: sha256HexUtf8V1(JSON.stringify(acceptedBoot.auctionProjection)), + integrationConfigDigest: canonicalIntegrationConfigDigestV1(acceptedBoot.integrations), + }), + }); }, writable: false, }); diff --git a/crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json b/crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json new file mode 100644 index 000000000..418c2cfa2 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json @@ -0,0 +1,39 @@ +[ + { "name": "negative zero", "input": "{\"n\":-0.0}", "expected": "{\"n\":0}" }, + { "name": "integral float", "input": "{\"n\":1.0}", "expected": "{\"n\":1}" }, + { + "name": "upper decimal threshold", + "input": "{\"n\":1e20}", + "expected": "{\"n\":100000000000000000000}" + }, + { + "name": "upper exponent threshold", + "input": "{\"n\":1e21}", + "expected": "{\"n\":1e+21}" + }, + { + "name": "lower decimal threshold", + "input": "{\"n\":1e-6}", + "expected": "{\"n\":0.000001}" + }, + { + "name": "lower exponent threshold", + "input": "{\"n\":1e-7}", + "expected": "{\"n\":1e-7}" + }, + { + "name": "binary64 integer rounding", + "input": "{\"n\":9007199254740993}", + "expected": "{\"n\":9007199254740992}" + }, + { + "name": "integer index enumeration", + "input": "{\"a\":1,\"10\":10,\"2\":2,\"01\":1}", + "expected": "{\"2\":2,\"10\":10,\"a\":1,\"01\":1}" + }, + { + "name": "nested number and key ordering", + "input": "{\"values\":[-0.0,1e20,1e-7],\"3\":{\"20\":20,\"4\":4}}", + "expected": "{\"3\":{\"4\":4,\"20\":20},\"values\":[0,100000000000000000000,1e-7]}" + } +] 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 b545f6162..a899198d9 100644 --- a/crates/trusted-server-js/lib/test/kernel/fallback.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/fallback.test.ts @@ -157,44 +157,10 @@ describe('kernel boot creative ABI', () => { }); describe('terminal fallback boot manifest', () => { - it('uses the independently trusted takeover source when the manifest field is missing', () => { - const fallback = buildFallbackBoot( - RELEASE_ID, - { - ...boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), - manifest: { - version: 1, - releaseId: RELEASE_ID, - integrations: [], - }, - }, - TRUSTED_RUNTIME_SRC - ) as { readonly manifest: unknown }; - - expect(fallback.manifest).toEqual({ - version: 1, - releaseId: RELEASE_ID, - firstDisplay: null, - runtimeSrc: TRUSTED_RUNTIME_SRC, - integrations: [], - }); - }); - - it('uses the independently trusted takeover source when the manifest field is malformed', () => { - const fallback = buildFallbackBoot( - RELEASE_ID, - { - ...boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), - manifest: { - version: 1, - releaseId: RELEASE_ID, - firstDisplay: null, - runtimeSrc: `/static/tsjs=tsjs-unified.min.js?v=${'e'.repeat(64)}&publisher=1`, - integrations: [], - }, - }, - TRUSTED_RUNTIME_SRC - ) as { readonly manifest: unknown }; + it('constructs the exact safe manifest from the independently trusted takeover source', () => { + const fallback = buildFallbackBoot(RELEASE_ID, TRUSTED_RUNTIME_SRC) as { + readonly manifest: unknown; + }; expect(fallback.manifest).toEqual({ version: 1, @@ -206,22 +172,14 @@ describe('terminal fallback boot manifest', () => { }); it('refuses to construct a fallback boot without an independently trusted takeover source', () => { - expect( - buildFallbackBoot( - RELEASE_ID, - boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), - undefined as never - ) - ).toBeUndefined(); + expect(buildFallbackBoot(RELEASE_ID, undefined as never)).toBeUndefined(); }); it('publishes the exact phase-aware fallback manifest with the accepted takeover source', () => { const acceptedManifest = manifest(['render_runtime', 'diagnostics_presentation']); - const fallback = buildFallbackBoot( - RELEASE_ID, - boot({ version: 1, enabled: false, clickGuard: false, renderGuard: false }), - acceptedManifest.runtimeSrc - ) as { readonly manifest: unknown }; + const fallback = buildFallbackBoot(RELEASE_ID, acceptedManifest.runtimeSrc) as { + readonly manifest: unknown; + }; expect(fallback.manifest).toEqual({ version: 1, diff --git a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts index 7ce3028c1..9edf54779 100644 --- a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { BootManifestV1 } from '../../src/core/types'; +import { EMBEDDED_MAX_MANIFEST_MODULES } from '../../src/kernel/contracts/release_capacity'; import { createIntegrationRegistry as createIntegrationRegistryOwner, snapshotIntegrationRegistration, @@ -535,7 +536,12 @@ describe('integration manifest and registration admission', () => { ], }, ], - ['over capacity', manifest(Array.from({ length: 21 }, (_, index) => `module_${index}`))], + [ + 'over generated capacity', + manifest( + Array.from({ length: EMBEDDED_MAX_MANIFEST_MODULES + 1 }, (_, index) => `module_${index}`) + ), + ], ])('rejects a malformed manifest: %s', async (_name, candidate) => { const registry = createIntegrationRegistry({ manifest: candidate, 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 f46881f54..b125a1730 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -1331,6 +1331,64 @@ describe('Runtime bootstrap owner', () => { ]); }); + it.each(['prepare', 'activate'] as const)( + 'returns a takeover %s failure to bootstrap without publishing a second fallback owner', + async (checkpoint) => { + const target = { que: [] as unknown[] }; + const onInstallComplete = vi.fn(); + const coordinateTakeover = vi.fn((prepared) => { + const candidate = takeoverHandoff(); + const handoff = prepared.validateHandoff(candidate.handoff, candidate.outline); + expect(handoff).toBeDefined(); + prepared.activate( + Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: handoff!, + identities: Object.freeze([]), + }) + ); + prepared.commit(); + }); + const fail = () => { + throw new Error(checkpoint); + }; + const runtime = createRuntime({ + target, + releaseId: RELEASE, + manifest: manifest([]), + knownIntegrationIds: Object.freeze([]), + boot: boot(), + ...(checkpoint === 'prepare' ? { prepareOwner: fail } : { activateOwner: fail }), + coordinateTakeover, + onInstallComplete, + kernel: { + addAdUnits: vi.fn(), + diagnostics: Object.freeze({}), + requestAds: vi.fn(), + }, + }); + + expect(runtime.start()).toBe(true); + await expect(runtime.install()).resolves.toEqual({ + state: 'fallback', + reason: 'bundle_partial', + }); + + expect(runtime.state).toBe('failed'); + expect(onInstallComplete).toHaveBeenCalledOnce(); + expect(onInstallComplete).toHaveBeenCalledWith({ + state: 'fallback', + reason: 'bundle_partial', + }); + expect(target).not.toHaveProperty('version'); + expect(target).not.toHaveProperty('_internal'); + expect(target).not.toHaveProperty('requestAds'); + if (checkpoint === 'prepare') expect(coordinateTakeover).not.toHaveBeenCalled(); + else expect(coordinateTakeover).toHaveBeenCalledOnce(); + } + ); + it('stops activation when owner activation disposes the installing runtime', async () => { const activateCore = vi.fn(); const activateModule = vi.fn(); @@ -1494,6 +1552,7 @@ describe('Runtime bootstrap owner', () => { state: 'fallback', releaseId: RELEASE, reason, + initialDisplayCommitted: false, }); await expect( (target as unknown as { requestAds(options?: unknown): Promise }).requestAds() @@ -1756,6 +1815,7 @@ describe('Runtime bootstrap owner', () => { state: 'fallback', releaseId: RELEASE, reason: 'bundle_partial', + initialDisplayCommitted: false, }); }); @@ -2071,7 +2131,7 @@ describe('Runtime bootstrap owner', () => { expect(runtime.state).toBe('kernel'); }); - it('validates fallback calls and settles known, unknown, and aborted slots', async () => { + it('validates fallback calls against the exact empty safe projection', async () => { const target = {}; const runtime = createRuntime({ target, @@ -2089,9 +2149,10 @@ describe('Runtime bootstrap owner', () => { boot: unknown; }; + await expect(api.requestAds()).resolves.toEqual({ slots: [] }); await expect(api.requestAds({ slots: ['known', 'unknown'] })).resolves.toEqual({ slots: [ - { slot: 'known', path: 'primary', outcome: 'failed', reason: 'abi_mismatch' }, + { slot: 'known', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, { slot: 'unknown', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, ], }); @@ -2138,7 +2199,7 @@ describe('Runtime bootstrap owner', () => { }); }); - it('snapshots fallback boot before publisher mutation during installation', async () => { + it('never retains projected slot membership in fallback boot', async () => { const target = { boot: boot([{ slot: 'initial', outcome: 'no_bid' }]) }; const runtime = createRuntime({ target, @@ -2156,12 +2217,10 @@ describe('Runtime bootstrap owner', () => { boot: { auctionProjection: { auction: { results: readonly { slot: string }[] } } }; requestAds(options: unknown): Promise; }; - expect(api.boot.auctionProjection.auction.results).toEqual([ - { slot: 'initial', outcome: 'no_bid' }, - ]); + expect(api.boot.auctionProjection.auction.results).toEqual([]); await expect(api.requestAds({ slots: ['initial', 'mutated'] })).resolves.toEqual({ slots: [ - { slot: 'initial', path: 'primary', outcome: 'failed', reason: 'abi_mismatch' }, + { slot: 'initial', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, { slot: 'mutated', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }, ], }); @@ -2446,7 +2505,7 @@ describe('Runtime bootstrap owner', () => { } ); - it('applies fallback slot collision and combined registry capacity validation', async () => { + it('does not retain server slot collisions or capacity in fallback validation', async () => { const makeFallback = async (slots: readonly string[]) => { const target = {}; const runtime = createRuntime({ @@ -2464,14 +2523,12 @@ describe('Runtime bootstrap owner', () => { const collision = await makeFallback(['server']); expect(() => collision({ code: 'server', mediaTypes: { banner: { sizes: [[300, 250]] } } }) - ).toThrow(expect.objectContaining({ code: 'slot_collision', unitIndex: 0 })); + ).toThrow(TsjsUnavailableError); const full = await makeFallback(Array.from({ length: 256 }, (_, index) => `slot-${index}`)); - const capacityError = thrownBy(() => + expect(() => full({ code: 'overflow', mediaTypes: { banner: { sizes: [[300, 250]] } } }) - ); - expect(capacityError).toMatchObject({ code: 'registry_capacity' }); - expect(Object.prototype.hasOwnProperty.call(capacityError, 'unitIndex')).toBe(false); + ).toThrow(TsjsUnavailableError); }); it('reports aggregate request overflow before combined registry capacity', async () => { diff --git a/crates/trusted-server-js/lib/vite.config.ts b/crates/trusted-server-js/lib/vite.config.ts index a28bcc092..9ed8544ee 100644 --- a/crates/trusted-server-js/lib/vite.config.ts +++ b/crates/trusted-server-js/lib/vite.config.ts @@ -2,4 +2,8 @@ import { defineConfig } from 'vite'; // Build configuration has moved to build-all.mjs. // This file is retained for vitest, which uses it for test resolution. -export default defineConfig({}); +export default defineConfig({ + define: { + __TSJS_EMBEDDED_MAX_MANIFEST_MODULES_V1__: JSON.stringify(20), + }, +}); 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 6cc1fbcd7..adbabbb19 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 @@ -1133,21 +1133,41 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled **Files:** - Create: `crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts` +- Create: `crates/trusted-server-js/lib/src/core/contracts/sha256.ts` +- Create: `crates/trusted-server-js/lib/src/core/contracts/bounded_string.ts` +- Create: `crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json` +- Move: `crates/trusted-server-js/lib/src/core/release_capacity.ts` to + `crates/trusted-server-js/lib/src/kernel/contracts/release_capacity.ts` +- Modify: `crates/trusted-server-core/src/creative.rs` +- Modify: `crates/trusted-server-core/src/html_processor.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/test_support.rs` - Modify: `crates/trusted-server-core/src/tsjs.rs` - Modify: `crates/trusted-server-js/lib/src/core/bootstrap.ts` +- Modify: `crates/trusted-server-js/lib/src/core/contracts/auction_projection.ts` - Modify: `crates/trusted-server-js/lib/src/core/contracts/boot.ts` +- Modify: `crates/trusted-server-js/lib/src/core/contracts/integration_configs.ts` +- Modify: `crates/trusted-server-js/lib/src/core/index.ts` +- Modify: `crates/trusted-server-js/lib/src/core/registry.ts` +- Modify: `crates/trusted-server-js/lib/src/core/types.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/fallback.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/release_catalog.ts` -- Modify: `crates/trusted-server-js/lib/src/shared/first_display_handoff.ts` -- Modify: `crates/trusted-server-js/lib/src/shared/takeover.ts` -- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/src/services/context.ts` +- Modify: `crates/trusted-server-js/lib/vite.config.ts` - Modify: `crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs` +- Modify: `crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts` +- Test: `crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts` +- Test: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts` - Test: `crates/trusted-server-js/lib/test/core/bootstrap.test.ts` +- Test: `crates/trusted-server-js/lib/test/core/index.test.ts` +- Test: `crates/trusted-server-js/lib/test/core/request.test.ts` +- Test: `crates/trusted-server-js/lib/test/kernel/fallback.test.ts` +- Test: `crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts` - Test: `crates/trusted-server-js/lib/test/kernel/runtime.test.ts` -- Test: `crates/trusted-server-js/lib/test/first_display/handoff.test.ts` -- Test: `crates/trusted-server-js/lib/test/first_display/takeover.test.ts` - Test: `crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs` - Test: `crates/trusted-server-js/lib/test/build/release-v1.test.mjs` +- Test: `crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs` - Test: colocated `crates/trusted-server-core/src/tsjs.rs` tests - [ ] **Step 1: Add the RED production-transport tests.** Require Rust to emit one @@ -1158,21 +1178,29 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled prove exact release, manifest, projection, carrier, creative, diagnostics, always-present boot-integrity digests, the exact `outline:null | TakeoverOutlineV1` union and non-null equality, escaping, and 10 - MiB decoded-size behavior. + MiB decoded-size behavior. Pin a shared Rust/JavaScript ECMAScript + `JSON.stringify` corpus covering negative zero, fixed/exponent thresholds, + IEEE-754 rounding, integer-index key ordering, and nested combinations so the + producer and verifier hash identical admitted bytes. - [ ] **Step 2: Add the RED compact-parser and graph tests.** Define the wished-for `snapshotServerBootTransportV1(payload, releaseId)` contract. A canonical valid string returns one recursively frozen fresh snapshot; malformed JSON, wrong or extra root keys, release mismatch, bad first-display URL/mask/slices, bad - runtime URL, missing/malformed boot integrity, outline parity/count/digest form - or integrity mismatch, invalid dedicated booleans, or an oversized string - returns `undefined` before effects. A direct-runtime snapshot has + runtime URL, more than 14 takeover entries, a deferred source not bound to its + declared id, missing/malformed boot integrity, outline parity/count/digest + form or integrity mismatch, invalid dedicated booleans, or an oversized string + returns `undefined` before effects. Boundary-valid takeover counts and exact + deferred id/source pairs remain accepted. A direct-runtime snapshot has `outline:null` but retains the integrity value. Add RED runtime/handoff tests proving direct boot recomputes both digests, and takeover requires recomputed boot = integrity = outline = handoff. Projection and integration-config mismatches independently produce `abi_mismatch` before preparation, - activation, or publication; a valid carrier succeeds on both paths. The release metafile - must prove `bootstrap` no longer reaches `core/contracts/boot.ts`, + activation, or publication; a valid carrier succeeds on both paths. At both + direct and takeover runtime boundaries, accessors are never invoked and custom + prototypes, symbols, sparse arrays, repeated aliases, or any mutable nested + manifest/projection/carrier/diagnostics value fail immediately. The release + metafile must prove `bootstrap` no longer reaches `core/contracts/boot.ts`, `core/contracts/auction_projection.ts`, or `core/contracts/integration_configs.ts`. @@ -1184,7 +1212,11 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled both `initialDisplayCommitted` values, exact FIFO queue drain, accepted-DOM preservation, no pending call, no artifact retry, and both `abi_mismatch`/`bundle_partial` classifications. Fallback never needs the full - projection validator. + projection validator, but `addAdUnits` still validates before refusing. The + registration validator obtains its bounded-string primitive from an + effect-free bootstrap-safe leaf rather than importing the auction-projection + graph. The persistent-core graph must consume a generated numeric manifest + capacity and exclude the build-time release catalog. - [ ] **Step 3: Run RED and inspect the expected failures.** @@ -1199,11 +1231,14 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled absent, and the bootstrap metafile still reaches the full validators. - [ ] **Step 4: Implement the minimal sealed transport.** Canonicalize and validate - in Rust exactly as today, serialize the payload once, escape it as a JS string, - and keep `window.tsjs` target acquisition outside the JSON. In the compact - parser, use the parsed fresh tree, perform only revision-43 critical - release/manifest/integrity/outline checks, recursively freeze, and return one - snapshot. + in Rust, normalize the admitted projection and every generic configuration to + ECMAScript `JSON.stringify`-equivalent bytes, hash and embed those exact bytes, + serialize the payload once, escape it as a JS string, and keep `window.tsjs` + target acquisition outside the JSON. Do not hash raw `serde_json` spellings: + negative zero, exponent formatting, and integer-index key enumeration are + observably different in JavaScript. In the compact parser, use the parsed fresh + tree, perform only revision-43 critical release/manifest/integrity/outline + checks, recursively freeze, and return one snapshot. Do not accept object form or move executable code into the payload. - [ ] **Step 5: Rewire only the production bootstrap.** Import the compact parser, @@ -1214,29 +1249,52 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled projection and integration-config SHA-256 digests before direct use or handoff adoption and compares them with the always-present integrity value; the controller validates only exact digest form and outline/integrity relations. + Reuse the existing pure manifest validator and full auction-projection parser + in persistent core, validate the generic carrier there without importing its + bootstrap-hostile object graph, and split the synchronous SHA-256 primitive + into an effect-free leaf shared by the two boundaries. On rejection after the + one-use exact-source-authenticated claim, invoke its one-use completion + capability with `abi_mismatch` during the current runtime-script task; do not + let the watchdog later relabel a validation failure as `bundle_partial`. The + claim reserves a closure-private `claiming` state before any mutable DOM or + realm authentication boundary, and completion validates with primitive string + comparisons and consumes its guard before invoking a handler; reentrant claim + or completion cannot obtain or consume the capability twice. In + takeover mode, the persistent runtime never publishes fallback itself: it + reports preparation/activation failure through the same completion capability, + and the bootstrap owner alone preserves accepted DOM, snapshots + `initialDisplayCommitted`, disposes the agent, and commits fallback. Direct mode + retains the persistent runtime's existing terminal-fallback ownership. - [ ] **Step 6: Run GREEN and adjacent hard-cutover checks.** ```bash cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" tsjs::tests + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" html_processor::tests + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" injects_tsjs_creative_when_body_present + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" c2_reader_seam_inserts_one_complete_hard_cutover_boot npm --prefix crates/trusted-server-js/lib test -- --run test/core/bootstrap.test.ts test/kernel/runtime.test.ts test/first_display npm --prefix crates/trusted-server-js/lib run typecheck npm --prefix crates/trusted-server-js/lib run lint 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 check:hard-cutover-absence + npx --prefix crates/trusted-server-integration-tests/browser playwright test --list tests/shared/tsjs-runtime.spec.ts tests/shared/creative-sandbox.spec.ts ``` - [ ] **Step 7: Measure rather than infer transfer improvement.** Read the generated bootstrap raw/gzip/Brotli values and its complete source-owner list. If the graph exclusions pass but the served GPT semantic interval cannot fit beneath the exact rc baseline after including its real inline payload, stop and revisit - the architecture instead of adding mangling or changing membership. + the architecture instead of adding mangling or changing membership. Generate + the manifest-entry capacity as a build constant so persistent core does not + import the release catalog for one number; assert both catalog exclusion and + intentional tree-shaking of the declaration module in the release metafile. - [ ] **Step 8: Commit.** ```bash - git add crates/trusted-server-core/src/tsjs.rs crates/trusted-server-js/lib/src/core/bootstrap.ts crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts crates/trusted-server-js/lib/src/core/contracts/boot.ts crates/trusted-server-js/lib/src/kernel/runtime.ts crates/trusted-server-js/lib/src/kernel/release_catalog.ts crates/trusted-server-js/lib/src/shared/first_display_handoff.ts crates/trusted-server-js/lib/src/shared/takeover.ts crates/trusted-server-js/lib/build-all.mjs crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs crates/trusted-server-js/lib/test/core/bootstrap.test.ts crates/trusted-server-js/lib/test/kernel/runtime.test.ts crates/trusted-server-js/lib/test/first_display/handoff.test.ts crates/trusted-server-js/lib/test/first_display/takeover.test.ts crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs crates/trusted-server-js/lib/test/build/release-v1.test.mjs + git add -A docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md crates/trusted-server-core/src/creative.rs crates/trusted-server-core/src/html_processor.rs crates/trusted-server-core/src/publisher.rs crates/trusted-server-core/src/test_support.rs crates/trusted-server-core/src/tsjs.rs crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts crates/trusted-server-js/lib/src/core crates/trusted-server-js/lib/src/kernel/contracts/release_capacity.ts crates/trusted-server-js/lib/src/kernel/fallback.ts crates/trusted-server-js/lib/src/kernel/integration_registry.ts crates/trusted-server-js/lib/src/kernel/runtime.ts crates/trusted-server-js/lib/src/services/context.ts crates/trusted-server-js/lib/vite.config.ts crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs crates/trusted-server-js/lib/test/fixtures/contracts/ecmascript-json-stringify-v1.json crates/trusted-server-js/lib/test/core/bootstrap.test.ts crates/trusted-server-js/lib/test/core/index.test.ts crates/trusted-server-js/lib/test/core/request.test.ts crates/trusted-server-js/lib/test/kernel/fallback.test.ts crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts crates/trusted-server-js/lib/test/kernel/runtime.test.ts crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs crates/trusted-server-js/lib/test/build/release-v1.test.mjs crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs git commit -m "Seal the production TSJS boot transport" ``` 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 58630e247..c1d8392d6 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 @@ -2656,8 +2656,13 @@ canonicalizer and embedded as `boot.auctionProjection`. It computes `integrity.integrationConfigDigest` as lowercase SHA-256 over the exact UTF-8 `JSON.stringify`-equivalent serialization of the ordered `IntegrationConfigsV1` embedded as `boot.integrations`; object insertion order is the wire order and no -insignificant whitespace is present. On an agent page Rust copies those exact values -into the outline rather than hashing again. The compact controller checks integrity +insignificant whitespace is present. Rust must normalize both admitted trees to +ECMAScript `JSON.stringify` spellings and enumeration order before hashing and +embedding; hashing `serde_json` output directly is invalid because negative zero, +fixed/exponent thresholds, IEEE-754 rounding, and integer-index object keys differ. +A shared Rust/JavaScript corpus pins those edge cases and nested combinations. On an +agent page Rust copies those exact values into the outline rather than hashing again. +The compact controller checks integrity and outline digest form and, when the outline is non-null, their exact equality, but does not bundle SHA-256 or the full domain validators. The first-display owner completely validates the batch, retains the always-present sealed integrity values, @@ -3003,7 +3008,8 @@ properties and therefore cannot preserve a source accessor, Proxy, symbol, custo prototype, sparse hole, or repeated object identity. Before any DOM, timer, queue, GPT, attribution, or registration effect, the parser requires the exact root keys and version, embedded release equality, exact critical boot keys, manifest release -and URL/mask/slice relationships, the always-present exact integrity shape and digest +and URL/mask/slice relationships, at most 14 takeover entries, every deferred source +bound to its declared module id, the always-present exact integrity shape and digest forms, null/non-null first-display and outline parity, outline release/slice/count/digest forms and digest equality with integrity, dedicated creative/diagnostics booleans, and the configured decoded-size bound. It recursively freezes the parsed tree, @@ -3016,6 +3022,16 @@ first-display base validates its complete immutable projection/batch before activation; every selected slice validates its attenuated product value before its effect; and persistent core performs the complete manifest, projection, carrier, creative, and diagnostics validation before a direct boot or takeover uses them. +If that post-claim validation fails, persistent core returns `abi_mismatch` through +the one-use completion capability returned by the one-use, +exact-source-authenticated closure claim during the current runtime script task; the +controller commits fallback immediately instead of allowing the load watchdog to +relabel the failure `bundle_partial`. Takeover preparation or activation failure is +reported through that same capability. In takeover mode only the bootstrap owner may +publish terminal fallback: it preserves accepted DOM, snapshots the agent's +`initialDisplayCommitted` state, disposes the provisional agent, and commits once. +The persistent runtime never publishes a competing takeover fallback. On a direct +page, the persistent runtime remains the terminal-fallback owner after a valid claim. The full object-form validators retain their existing hostile-object contract for runtime/public/test boundaries: exact `version`/`entries`, unique ordered ids, plain configs, finite JSON values, dense Arrays, own enumerable data properties, no @@ -3023,6 +3039,19 @@ accessors/symbols/custom prototypes/cycles/aliases, depth 16, 4,096 values, 4,09 keys/strings, 65,536-byte entries, and the 524,288-byte carrier. The inline bootstrap artifact must not import those full validators or the complete auction- projection parser merely to revalidate the trusted lexical producer. +The closure claim atomically reserves a private `claiming` state before it touches +any mutable DOM or realm authentication surface and rolls back only after a failed +authentication. Its completion capability admits outcomes through primitive string +comparisons, consumes its one-use guard before invoking the selected handler, and +therefore remains one-shot under reentrant claim and completion attempts. +The compact fallback's `addAdUnits` surface still runs the exact public registration +validator before it refuses a valid call. That validator imports its bounded-string +primitive from an effect-free bootstrap-safe leaf; it does not make bootstrap reach +the auction-projection parser. +The persistent core receives the manifest-entry capacity as a generated numeric +build constant; it does not import the build-time release catalog merely to learn +that bound. Substitution may tree-shake the declaration module completely, and the +release metafile must prove that the persistent core graph excludes the catalog. The release catalog binds every module id to exactly one config source: its product entry, `creative`, `diagnostics`, or none. The integration's generated exact typed From 82faad032fc5e1fac3a2434510bcdbbbda6122f1 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:54:46 -0700 Subject: [PATCH 840/844] Validate sealed TSJS boot transport in Rust tests --- .../src/integrations/prebid.rs | 12 +++- crates/trusted-server-core/src/publisher.rs | 72 ++++++++++++++----- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 6521d4dde..e51979c40 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -3289,7 +3289,7 @@ mod tests { }; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; - use crate::test_support::tests::create_test_settings; + use crate::test_support::tests::{bootstrap_transport, create_test_settings}; use base64::engine::general_purpose::STANDARD as TEST_BASE64_STANDARD; use bytes::Bytes; use http::Method; @@ -3908,7 +3908,15 @@ excluded_gam_ad_unit_path_suffixes = ["{suffix}"] let shim_index = processed .find("id=\"trustedserver-js\"") .expect("should inject one TSJS runtime tag"); - assert!(processed.contains(r#""id":"prebid","phase":"takeover""#)); + let transport = bootstrap_transport(&processed); + let manifest_integrations = transport["boot"]["manifest"]["integrations"] + .as_array() + .expect("manifest integrations should be an array"); + assert!( + manifest_integrations + .iter() + .any(|entry| { entry["id"] == "prebid" && entry["phase"] == "takeover" }) + ); assert!(!processed.contains("tsjs-prebid.min.js")); assert!( bundle_index < shim_index, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index b0157fc59..58f67a0f9 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5471,6 +5471,19 @@ mod tests { } } + fn boot_auction_id(html: &str) -> String { + bootstrap_transport(html)["boot"]["auctionProjection"]["auction"]["auctionId"] + .as_str() + .expect("sealed auction projection should carry an auction id") + .to_owned() + } + + fn boot_gpt_diagnostics_active(html: &str) -> bool { + bootstrap_transport(html)["boot"]["diagnostics"]["gpt"]["active"] + .as_bool() + .expect("sealed diagnostics should carry the GPT activation state") + } + mod coordinated_cutover_projection_tests { use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -7025,7 +7038,7 @@ mod tests { "should not inject the removed activation flag" ); assert!( - html.contains(r#""gpt":{"active":true}"#), + boot_gpt_diagnostics_active(&html), "should activate diagnostics through immutable boot data" ); assert!(html.contains("tsjs-unified.min.js?v=")); @@ -9551,7 +9564,7 @@ mod tests { "https://origin.test-publisher.com/article?keep=a%2Fb" ); assert_eq!(result.outbound_cookie.as_deref(), Some("publisher=value")); - assert!(body.contains(r#""gpt":{"active":false}"#)); + assert!(!boot_gpt_diagnostics_active(&body)); assert!(!body.contains("tsjs-gpt_diagnostics.min.js")); assert!(!body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); assert_eq!(body.matches("history.replaceState").count(), 1); @@ -9569,7 +9582,7 @@ mod tests { .await; let active_body = response_body_string(active.response); assert_eq!(active.outbound_cookie.as_deref(), Some("publisher=value")); - assert!(active_body.contains(r#""gpt":{"active":true}"#)); + assert!(boot_gpt_diagnostics_active(&active_body)); assert!(active_body.contains("tsjs-unified.min.js?v=")); assert!(!active_body.contains("tsjs-gpt_diagnostics.min.js")); assert!(!active_body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); @@ -9591,7 +9604,7 @@ mod tests { "https://origin.test-publisher.com/article?keep=%2F" ); let disabled_body = response_body_string(disabled.response); - assert!(disabled_body.contains(r#""gpt":{"active":false}"#)); + assert!(!boot_gpt_diagnostics_active(&disabled_body)); assert!(!disabled_body.contains("tsjs-gpt_diagnostics.min.js")); assert!(!disabled_body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); assert_eq!(disabled_body.matches("history.replaceState").count(), 1); @@ -9607,13 +9620,15 @@ mod tests { ) .await; let body = response_body_string(result.response); + let transport = bootstrap_transport(&body); + let diagnostics = &transport["boot"]["diagnostics"]; assert!( - body.contains(r#""renderTraceOverlay":true"#), + diagnostics["renderTraceOverlay"] == true, "the exact server-owned trace cookie must populate DiagnosticsBootV1: {body}" ); assert_eq!( - body.matches(r#""diagnostics":{"version":1,"renderTraceOverlay":true"#) + body.matches("const __TSJS_SERVER_BOOT_TRANSPORT_V1__=") .count(), 1, "the immutable server boot must carry one active diagnostics object" @@ -9637,7 +9652,7 @@ mod tests { 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(r#""gpt":{"active":false}"#)); + assert!(!boot_gpt_diagnostics_active(&body)); assert!(!body.contains("tsjs-gpt_diagnostics.min.js")); assert!(!body.contains("tsjs-gpt_diagnostics-bootstrap.min.js")); assert!(!body.contains("history.replaceState")); @@ -11867,16 +11882,21 @@ mod tests { .expect("stream body with auction should process on async path"); let html = String::from_utf8(output).expect("should be valid UTF-8"); + let auction_id = boot_auction_id(&html); assert!( html.contains("hello"), "should preserve streamed HTML content. Got: {html}" ); assert!( - html.contains(&format!(r#""auctionId":"{}""#, auction_request.id)), - "the immutable pre-core boot must contain the collected auction projection. Got: {html}" + auction_id.starts_with("a1_"), + "the immutable pre-core boot must contain the collected auction projection with a browser-only auction id. Got: {html}" ); assert!( - !html.contains(r#""auctionId":"initial""#), + auction_id != auction_request.id, + "the immutable pre-core boot must not expose the upstream auction id. Got: {html}" + ); + assert!( + auction_id != "initial", "the safe empty projection must not replace a dispatched auction. Got: {html}" ); assert!(!html.contains(".adSlots")); @@ -11946,6 +11966,7 @@ mod tests { .expect("buffered multi-member gzip auction body should process"); let html = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); + let auction_id = boot_auction_id(&html); assert!( html.contains("hello"), "should decode the first gzip member. Got: {html}" @@ -11955,8 +11976,12 @@ mod tests { "should decode the second gzip member that a single-member decoder drops. Got: {html}" ); assert!( - html.contains(r#""auctionId":"test-auction""#), - "should emit the collected projection before the head bundle. Got: {html}" + auction_id.starts_with("a1_"), + "should emit the collected projection with a browser-only auction id before the head bundle. Got: {html}" + ); + assert!( + auction_id != "test-auction", + "should not expose the upstream auction id to the browser. Got: {html}" ); assert!(!html.contains(".bids=")); }); @@ -12321,16 +12346,21 @@ mod tests { let first = first_lazy_body_chunk(body); let html = String::from_utf8(first.to_vec()).expect("should be valid UTF-8"); + let auction_id = boot_auction_id(&html); assert!( html.contains("hello"), "the origin body should remain lazy after the pre-head collect. Got: {html}" ); assert!( - html.contains(r#""auctionId":"test-auction""#), - "the first head chunk must contain the exact collected projection. Got: {html}" + auction_id.starts_with("a1_"), + "the first head chunk must contain the exact collected projection with a browser-only auction id. Got: {html}" + ); + assert!( + auction_id != "test-auction", + "the first head chunk must not expose the upstream auction id. Got: {html}" ); assert!( - !html.contains(r#""auctionId":"initial""#), + auction_id != "initial", "a dispatched auction must not boot with the safe empty projection. Got: {html}" ); assert!(!html.contains(".adSlots")); @@ -12849,9 +12879,15 @@ mod tests { .to_vec(); let html = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); + let auction_id = boot_auction_id(&html); + assert!( + auction_id.starts_with("a1_"), + "should collect the exact projection with a browser-only auction id before the compressed head. Got head: {}", + &html[..html.len().min(500)] + ); assert!( - html.contains(r#""auctionId":"test-auction""#), - "should collect the exact projection before the compressed head. Got head: {}", + auction_id != "test-auction", + "should not expose the upstream auction id in the compressed head. Got head: {}", &html[..html.len().min(500)] ); assert!(!html.contains(".bids=")); @@ -12916,7 +12952,7 @@ mod tests { let html = String::from_utf8(output).expect("should be valid UTF-8"); assert!( - html.contains(r#""auctionId":"initial","results":[]},"slots":[],"bids":[]"#), + boot_auction_id(&html) == "initial", "mixed-case HTML must use the HTML processor and inject the canonical boot projection. Got: {html}" ); assert!( From ad4479c36f8e529d77879b88a9da2e78fd34c582 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:59:36 -0700 Subject: [PATCH 841/844] Thin the APS first-display owner --- .../src/auction/formats.rs | 2 +- .../src/integrations/registry.rs | 74 +- crates/trusted-server-core/src/publisher.rs | 39 +- .../trusted-server-core/src/test_support.rs | 4 + crates/trusted-server-core/src/tsjs.rs | 105 +- .../browser/helpers/tsjs-fixture.ts | 172 +- .../tests/shared/aps-puc-lifecycle.spec.ts | 20 +- .../tests/shared/tsjs-performance.spec.ts | 16 +- .../browser/tests/shared/tsjs-runtime.spec.ts | 271 +++ crates/trusted-server-js/build.rs | 26 +- crates/trusted-server-js/lib/build-all.mjs | 27 +- .../lib/scripts/bundle-metrics.mjs | 14 +- .../lib/scripts/check-bundle-budgets.mjs | 29 +- .../lib/scripts/print-release-id.mjs | 2 +- .../lib/src/core/bootstrap.ts | 63 +- .../lib/src/core/contracts/boot.ts | 3 + .../core/contracts/server_boot_transport.ts | 3 + .../src/first_display/adm_render_bridge.ts | 361 --- .../lib/src/first_display/agent.ts | 102 +- .../lib/src/first_display/composition.ts | 7 + .../src/first_display/leaf/aps_protocol.ts | 89 +- .../lib/src/first_display/render_bridge.ts | 2017 ++++------------- .../lib/src/first_display/render_journal.ts | 1611 +++++++++++++ .../lib/src/first_display/slices/aps.ts | 2 +- .../lib/src/first_display/slices/creative.ts | 2 +- .../lib/src/first_display/slices/datadome.ts | 2 +- .../lib/src/first_display/slices/didomi.ts | 2 +- .../slices/google_tag_manager.ts | 2 +- .../lib/src/first_display/slices/gpt.ts | 2 +- .../lib/src/first_display/slices/lockr.ts | 2 +- .../lib/src/first_display/slices/osano.ts | 2 +- .../lib/src/first_display/slices/permutive.ts | 2 +- .../lib/src/first_display/slices/prebid.ts | 2 +- .../src/first_display/slices/render_owner.ts | 10 + .../src/first_display/slices/sourcepoint.ts | 2 +- .../lib/src/first_display/slices/testlight.ts | 2 +- .../src/integrations/render_runtime/module.ts | 11 +- .../src/kernel/contracts/puc_dynamic_owner.ts | 174 +- .../lib/src/kernel/integration_registry.ts | 2 +- .../lib/src/kernel/release_catalog.ts | 53 +- .../lib/src/shared/first_display_contracts.ts | 13 +- .../src/shared/first_display_registration.ts | 3 +- .../src/shared/first_display_transaction.ts | 83 +- .../test/build/generated-fallback.test.mjs | 50 + .../lib/test/build/release-v1.test.mjs | 100 +- .../lib/test/core/bootstrap.test.ts | 45 + .../first_display/adm_render_bridge.test.ts | 39 +- .../lib/test/first_display/contracts.test.ts | 32 + .../helpers/render_owner_composition.ts | 17 + .../test/first_display/render_bridge.test.ts | 130 +- .../lib/test/first_display/slices.test.ts | 75 +- .../test/first_display/transaction.test.ts | 72 +- .../render_runtime/module.test.ts | 59 + .../lib/test/kernel/release_catalog.test.ts | 59 +- .../lib/test/services/puc_bridge.test.ts | 117 +- crates/trusted-server-js/src/bundle.rs | 14 +- ...8-04-aps-tsjs-resilience-implementation.md | 58 +- ...s-render-fix-and-tsjs-resilience-design.md | 9 + .../validate-tsjs-performance-evidence.mjs | 16 +- 59 files changed, 3926 insertions(+), 2396 deletions(-) delete mode 100644 crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts create mode 100644 crates/trusted-server-js/lib/src/first_display/render_journal.ts create mode 100644 crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts create mode 100644 crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 76bcc30fe..50b6a2fec 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -354,7 +354,7 @@ pub(crate) mod coordinated_cutover_v1 { value.len() == 12 && value.bytes().all(is_base64url_byte) } - fn valid_renderer_reservation_id(value: &str) -> bool { + pub(crate) fn valid_renderer_reservation_id(value: &str) -> bool { value .strip_prefix("r1_") .is_some_and(|token| token.len() == 22 && token.bytes().all(is_base64url_byte)) diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 91a7eba3a..8e128ef0f 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -916,40 +916,46 @@ fn first_display_static_transport_selections( let creative_guard = creative.enabled && (creative.click_guard || creative.render_guard); let mut selections = Vec::new(); for gpt_participates in [false, true] { - for aps_participates in aps_values { - if *aps_participates && !gpt_participates { + for render_owner_participates in [false, true] { + if render_owner_participates && !gpt_participates { continue; } - for prebid_participates in prebid_values { - if *prebid_participates && !gpt_participates { + for aps_participates in aps_values { + if *aps_participates && !render_owner_participates { continue; } - let mut mask = 0_u16; - let mut slices = Vec::new(); - for (index, metadata) in trusted_server_js::all_first_display_metadata() - .into_iter() - .enumerate() - { - let selected = match metadata.include { - Some("eligible_batch") => true, - Some("gpt_initial") => gpt_participates, - Some("aps_participates") => *aps_participates, - Some("creative_guard") => creative_guard, - Some("prebid_participates") => *prebid_participates, - Some(predicate) => predicate - .strip_prefix("integration:") - .is_some_and(|id| inner.enabled_integration_ids.contains(&id)), - None => false, - }; - if selected { - mask |= 1_u16 << index; - slices.push(metadata.id); + for prebid_participates in prebid_values { + if *prebid_participates && !gpt_participates { + continue; + } + let mut mask = 0_u16; + let mut slices = Vec::new(); + for (index, metadata) in trusted_server_js::all_first_display_metadata() + .into_iter() + .enumerate() + { + let selected = match metadata.include { + Some("eligible_batch") => true, + Some("render_owner_participates") => render_owner_participates, + Some("gpt_initial") => gpt_participates, + Some("aps_participates") => *aps_participates, + Some("creative_guard") => creative_guard, + Some("prebid_participates") => *prebid_participates, + Some(predicate) => predicate + .strip_prefix("integration:") + .is_some_and(|id| inner.enabled_integration_ids.contains(&id)), + None => false, + }; + if selected { + mask |= 1_u16 << index; + slices.push(metadata.id); + } + } + if slices.first() == Some(&"first_display") + && trusted_server_js::first_display_mask_is_permitted(mask) + { + selections.push((mask, slices)); } - } - if slices.first() == Some(&"first_display") - && trusted_server_js::first_display_mask_is_permitted(mask) - { - selections.push((mask, slices)); } } } @@ -2730,12 +2736,20 @@ mod tests { .expect("catalog should contain slice") }; let fixed = bit("first_display") | bit("creative_initial"); + let owner = bit("render_owner_initial"); let gpt = bit("gpt_initial"); let aps = bit("aps_initial"); let prebid = bit("prebid_initial"); assert_eq!( masks, - vec![fixed, fixed | gpt, fixed | gpt | aps, fixed | gpt | prebid,], + vec![ + fixed, + fixed | gpt, + fixed | owner | gpt, + fixed | owner | aps | gpt, + fixed | gpt | prebid, + fixed | owner | gpt | prebid, + ], "registry should enumerate every configuration-reachable mask admitted by the fixed transfer ceiling" ); for mask in masks { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 58f67a0f9..03b3d41ce 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -10590,16 +10590,28 @@ mod tests { #[test] fn tsjs_dynamic_serves_only_the_exact_precomputed_first_display_mask_and_hash() { let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "creative", + &serde_json::json!({ + "enabled": false, + "click_guard": false, + "render_guard": false + }), + ) + .expect("should disable the creative guard"); settings .integrations .insert_config("gpt", &serde_json::json!({})) .expect("should enable GPT"); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); - let mask = *registry - .tsjs_first_display_masks() - .first() - .expect("GPT should permit one first-display mask"); + let mask = 0x0083; + assert!( + registry.tsjs_first_display_masks().contains(&mask), + "GPT must precompute the ADM render-owner mask" + ); let selected = trusted_server_js::all_first_display_ids() .into_iter() .enumerate() @@ -10669,6 +10681,25 @@ mod tests { "case {suffix}" ); } + + let owner_hash = + trusted_server_js::concatenated_first_display_hash(&["render_owner_initial"]) + .expect("render owner should have one generated component hash"); + let owner_request = build_request( + Method::GET, + &format!( + "https://publisher.example/static/tsjs=tsjs-render_owner_initial.min.js?v={owner_hash}" + ), + ); + let owner_response = + handle_tsjs_dynamic(&owner_request, ®istry, EdgeCacheHeader::SMaxageFallback) + .expect("standalone owner route should reject locally"); + assert_eq!(owner_response.status(), StatusCode::NOT_FOUND); + assert_eq!( + owner_response.headers().get(header::CACHE_CONTROL), + Some(&HeaderValue::from_static("no-store")) + ); + assert!(!owner_response.headers().contains_key(header::LOCATION)); } #[test] diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index a01c2ad03..d6a4ec8db 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -4,6 +4,10 @@ pub mod tests { use serde::Deserialize as _; /// Decode the one server-sealed lexical TSJS boot transport from generated HTML. + /// + /// # Panics + /// + /// Panics if the script omits the sealed transport or contains invalid JSON. #[must_use] pub fn bootstrap_transport(script: &str) -> serde_json::Value { let encoded = script diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index ff7133b37..73c5e7333 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -922,12 +922,23 @@ pub fn select_first_display_slices_v1( } match &bid.render_source { BidRenderSourceV1::Aps(_) => { + if !bid.renderer_reservation_id.as_deref().is_some_and( + crate::auction::formats::coordinated_cutover_v1::valid_renderer_reservation_id, + ) { + return None; + } if !enabled.contains("aps") { return None; } aps_participates = true; } - BidRenderSourceV1::Adm(_) => {} + BidRenderSourceV1::Adm(_) => { + if !bid.renderer_reservation_id.as_deref().is_some_and( + crate::auction::formats::coordinated_cutover_v1::valid_renderer_reservation_id, + ) { + return None; + } + } BidRenderSourceV1::PbsCache(_) => return None, } winner_count += 1; @@ -941,6 +952,7 @@ pub fn select_first_display_slices_v1( && config.creative.enabled && (config.creative.click_guard || config.creative.render_guard); let gpt_participates = winner_count > 0 || config.gam_attribution_enabled; + let render_owner_participates = winner_count > 0; let prebid_participates = enabled.contains("prebid") && projection.bids.iter().any(|bid| bid.provider == "prebid"); let mut mask = 0_u16; @@ -951,6 +963,7 @@ pub fn select_first_display_slices_v1( { let selected = match metadata.include { Some("eligible_batch") => true, + Some("render_owner_participates") => render_owner_participates, Some("aps_participates") => aps_participates, Some("creative_guard") => creative_guard, Some("gpt_initial") => gpt_participates, @@ -1643,7 +1656,7 @@ mod tests { "typed GAM attribution must select the GPT parser-time owner: {script}" ); assert!( - script.contains("m=0041"), + script.contains("m=0081"), "attribution-only first display must use the admitted GPT mask: {script}" ); } @@ -2103,7 +2116,7 @@ mod tests { targeting: std::collections::BTreeMap::new(), renderer_reservation_id: match render_source { BidRenderSourceV1::Aps(_) | BidRenderSourceV1::Adm(_) => { - Some("reservation-1".to_string()) + Some("r1_aaaaaaaaaaaaaaaaaaaaaa".to_string()) } BidRenderSourceV1::PbsCache(_) => None, }, @@ -2188,8 +2201,28 @@ mod tests { }, ) .expect("closed GPT ADM batch should select GPT initial ownership"); - assert_eq!(adm_selected.mask(), 0x0041); - assert_eq!(adm_selected.slices(), &["first_display", "gpt_initial"]); + assert_eq!(adm_selected.mask(), 0x0083); + assert_eq!( + adm_selected.slices(), + &["first_display", "render_owner_initial", "gpt_initial"] + ); + + for invalid in [None, Some("not-a-reservation".to_string())] { + let mut malformed = adm.clone(); + malformed.bids[0].renderer_reservation_id = invalid; + assert!( + select_first_display_slices_v1( + &malformed, + FirstDisplaySelectionConfigV1 { + enabled_integrations: &enabled, + creative: CreativeBootConfigV1::default(), + gam_attribution_enabled: false, + }, + ) + .is_none(), + "a rendering mask must require one exact reservation capability" + ); + } assert!( select_first_display_slices_v1( @@ -2237,9 +2270,14 @@ mod tests { assert_eq!( creative_selected.slices(), - &["first_display", "creative_initial", "gpt_initial"] + &[ + "first_display", + "render_owner_initial", + "creative_initial", + "gpt_initial", + ] ); - assert_eq!(creative_selected.mask(), 0x0045); + assert_eq!(creative_selected.mask(), 0x008b); let aps_selected = select_first_display_slices_v1( &first_display_projection(Some(aps_source())), @@ -2252,9 +2290,29 @@ mod tests { .expect("bounded APS/GPT configuration should select the agent"); assert_eq!( aps_selected.slices(), - &["first_display", "aps_initial", "gpt_initial"] + &[ + "first_display", + "render_owner_initial", + "aps_initial", + "gpt_initial", + ] ); - assert_eq!(aps_selected.mask(), 0x0043); + assert_eq!(aps_selected.mask(), 0x0087); + + let aps_creative_selected = select_first_display_slices_v1( + &first_display_projection(Some(aps_source())), + FirstDisplaySelectionConfigV1 { + enabled_integrations: &["aps", "creative", "gpt"], + creative: CreativeBootConfigV1 { + enabled: true, + click_guard: true, + render_guard: false, + }, + gam_attribution_enabled: false, + }, + ) + .expect("bounded APS/creative/GPT configuration should select the agent"); + assert_eq!(aps_creative_selected.mask(), 0x008f); let mut prebid_projection = first_display_projection(Some(adm_source())); prebid_projection.bids[0].provider = "prebid".to_owned(); @@ -2269,9 +2327,14 @@ mod tests { .expect("bounded Prebid/GPT configuration should select the agent"); assert_eq!( prebid_selected.slices(), - &["first_display", "gpt_initial", "prebid_initial"] + &[ + "first_display", + "render_owner_initial", + "gpt_initial", + "prebid_initial", + ] ); - assert_eq!(prebid_selected.mask(), 0x0841); + assert_eq!(prebid_selected.mask(), 0x1083); } #[test] @@ -2292,10 +2355,26 @@ mod tests { ]; let mut projection = first_display_projection(Some(adm_source())); projection.bids[0].provider = "prebid".to_owned(); + assert!( + select_first_display_slices_v1( + &projection, + FirstDisplaySelectionConfigV1 { + enabled_integrations: &enabled, + creative: CreativeBootConfigV1 { + enabled: true, + click_guard: false, + render_guard: true, + }, + gam_attribution_enabled: false, + }, + ) + .is_none(), + "an oversized optional composition must fall through to persistent boot" + ); let selected = select_first_display_slices_v1( &projection, FirstDisplaySelectionConfigV1 { - enabled_integrations: &enabled, + enabled_integrations: &["creative", "gpt", "prebid"], creative: CreativeBootConfigV1 { enabled: true, click_guard: false, @@ -2304,7 +2383,7 @@ mod tests { gam_attribution_enabled: false, }, ) - .expect("the optimized closed composition should fit the generated budget"); + .expect("the bounded owner/creative/GPT/Prebid composition should fit"); assert!(trusted_server_js::first_display_mask_is_permitted( selected.mask() )); diff --git a/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts b/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts index b7ebc8ecc..10dd1595c 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts @@ -8,8 +8,13 @@ const DIST = resolve(TSJS_CRATE, "dist"); interface ReleaseArtifact { id: string; - role: "bootstrap" | "core" | "integration"; - phase: "takeover" | "deferred" | null; + role: + | "bootstrap" + | "core" + | "integration" + | "first_display_base" + | "first_display_slice"; + phase: "first_display" | "takeover" | "deferred" | null; file: string; } @@ -33,6 +38,43 @@ export interface RuntimeTsjsFixture { }; } +export interface FirstDisplayTsjsFixture { + releaseId: string; + bootstrapBody: string; + firstDisplayBody: string; + firstDisplaySrc: string; + runtimeBody: string; + runtimeSrc: string; + boot: Readonly>; + outline: Readonly>; +} + +interface FirstDisplayTsjsFixtureInput { + readonly auctionProjection: Readonly>; + readonly integrations: Readonly>; + readonly creative?: Readonly>; + readonly diagnostics?: Readonly>; + readonly firstDisplayIds: readonly string[]; + readonly takeoverIds: readonly string[]; +} + +const FIRST_DISPLAY_IDS = Object.freeze([ + "first_display", + "render_owner_initial", + "aps_initial", + "creative_initial", + "datadome_initial", + "didomi_initial", + "google_tag_manager_initial", + "gpt_initial", + "lockr_initial", + "osano_initial", + "permutive_initial", + "sourcepoint_initial", + "prebid_initial", + "testlight_initial", +] as const); + interface ServerBootDigestInputV1 { readonly auctionProjection: unknown; readonly integrations: unknown; @@ -73,14 +115,25 @@ function exactArtifact(release: Release, id: string): ReleaseArtifact { return matches[0]!; } -/** Build the same content-addressed persistent-runtime response and manifest as production. */ -export function runtimeTsjsFixture(ids: readonly string[]): RuntimeTsjsFixture { +function jsonDigest(value: unknown): string { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error("TSJS fixture value is not JSON"); + return createHash("sha256").update(encoded, "utf8").digest("hex"); +} + +function releaseFixture(): { release: Release; dist: string } { const release = JSON.parse( readFileSync(resolve(DIST, "tsjs-release-v1.json"), "utf8"), ) as Release; if (release.version !== 1 || !/^[0-9a-f]{64}$/u.test(release.releaseId)) { throw new Error("TSJS release manifest is invalid"); } + return { release, dist: DIST }; +} + +/** Build the same content-addressed persistent-runtime response and manifest as production. */ +export function runtimeTsjsFixture(ids: readonly string[]): RuntimeTsjsFixture { + const { release } = releaseFixture(); const artifacts = [exactArtifact(release, "core")].concat( ids.map((id) => exactArtifact(release, id)), ); @@ -113,6 +166,117 @@ export function runtimeTsjsFixture(ids: readonly string[]): RuntimeTsjsFixture { }; } +/** Build one exact parser-blocking first-display mask and its persistent takeover. */ +export function firstDisplayTsjsFixture( + input: FirstDisplayTsjsFixtureInput, +): FirstDisplayTsjsFixture { + const { release, dist } = releaseFixture(); + const firstDisplayArtifacts = input.firstDisplayIds.map((id, index) => { + const expectedIndex = FIRST_DISPLAY_IDS.indexOf( + id as (typeof FIRST_DISPLAY_IDS)[number], + ); + if ( + expectedIndex < 0 || + (index > 0 && + expectedIndex <= + FIRST_DISPLAY_IDS.indexOf( + input.firstDisplayIds[ + index - 1 + ] as (typeof FIRST_DISPLAY_IDS)[number], + )) + ) { + throw new Error(`TSJS first-display fixture order is invalid at ${id}`); + } + const artifact = exactArtifact(release, id); + if (artifact.phase !== "first_display") { + throw new Error(`TSJS fixture module ${id} is not first-display`); + } + return artifact; + }); + if (input.firstDisplayIds[0] !== "first_display") { + throw new Error("TSJS first-display fixture requires the base slice"); + } + const mask = input.firstDisplayIds.reduce((value, id) => { + const index = FIRST_DISPLAY_IDS.indexOf( + id as (typeof FIRST_DISPLAY_IDS)[number], + ); + return value | (1 << index); + }, 0); + const firstDisplayBody = firstDisplayArtifacts + .map((artifact) => readFileSync(resolve(dist, artifact.file), "utf8")) + .join(";\n"); + const firstDisplayHash = createHash("sha256") + .update(firstDisplayBody, "utf8") + .digest("hex"); + const firstDisplaySrc = `/static/tsjs=tsjs-first-display.min.js?m=${mask + .toString(16) + .padStart(4, "0")}&v=${firstDisplayHash}`; + const runtime = runtimeTsjsFixture(input.takeoverIds); + const manifest = { + ...runtime.manifest, + firstDisplay: { + src: firstDisplaySrc, + slices: [...input.firstDisplayIds], + }, + }; + const creative = input.creative ?? { + version: 1, + enabled: false, + clickGuard: false, + renderGuard: false, + }; + const diagnostics = input.diagnostics ?? { + version: 1, + renderTraceOverlay: false, + gpt: { active: false }, + }; + const boot = { + abi: 1, + releaseId: release.releaseId, + manifest, + auctionProjection: input.auctionProjection, + integrations: input.integrations, + creative, + diagnostics, + }; + const projectionDigest = jsonDigest(input.auctionProjection); + const integrationConfigDigest = jsonDigest(input.integrations); + const slots = input.auctionProjection.slots; + const outcomes = ( + input.auctionProjection.auction as Readonly> + ).results; + const bids = input.auctionProjection.bids; + if ( + !Array.isArray(slots) || + !Array.isArray(outcomes) || + !Array.isArray(bids) + ) { + throw new Error("TSJS first-display fixture projection is invalid"); + } + const outline = { + version: 1, + releaseId: release.releaseId, + generation: 1, + projectionDigest, + integrationConfigDigest, + slices: [...input.firstDisplayIds], + slotCount: slots.length, + outcomeCount: outcomes.length, + capabilities: [], + objectKinds: bids.length === 0 ? [] : ["gpt_slot", "dom_artifact"], + }; + return { + releaseId: release.releaseId, + bootstrapBody: runtime.bootstrapBody, + firstDisplayBody, + firstDisplaySrc, + runtimeBody: runtime.runtimeBody, + runtimeSrc: runtime.runtimeSrc, + boot, + outline, + }; +} + /** Serve a fixture from the exact content-addressed production route. */ export async function routeRuntimeTsjsFixture( page: Page, diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts index 9d8d543b3..5ea4ce5dc 100644 --- a/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts +++ b/crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts @@ -38,12 +38,12 @@ function apsDescriptor() { creativeId: "fictional-creative", tagType: bid.ext.tagtype, creativeUrl: bid.ext.creativeurl, - width: bid.w, - height: bid.h, aaxResponse: Buffer.from( JSON.stringify({ seatbid: [{ bid: [bid] }] }), "utf8", ).toString("base64"), + width: bid.w, + height: bid.h, }; } @@ -184,7 +184,21 @@ async function openLifecyclePage( ).tsjs?._internal?.state, ), ) - .toBe("kernel"); + .toMatch(/^(?:kernel|fallback)$/u); + const runtimeState = await page.evaluate(() => { + const browserWindow = window as unknown as { + tsjs?: { _internal?: unknown }; + }; + return { + internal: browserWindow.tsjs?._internal, + marks: performance + .getEntriesByType("mark") + .map((entry) => ({ name: entry.name, startTime: entry.startTime })), + }; + }); + expect(runtimeState.internal, JSON.stringify(runtimeState)).toMatchObject({ + state: "kernel", + }); await expect .poll(() => page.evaluate(() => 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 375a2f193..0a7b212d4 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 @@ -362,11 +362,17 @@ function loadReleaseFixtureResources( performanceCase === "aps" ? ([ "first_display", + "render_owner_initial", "aps_initial", "creative_initial", "gpt_initial", ] as const) - : (["first_display", "creative_initial", "gpt_initial"] as const); + : ([ + "first_display", + "render_owner_initial", + "creative_initial", + "gpt_initial", + ] as const); const firstDisplayArtifacts = firstDisplayIds.map((id) => exactArtifact(release, id), ); @@ -376,7 +382,7 @@ function loadReleaseFixtureResources( .map((artifact) => readFileSync(resolve(dist, artifact.file), "utf8")) .join(";\n"); const selectedHash = createHash("sha256").update(selectedBody).digest("hex"); - const mask = performanceCase === "aps" ? "0047" : "0045"; + const mask = performanceCase === "aps" ? "008f" : "008b"; const selectedSrc = `/static/tsjs=tsjs-first-display.min.js?m=${mask}&v=${selectedHash}`; const deferred = new Map(); for (const id of DEFERRED_IDS) { @@ -432,8 +438,10 @@ function loadReleaseFixtureResources( expect(controllerDocument.match(/tsjs:bids-script/gu)).toHaveLength(2); expect(controllerDocument.match(/id="trustedserver-js"/gu)).toHaveLength(1); expect(controllerDocument).toContain(selectedTag); - expect(controllerDocument).toContain(`"releaseId":"${release.releaseId}"`); - expect(controllerDocument).toContain(`"runtimeSrc":"${runtimeSrc}"`); + expect(controllerDocument).toContain( + `\\"releaseId\\":\\"${release.releaseId}\\"`, + ); + expect(controllerDocument).toContain(`\\"runtimeSrc\\":\\"${runtimeSrc}\\"`); expect(controllerDocument).not.toContain( ``, ); 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 index b751fe447..b7a117f11 100644 --- 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 @@ -1,5 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; +import { installGptStub } from "../../helpers/gpt-stub.js"; import { + firstDisplayTsjsFixture, loadRuntimeTsjsFixture, runtimeTsjsFixture, serverBootTransportLiteralV1, @@ -8,6 +10,65 @@ import { const KERNEL_FIXTURE = runtimeTsjsFixture(["render_runtime"]); const FALLBACK_FIXTURE = runtimeTsjsFixture([]); +const FIRST_DISPLAY_SLOT = "first-display-owner-slot"; +const FIRST_DISPLAY_FIXTURE = firstDisplayTsjsFixture({ + firstDisplayIds: ["first_display", "render_owner_initial", "gpt_initial"], + takeoverIds: ["render_runtime", "gpt"], + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: "browser-first-display-owner", + results: [ + { + slot: FIRST_DISPLAY_SLOT, + outcome: "winner", + candidateId: "AAAAAAAAAAAA", + }, + ], + }, + slots: [ + { + slot: FIRST_DISPLAY_SLOT, + gamUnitPath: `/123/${FIRST_DISPLAY_SLOT}`, + divId: FIRST_DISPLAY_SLOT, + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [ + { + candidateId: "AAAAAAAAAAAA", + slot: FIRST_DISPLAY_SLOT, + provider: "fictional", + upstreamBidId: "first-display-owner-bid", + cpm: 1.25, + currency: "USD", + targeting: { hb_bidder: "fictional" }, + rendererReservationId: "r1_AAAAAAAAAAAAAAAAAAAAAA", + renderSource: { + type: "adm", + version: 1, + adm: "
fictional first-display creative
", + width: 300, + height: 250, + }, + }, + ], + }, + integrations: { + version: 1, + entries: [ + { + id: "gpt", + config: { + gamAttributionEnabled: false, + pageBidsEnabled: true, + }, + }, + ], + }, +}); function boot(fixture: RuntimeTsjsFixture) { return { @@ -61,7 +122,217 @@ async function openRuntimePage(page: Page) { await page.goto("https://runtime.test/fixture"); } +async function installFirstDisplayResourceProbe(page: Page): Promise { + await page.addInitScript(() => { + const events: Array<{ kind: string; beforePaint: boolean }> = []; + const record = (kind: string): void => { + events.push({ + kind, + beforePaint: + performance.getEntriesByName("tsjs:first-display-paint", "mark") + .length === 0, + }); + }; + + const nativeCreateElement = Document.prototype.createElement; + Document.prototype.createElement = function ( + qualifiedName: string, + options?: ElementCreationOptions, + ): HTMLElement { + const normalized = String(qualifiedName).toLowerCase(); + if (normalized === "script" || normalized === "link") { + record(`create:${normalized}`); + } + return nativeCreateElement.call(this, qualifiedName, options); + }; + + const nativeFetch = window.fetch; + window.fetch = ((...arguments_: Parameters) => { + record("fetch"); + return Reflect.apply(nativeFetch, window, arguments_); + }) as typeof window.fetch; + + const nativeXhrOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (...arguments_: unknown[]): void { + record("xhr"); + Reflect.apply(nativeXhrOpen, this, arguments_); + } as typeof XMLHttpRequest.prototype.open; + + const wrapConstructor = (name: "Worker" | "SharedWorker"): void => { + const nativeConstructor = Reflect.get(window, name); + if (typeof nativeConstructor !== "function") return; + const wrapped = function (this: unknown, ...arguments_: unknown[]) { + record(name.toLowerCase()); + return Reflect.construct(nativeConstructor, arguments_, new.target); + }; + Object.setPrototypeOf(wrapped, nativeConstructor); + Object.defineProperty(wrapped, "prototype", { + value: nativeConstructor.prototype, + }); + Reflect.set(window, name, wrapped); + }; + wrapConstructor("Worker"); + wrapConstructor("SharedWorker"); + + const nativeCreateObjectUrl = URL.createObjectURL; + URL.createObjectURL = ((object: Blob | MediaSource): string => { + record("blob-url"); + return Reflect.apply(nativeCreateObjectUrl, URL, [object]) as string; + }) as typeof URL.createObjectURL; + + Object.defineProperty(window, "__firstDisplayResourceProbe", { + value: () => events.map((entry) => ({ ...entry })), + }); + }); +} + test.describe("TSJS hard-cutover runtime", () => { + test("loads the render owner only inside one parser-blocking first-display request before paint", async ({ + page, + }) => { + await installGptStub(page); + await installFirstDisplayResourceProbe(page); + const firstDisplayUrl = new URL( + FIRST_DISPLAY_FIXTURE.firstDisplaySrc, + "https://runtime.test", + ).toString(); + const runtimeUrl = new URL( + FIRST_DISPLAY_FIXTURE.runtimeSrc, + "https://runtime.test", + ).toString(); + const firstDisplayRequests: string[] = []; + const allRequests: string[] = []; + const pageErrors: string[] = []; + const consoleMessages: string[] = []; + page.on("request", (request) => allRequests.push(request.url())); + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => + consoleMessages.push(`${message.type()}: ${message.text()}`), + ); + await page.route(firstDisplayUrl, (route) => { + firstDisplayRequests.push(route.request().url()); + return route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: FIRST_DISPLAY_FIXTURE.firstDisplayBody, + }); + }); + await page.route(runtimeUrl, (route) => + route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: FIRST_DISPLAY_FIXTURE.runtimeBody, + }), + ); + await page.route("https://runtime.test/first-display-owner", (route) => + route.fulfill({ + status: 200, + contentType: "text/html; charset=utf-8", + body: `
`, + }), + ); + + await page.goto("https://runtime.test/first-display-owner"); + await expect + .poll(() => + page.evaluate( + () => + performance.getEntriesByName("tsjs:first-display-paint", "mark") + .length, + ), + ) + .toBe(1); + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + tsjs?: { _internal?: { state?: string } }; + } + ).tsjs?._internal?.state, + ), + ) + .toMatch(/^(?:kernel|fallback)$/u); + const runtimeDiagnostic = await page.evaluate(() => ({ + internal: ( + window as unknown as { + tsjs?: { _internal?: unknown }; + } + ).tsjs?._internal, + marks: performance + .getEntriesByType("mark") + .map((entry) => ({ name: entry.name, startTime: entry.startTime })), + frames: [...document.querySelectorAll("iframe")].map((frame) => ({ + connected: frame.isConnected, + parent: frame.parentElement?.id ?? null, + title: frame.title, + })), + })); + expect( + runtimeDiagnostic.internal, + JSON.stringify({ + ...runtimeDiagnostic, + allRequests, + consoleMessages, + firstDisplayRequests, + pageErrors, + }), + ).toMatchObject({ state: "kernel" }); + + const observation = await page.evaluate((slotId) => { + const browserWindow = window as unknown as { + __firstDisplayParserObservation: { + async: boolean; + defer: boolean; + firstAction: number; + }; + __firstDisplayResourceProbe(): Array<{ + kind: string; + beforePaint: boolean; + }>; + }; + return { + parser: browserWindow.__firstDisplayParserObservation, + loaderCallsBeforePaint: browserWindow + .__firstDisplayResourceProbe() + .filter((entry) => entry.beforePaint) + .map((entry) => entry.kind), + loaderCallsAfterPaint: browserWindow + .__firstDisplayResourceProbe() + .filter((entry) => !entry.beforePaint) + .map((entry) => entry.kind), + firstDisplayScripts: document.querySelectorAll( + "script#trustedserver-js", + ).length, + creativeFrames: document.querySelectorAll( + `#${slotId} > iframe[title="Ad content"]`, + ).length, + }; + }, FIRST_DISPLAY_SLOT); + + expect(firstDisplayRequests).toEqual([firstDisplayUrl]); + expect(allRequests).toEqual([ + "https://runtime.test/first-display-owner", + firstDisplayUrl, + runtimeUrl, + ]); + expect( + allRequests.filter((url) => /tsjs-render_owner_initial/u.test(url)), + ).toEqual([]); + expect(observation.parser).toEqual({ + async: false, + defer: false, + firstAction: 1, + }); + expect(observation.loaderCallsBeforePaint).toEqual([]); + expect(observation.loaderCallsAfterPaint).toEqual(["create:script"]); + expect(observation.firstDisplayScripts).toBe(1); + expect(observation.creativeFrames).toBe(1); + }); + test("generated bootstrap transfers one direct-runtime watchdog to the persistent owner", async ({ page, }) => { diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index ebcaffd4f..7cd37b913 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -158,8 +158,8 @@ fn read_and_validate_catalog(dist_dir: &Path, release: &ReleaseManifest) -> Cata .collect::>(); assert_eq!( catalog.first_display.len(), - 13, - "tsjs: first-display catalog must contain base plus twelve slices" + 14, + "tsjs: first-display catalog must contain base plus thirteen slices" ); assert_eq!( first_display_artifacts.len(), @@ -181,6 +181,7 @@ fn read_and_validate_catalog(dist_dir: &Path, release: &ReleaseManifest) -> Cata .unwrap_or_else(|| panic!("tsjs: first-display catalog is missing {id}")) }; let gpt_mask = mask_bit("gpt_initial"); + let render_owner_mask = mask_bit("render_owner_initial"); let aps_mask = mask_bit("aps_initial"); let prebid_mask = mask_bit("prebid_initial"); for encoded in &catalog.permitted_first_display_masks { @@ -203,8 +204,12 @@ fn read_and_validate_catalog(dist_dir: &Path, release: &ReleaseManifest) -> Cata "tsjs: permitted first-display mask contains an unknown slice bit" ); assert!( - mask & (aps_mask | prebid_mask) == 0 || mask & gpt_mask != 0, - "tsjs: APS and Prebid participation require GPT initial ownership" + mask & (render_owner_mask | aps_mask | prebid_mask) == 0 || mask & gpt_mask != 0, + "tsjs: render-owner, APS, and Prebid participation require GPT initial ownership" + ); + assert!( + mask & aps_mask == 0 || mask & render_owner_mask != 0, + "tsjs: APS participation requires render-owner initial ownership" ); assert!( previous_mask.is_none_or(|previous| mask > previous), @@ -241,6 +246,7 @@ fn read_and_validate_catalog(dist_dir: &Path, release: &ReleaseManifest) -> Cata assert!(!module.obligation.is_empty()); assert!( module.include == "eligible_batch" + || module.include == "render_owner_participates" || module.include == "aps_participates" || module.include == "creative_guard" || module.include == "gpt_initial" @@ -295,15 +301,15 @@ fn read_and_validate_release(dist_dir: &Path) -> ReleaseManifest { ); assert_eq!( manifest.artifacts.len(), - 35, - "tsjs: release must contain bootstrap, thirteen first-display components, core, and twenty integrations" + 36, + "tsjs: release must contain bootstrap, fourteen first-display components, core, and twenty integrations" ); assert_eq!(manifest.artifacts[0].id, "bootstrap"); assert_eq!(manifest.artifacts[0].role, "bootstrap"); assert_eq!(manifest.artifacts[1].id, "first_display"); assert_eq!(manifest.artifacts[1].role, "first_display_base"); - assert_eq!(manifest.artifacts[14].id, "core"); - assert_eq!(manifest.artifacts[14].role, "core"); + assert_eq!(manifest.artifacts[15].id, "core"); + assert_eq!(manifest.artifacts[15].role, "core"); let mut canonical = Vec::new(); canonical.extend_from_slice(RELEASE_PREFIX); @@ -317,12 +323,12 @@ fn read_and_validate_release(dist_dir: &Path) -> ReleaseManifest { assert!(artifact.phase.is_none() && artifact.trigger.is_none()); } "first_display_base" | "first_display_slice" => { - assert!((1..=13).contains(&index)); + assert!((1..=14).contains(&index)); assert_eq!(artifact.phase.as_deref(), Some("first_display")); assert!(artifact.trigger.is_none()); } "integration" => { - assert!(index >= 15); + assert!(index >= 16); if integration_index < 14 { assert_eq!(artifact.phase.as_deref(), Some("takeover")); assert!(artifact.trigger.is_none()); diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 54dae073c..f5cab24ec 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -35,10 +35,14 @@ const bootstrapPrivateProperties = const firstDisplayBasePrivateProperties = /^(?:options|stateValue|agentBatch|slotResults|handoffOwner|mutationObserver|observedMutationRevision|displayWasCommitted|sealed|failed|pending|reasons|actionStarted|disposedDriver|handoffFinalized|committedArtifactsDetached|lastTimingMs|firstActionAtMs|terminalAtMs|paintAtMs|nextTraceSequence|acceptedTrace|recordTerminal|recordFirstAction|scheduleProtectedPaint|readTiming|captureHandoffData|disposeDriver|installNativeMutationIngress|observeDomMutations|isOwnedRuntimeInsertion|closeNativeMutationIngress|disposeNativeMutationIngress|claimTimer|completionTimer|controlRelease|directFrame|documentAccepted|documentAcceptancePending|documentRelease|documentTimer|documentTransferred|insertionTimer|pendingDocumentTerminal|ownerSource|ownerTicket|bootstrapNonce|rendererNonce|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|recordFailure)$/; -// APS adds one private render state machine inside its independently built slice. -// These fields never enter registration, message, or handoff objects. +// The source-neutral owner keeps these fields inside one independently built IIFE. +const firstDisplayRenderOwnerPrivateProperties = + /^(?:claimTimer|controlRelease|insertionTimer|ownerSource|ownerTicket|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|execution|originalCount|exact|exactShape|ports|port|source)$/; + +// APS keeps these renderer-specific fields inside its independently built IIFE. +// Cross-artifact strategy, protocol, callback, and artifact keys remain authored. const firstDisplayApsPrivateProperties = - /^(?:options|stateValue|agentBatch|slotResults|handoffOwner|mutationObserver|observedMutationRevision|displayWasCommitted|sealed|failed|pending|reasons|actionStarted|disposedDriver|handoffFinalized|committedArtifactsDetached|lastTimingMs|firstActionAtMs|terminalAtMs|paintAtMs|nextTraceSequence|acceptedTrace|recordTerminal|recordFirstAction|scheduleProtectedPaint|readTiming|captureHandoffData|disposeDriver|installNativeMutationIngress|observeDomMutations|isOwnedRuntimeInsertion|closeNativeMutationIngress|disposeNativeMutationIngress|claimTimer|completionTimer|controlRelease|directFrame|documentAccepted|documentAcceptancePending|documentRelease|documentTimer|documentTransferred|insertionTimer|pendingDocumentTerminal|ownerSource|ownerTicket|bootstrapNonce|rendererNonce|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|recordFailure|hostPositionOwned|previousHostPosition|previousHostPositionPriority|frameAttributes|frameContentWindow|frameSource|frameSourceDocument|bootstrapNavigated|bootstrapPolicy|bootstrapSource|originalCount|isReservationId|isLifecycleTicket|isBootstrapNonce|isRendererNonce|parseDocumentMessage|rendererUrl|sandbox|permanentSandbox|deadlines|insertionMs|documentAcceptanceMs|completionMs|ownerSettlementMs)$/; + /^(?:callbacks|overlay|active|accepted|bootstrapNavigated|bootstrapNonceInternal|bootstrapSource|completionTimer|cycle|documentAccepted|documentPort|documentRelease|documentTimer|frame|hostPositionOwned|pendingTerminal|previousHostPosition|previousHostPositionPriority|rendererNonceInternal|originalCount|exact|ports|publisherOrigin|rendererUrl|sandbox|permanentSandbox|deadlines|documentAcceptanceMs|completionMs|isBootstrapNonce|isRendererNonce|bootstrapPolicy|parseDocumentMessage|parseWindowMessage)$/; // These names are private to the GPT initial IIFE and never cross its protocol // receipt or handoff boundary. Keep the cross-artifact protocol keys authored. @@ -68,8 +72,8 @@ const releaseCatalog = catalogModule.RELEASE_CATALOG; const firstDisplayCatalog = catalogModule.FIRST_DISPLAY_CATALOG; catalogModule.validateReleaseCatalog(releaseCatalog); if (releaseCatalog.length !== 20) throw new Error('[build-all] Catalog must contain 20 rows'); -if (firstDisplayCatalog.length !== 13 || firstDisplayCatalog[0]?.id !== 'first_display') { - throw new Error('[build-all] First-display catalog must contain its base and twelve slices'); +if (firstDisplayCatalog.length !== 14 || firstDisplayCatalog[0]?.id !== 'first_display') { + throw new Error('[build-all] First-display catalog must contain its base and thirteen slices'); } const runtimeCatalog = releaseCatalog.map(({ id, phase, trigger, config, consumes, provides }) => ({ id, @@ -106,6 +110,7 @@ const sourceById = Object.freeze({ const firstDisplaySourceById = Object.freeze({ first_display: 'first_display/agent.ts', + render_owner_initial: 'first_display/slices/render_owner.ts', aps_initial: 'first_display/slices/aps.ts', creative_initial: 'first_display/slices/creative.ts', datadome_initial: 'first_display/slices/datadome.ts', @@ -206,11 +211,13 @@ async function buildArtifact(artifact) { ? { esbuild: { mangleProps: bootstrapPrivateProperties } } : artifact.id === 'first_display' ? { esbuild: { mangleProps: firstDisplayBasePrivateProperties } } - : artifact.id === 'aps_initial' - ? { esbuild: { mangleProps: firstDisplayApsPrivateProperties } } - : artifact.id === 'gpt_initial' - ? { esbuild: { mangleProps: firstDisplayGptPrivateProperties } } - : {}), + : artifact.id === 'render_owner_initial' + ? { esbuild: { mangleProps: firstDisplayRenderOwnerPrivateProperties } } + : artifact.id === 'aps_initial' + ? { esbuild: { mangleProps: firstDisplayApsPrivateProperties } } + : artifact.id === 'gpt_initial' + ? { esbuild: { mangleProps: firstDisplayGptPrivateProperties } } + : {}), define: { __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify(RELEASE_SENTINEL), __TSJS_EMBEDDED_INTEGRATION_IDS_V1__: JSON.stringify(catalogIds), diff --git a/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs b/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs index dcf1339a8..d68cf8c7d 100644 --- a/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs +++ b/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs @@ -190,8 +190,8 @@ function concatenateBundleSet(files, contents) { /** Enumerate every reachable base mask; APS and Prebid participation require GPT. */ export function enumerateReachableFirstDisplayMasks(catalog) { - if (!Array.isArray(catalog) || catalog.length !== 13) { - fail('first-display catalog must contain exactly thirteen rows'); + if (!Array.isArray(catalog) || catalog.length !== 14) { + fail('first-display catalog must contain exactly fourteen rows'); } const ids = new Set(); const files = new Set(); @@ -213,17 +213,23 @@ export function enumerateReachableFirstDisplayMasks(catalog) { fail('first-display catalog bit zero must be the base'); } const gpt = catalog.find(({ id }) => id === 'gpt_initial'); + const renderOwner = catalog.find(({ id }) => id === 'render_owner_initial'); const aps = catalog.find(({ id }) => id === 'aps_initial'); const prebid = catalog.find(({ id }) => id === 'prebid_initial'); if (!gpt) fail('first-display catalog must contain gpt_initial'); - if (!aps || !prebid) fail('first-display catalog must contain APS and Prebid participation'); + if (!renderOwner || !aps || !prebid) { + fail('first-display catalog must contain render-owner, APS, and Prebid participation'); + } const required = 1 << catalog[0].maskBit; const maximumMask = 1 << catalog.length; const result = []; for (let mask = 0; mask < maximumMask; mask += 1) { if ((mask & required) !== required) continue; const hasGpt = (mask & (1 << gpt.maskBit)) !== 0; - if (!hasGpt && ((mask & (1 << aps.maskBit)) !== 0 || (mask & (1 << prebid.maskBit)) !== 0)) { + const hasRenderOwner = (mask & (1 << renderOwner.maskBit)) !== 0; + const hasAps = (mask & (1 << aps.maskBit)) !== 0; + const hasPrebid = (mask & (1 << prebid.maskBit)) !== 0; + if ((!hasGpt && (hasRenderOwner || hasAps || hasPrebid)) || (hasAps && !hasRenderOwner)) { continue; } const selected = catalog.filter(({ maskBit }) => (mask & (1 << maskBit)) !== 0); diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs index 758542484..7fe56293e 100644 --- a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -105,6 +105,8 @@ const CURRENT_PROVIDER_SOURCE_OWNERS = Object.freeze({ 'src/integrations/sourcepoint/consent.ts': 'sourcepoint_consent', }); const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ + 'src/first_display/render_journal.ts': 'render_owner_initial', + 'src/first_display/render_bridge.ts': 'aps_initial', 'src/core/adapters/gam_attribution.ts': 'bootstrap', 'src/core/bootstrap.ts': 'bootstrap', 'src/core/contracts/server_boot_transport.ts': 'bootstrap', @@ -267,7 +269,7 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/trace.ts': Object.freeze(['core', 'gpt_diagnostics']), 'src/kernel/contracts/message_protocol.ts': Object.freeze(['core', 'gpt']), 'src/kernel/contracts/release_capacity.ts': Object.freeze(['core']), - 'src/kernel/contracts/puc_dynamic_owner.ts': Object.freeze(['aps_initial', 'gpt']), + 'src/kernel/contracts/puc_dynamic_owner.ts': Object.freeze(['render_owner_initial', 'gpt']), 'src/kernel/diagnostics.ts': Object.freeze(['core']), 'src/kernel/disposable.ts': Object.freeze(['core', 'gpt']), 'src/kernel/fallback.ts': Object.freeze(['bootstrap', 'core']), @@ -637,8 +639,8 @@ function validateMetricSets(sets, label) { /** Validate semantic set membership against exact release artifact ownership. */ export function validateSemanticBundleSets(metrics, release, catalog) { if (catalog?.version !== 1) fail('catalog.version must equal 1'); - if (!Array.isArray(catalog.firstDisplay) || catalog.firstDisplay.length !== 13) { - fail('catalog.firstDisplay must contain the closed thirteen-row inventory'); + if (!Array.isArray(catalog.firstDisplay) || catalog.firstDisplay.length !== 14) { + fail('catalog.firstDisplay must contain the closed fourteen-row inventory'); } if (release?.version !== 1 || !Array.isArray(release.artifacts)) { fail('release inventory is invalid'); @@ -875,6 +877,21 @@ function validateCurrentSourceOwnership(currentGraph, release) { violations.push(`${owner} reaches first-display source ${source}`); } } + const policy = currentExactSourceOwner(source); + if (policy) { + if (!artifactIds.has(policy.owner)) { + violations.push(`${source} requires missing current owner ${policy.owner}`); + } else { + for (const owner of owners) { + if (owner !== policy.owner) { + violations.push(`${owner} reaches ${policy.owner}-owned source ${source}`); + } + } + if (!owners.includes(policy.owner)) { + violations.push(`${source} is not reached by required owner ${policy.owner}`); + } + } + } continue; } const sharedOwners = CURRENT_SHARED_SOURCE_OWNER_POLICIES[source]; @@ -1053,7 +1070,9 @@ export function buildCandidateArchitectureSizeReport({ const largestRaw = largestMask(permittedMasks, 'rawBytes'); const largestGzip = largestMask(permittedMasks, 'gzipBytes'); const largestBrotli = largestMask(permittedMasks, 'brotliBytes'); - const maximalFiles = release.artifacts.map(({ file }) => file); + const maximalFiles = release.artifacts + .filter(({ role }) => role !== 'bootstrap') + .map(({ file }) => file); const maximalTotal = measureBundleSet(maximalFiles, currentArtifactContents); const named = { minimal: namedFirstDisplayMask(masks, ['first_display'], 'minimal'), @@ -1064,7 +1083,7 @@ export function buildCandidateArchitectureSizeReport({ ), aps: namedFirstDisplayMask( masks, - ['first_display', 'aps_initial', 'creative_initial', 'gpt_initial'], + ['first_display', 'render_owner_initial', 'aps_initial', 'creative_initial', 'gpt_initial'], 'APS' ), largestRaw, diff --git a/crates/trusted-server-js/lib/scripts/print-release-id.mjs b/crates/trusted-server-js/lib/scripts/print-release-id.mjs index f503ec1c2..13d37b845 100644 --- a/crates/trusted-server-js/lib/scripts/print-release-id.mjs +++ b/crates/trusted-server-js/lib/scripts/print-release-id.mjs @@ -7,7 +7,7 @@ import { computeReleaseId, RELEASE_SENTINEL } from './release-v1.mjs'; const directory = path.dirname(fileURLToPath(import.meta.url)); const distDirectory = path.resolve(directory, '..', '..', 'dist'); -const FIRST_DISPLAY_ARTIFACT_COUNT = 13; +const FIRST_DISPLAY_ARTIFACT_COUNT = 14; const CORE_ARTIFACT_INDEX = 1 + FIRST_DISPLAY_ARTIFACT_COUNT; const INTEGRATION_ARTIFACT_COUNT = 20; const RELEASE_ARTIFACT_COUNT = CORE_ARTIFACT_INDEX + 1 + INTEGRATION_ARTIFACT_COUNT; diff --git a/crates/trusted-server-js/lib/src/core/bootstrap.ts b/crates/trusted-server-js/lib/src/core/bootstrap.ts index acf383b25..34d091572 100644 --- a/crates/trusted-server-js/lib/src/core/bootstrap.ts +++ b/crates/trusted-server-js/lib/src/core/bootstrap.ts @@ -411,14 +411,14 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn }; const disposeAgent = (): void => { removePrivate('_registerFirstDisplay'); - for (let index = disposers.length - 1; index >= 0; index -= 1) { + while (disposers.length > 0) { + const dispose = disposers.pop(); try { - disposers[index]?.(); + dispose?.(); } catch { // Continue releasing every independently owned provisional effect. } } - disposers.length = 0; registrations.length = 0; agent = undefined; }; @@ -574,6 +574,9 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn register, }); } + if (id === 'render_owner_initial') { + return Object.freeze({ observe, register }); + } if (id === 'aps_initial') { return Object.freeze({ observe, publisherOrigin: location.origin, register }); } @@ -589,7 +592,12 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn }; const binding = (id: string): unknown => { const product = id.endsWith('_initial') ? id.slice(0, -'_initial'.length) : ''; - let config: unknown = id === 'creative_initial' ? boot.creative : undefined; + let config: unknown = + id === 'creative_initial' + ? boot.creative + : id === 'render_owner_initial' + ? Object.freeze({}) + : undefined; if (config === undefined && product !== '') { for (const entry of boot.integrations.entries) { if (entry.id === product) { @@ -654,6 +662,7 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn ) { return fail(); } + if (terminal || !current) return false; const fields = candidate as Readonly<{ abi: unknown; id: unknown; @@ -679,6 +688,7 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn let sliceHost: unknown; for (const component of registrations) { const value = component.prepare(component.id === 'first_display' ? host : sliceHost); + if (terminal || !current) return false; if (typeof value !== 'object' || value === null || !Object.isFrozen(value)) { return fail(); } @@ -693,23 +703,38 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn prepared.push(activate as (context: FirstDisplaySliceActivationContext) => void); } let afterActivate: (() => void) | undefined; - for (let index = 0; index < prepared.length; index += 1) { - prepared[index]!( - Object.freeze({ - own: (dispose: () => void) => { - if (typeof dispose !== 'function') throw new TypeError('tsjs'); - disposers.push(dispose); - }, - afterActivate: (callback: () => void) => { - if (index !== 0 || afterActivate || typeof callback !== 'function') { - throw new TypeError('tsjs'); - } - afterActivate = callback; - }, - }) - ); + let ownershipOpen = true; + try { + for (let index = 0; index < prepared.length; index += 1) { + prepared[index]!( + Object.freeze({ + own: (dispose: () => void) => { + if (!ownershipOpen || terminal || typeof dispose !== 'function') { + throw new TypeError('tsjs'); + } + disposers.push(dispose); + }, + afterActivate: (callback: () => void) => { + if ( + !ownershipOpen || + terminal || + index !== 0 || + afterActivate || + typeof callback !== 'function' + ) { + throw new TypeError('tsjs'); + } + afterActivate = callback; + }, + }) + ); + if (terminal || !current) return false; + } + } finally { + ownershipOpen = false; } afterActivate?.(); + if (terminal || !current) return false; return Boolean(agent && !terminal); } catch { return fail(); diff --git a/crates/trusted-server-js/lib/src/core/contracts/boot.ts b/crates/trusted-server-js/lib/src/core/contracts/boot.ts index ba67e4e6d..3b4b40a87 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/boot.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/boot.ts @@ -24,6 +24,7 @@ const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; const RELEASE_ID = /^[0-9a-f]{64}$/; const FIRST_DISPLAY_IDS = Object.freeze([ 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -160,6 +161,8 @@ function snapshotManifest(candidate: unknown, releaseId: string): BootManifestV1 if ( (mask & 1) === 0 || mask >>> FIRST_DISPLAY_IDS.length !== 0 || + ((mask & 4) !== 0 && (mask & 2) === 0) || + ((mask & 2) !== 0 && (mask & 128) === 0) || selected.length !== slices.length || selected.some((id, index) => id !== slices[index]) ) { diff --git a/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts index 49917c005..eb449e7f1 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts @@ -12,6 +12,7 @@ const RUNTIME_SRC = /^\/static\/tsjs=tsjs-unified\.min\.js\?v=[0-9a-f]{64}$/; const DEFERRED_SRC = /^\/static\/tsjs=tsjs-([a-z0-9][a-z0-9_-]{0,63})\.min\.js\?v=[0-9a-f]{64}$/; const FIRST_DISPLAY_IDS = Object.freeze([ 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -148,6 +149,8 @@ function validManifest(candidate: unknown, releaseId: string): BootManifestV1 | if ( (mask & 1) === 0 || mask >>> FIRST_DISPLAY_IDS.length !== 0 || + ((mask & 4) !== 0 && (mask & 2) === 0) || + ((mask & 2) !== 0 && (mask & 128) === 0) || selected.length !== slices.length || selected.some((id, index) => slices[index] !== id) ) { diff --git a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts deleted file mode 100644 index 19cc2d064..000000000 --- a/crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts +++ /dev/null @@ -1,361 +0,0 @@ -import type { - FirstDisplayGptBoundCycleV1, - FirstDisplayGptRenderResult, -} from './adapters/googletag'; -import type { FirstDisplayRenderBridgeV1 } from './driver'; - -const ADM_SANDBOX = - 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; -const ADM_LOAD_DEADLINE_MS = 5_000; -const RESERVATION_TTL_MS = 15 * 60 * 1_000; -const MAX_CAPABILITIES = 320; -const MAX_U32 = 4_294_967_295; -const RESERVATION_ID = /^r1_[A-Za-z0-9_-]{22}$/; - -export interface FirstDisplayAdmRenderBridgeOptionsV1 { - readonly clearTimer: (handle: unknown) => void; - readonly document: Document; - readonly now: () => number; - readonly onNativeMutation?: () => boolean; - readonly setTimer: (callback: () => void, delayMs: number) => unknown; -} - -interface AdmAttempt { - readonly cycle: FirstDisplayGptBoundCycleV1; - readonly expiresAtMs: number; - readonly onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void; - readonly ordinal: number; - active: boolean; - frame: HTMLIFrameElement | undefined; - timer: unknown; -} - -function validAdmCycle(cycle: FirstDisplayGptBoundCycleV1): boolean { - try { - return ( - cycle.bid.renderSource.type === 'adm' && - cycle.isCurrent() && - cycle.slotId === cycle.bid.slot && - cycle.slotId === cycle.placement.slot && - cycle.element.ownerDocument.getElementById(cycle.element.id) === cycle.element && - RESERVATION_ID.test(cycle.bid.rendererReservationId) - ); - } catch { - return false; - } -} - -function configureFrame(frame: HTMLIFrameElement, width: number, height: number): void { - frame.setAttribute('sandbox', ADM_SANDBOX); - frame.setAttribute('referrerpolicy', 'no-referrer'); - frame.setAttribute('width', String(width)); - frame.setAttribute('height', String(height)); - frame.setAttribute('scrolling', 'no'); - frame.setAttribute('frameborder', '0'); - frame.setAttribute('marginwidth', '0'); - frame.setAttribute('marginheight', '0'); - frame.setAttribute('title', 'Ad content'); - frame.setAttribute('aria-label', 'Advertisement'); - frame.setAttribute( - 'style', - `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` - ); -} - -/** Own only GPT-mediated ADM completion and the attributable empty-GAM fallback. */ -export function createFirstDisplayAdmRenderBridge( - options: FirstDisplayAdmRenderBridgeOptionsV1 -): FirstDisplayRenderBridgeV1 { - const attempts = new Map(); - const committedFrames = new Map< - string, - Readonly<{ frame: HTMLIFrameElement; host: HTMLElement; token: string }> - >(); - const timers = new Set(); - let nextReservationOrdinal = 1; - let lastNow = Number.NEGATIVE_INFINITY; - let sealed = false; - let ingressClosed = false; - let handoffCaptured = false; - let committedArtifactsDetached = false; - let disposed = false; - - const notifyNativeMutation = (): void => { - try { - options.onNativeMutation?.(); - } catch { - // Observation cannot alter the terminal renderer state. - } - }; - const readNow = (): number | undefined => { - try { - const value = options.now(); - if (!Number.isFinite(value) || value < lastNow) return undefined; - lastNow = value; - return value; - } catch { - return undefined; - } - }; - const clearOwnedTimer = (handle: unknown): void => { - if (handle === undefined || !timers.delete(handle)) return; - try { - options.clearTimer(handle); - } catch { - // The attempt latch remains authoritative. - } - }; - const arm = (callback: () => void, delayMs: number): unknown => { - try { - const timer = { handle: undefined as unknown }; - timer.handle = options.setTimer(() => { - if (!timers.delete(timer.handle)) return; - callback(); - }, delayMs); - if (timer.handle === undefined) return undefined; - timers.add(timer.handle); - return timer.handle; - } catch { - return undefined; - } - }; - const settle = ( - attempt: AdmAttempt, - result: 'accepted' | 'failed' | 'cancelled', - reason: string | null - ): boolean => { - if (!attempt.active) return false; - attempt.active = false; - clearOwnedTimer(attempt.timer); - attempt.timer = undefined; - const frame = attempt.frame; - attempt.frame = undefined; - if (result === 'accepted' && frame?.isConnected) { - committedFrames.set( - attempt.cycle.slotId, - Object.freeze({ - frame, - host: attempt.cycle.element, - token: attempt.cycle.bid.rendererReservationId, - }) - ); - } else if (frame) { - try { - frame.onload = null; - frame.onerror = null; - frame.remove(); - } catch { - // The attempt no longer owns a failed frame. - } - } - try { - attempt.onTerminal(result, result === 'accepted' ? null : reason); - } catch { - // A terminal observer cannot restore renderer authority. - } - notifyNativeMutation(); - return true; - }; - const fail = (attempt: AdmAttempt, reason: string): boolean => settle(attempt, 'failed', reason); - const retireCommitted = (cycle: FirstDisplayGptBoundCycleV1): boolean => { - if (disposed || committedArtifactsDetached) return false; - const entry = committedFrames.get(cycle.slotId); - if (!entry || entry.token !== cycle.bid.rendererReservationId) return false; - committedFrames.delete(cycle.slotId); - try { - entry.frame.onload = null; - entry.frame.onerror = null; - entry.frame.remove(); - } catch { - // The exact TS frame loses authority even when hostile DOM cleanup fails. - } - notifyNativeMutation(); - return true; - }; - const sweepCommittedArtifacts = (): number => { - if (disposed || committedArtifactsDetached) return 0; - let retired = 0; - for (const [slotId, entry] of [...committedFrames.entries()]) { - const current = (() => { - try { - return ( - entry.frame.ownerDocument === options.document && - entry.host.ownerDocument === options.document && - entry.frame.parentNode === entry.host && - entry.frame.isConnected && - entry.host.isConnected && - entry.host.id.length > 0 && - options.document.getElementById(entry.host.id) === entry.host - ); - } catch { - return false; - } - })(); - if (committedFrames.get(slotId) !== entry || current) continue; - committedFrames.delete(slotId); - try { - entry.frame.onload = null; - entry.frame.onerror = null; - entry.frame.remove(); - } catch { - // The exact TS frame loses authority even when hostile DOM cleanup fails. - } - retired += 1; - } - if (retired > 0) notifyNativeMutation(); - return retired; - }; - - return Object.freeze({ - bind: (cycle: FirstDisplayGptBoundCycleV1, onTerminal: AdmAttempt['onTerminal']): boolean => { - const reservationId = cycle.bid.rendererReservationId; - const observedAt = readNow(); - const ordinal = nextReservationOrdinal; - if ( - disposed || - sealed || - ingressClosed || - typeof onTerminal !== 'function' || - !validAdmCycle(cycle) || - attempts.size >= MAX_CAPABILITIES || - attempts.has(reservationId) || - observedAt === undefined || - ordinal > MAX_U32 - ) { - return false; - } - attempts.set(reservationId, { - active: true, - cycle, - expiresAtMs: observedAt + RESERVATION_TTL_MS, - frame: undefined, - onTerminal, - ordinal, - timer: undefined, - }); - nextReservationOrdinal += 1; - return true; - }, - recordGam: ( - cycle: FirstDisplayGptBoundCycleV1, - result: FirstDisplayGptRenderResult - ): boolean => { - const attempt = attempts.get(cycle.bid.rendererReservationId); - if (!attempt?.active || attempt.cycle !== cycle) return false; - if (result === 'nonempty_gam') return settle(attempt, 'accepted', null); - const source = cycle.bid.renderSource; - if (source.type !== 'adm') return fail(attempt, 'winner_not_renderable'); - try { - const frame = options.document.createElement('iframe'); - configureFrame(frame, source.width, source.height); - const intended = `${source.adm}`; - frame.onload = () => { - if ( - attempt.active && - attempt.frame === frame && - frame.parentNode === cycle.element && - frame.srcdoc === intended && - frame.getAttribute('src') === null - ) { - settle(attempt, 'accepted', null); - } - }; - frame.onerror = () => fail(attempt, 'adm_document_no_load'); - frame.srcdoc = intended; - attempt.frame = frame; - attempt.timer = arm(() => fail(attempt, 'adm_document_no_load'), ADM_LOAD_DEADLINE_MS); - if (attempt.timer === undefined) return fail(attempt, 'internal_error'); - cycle.element.appendChild(frame); - return true; - } catch { - return fail(attempt, 'adm_document_no_load'); - } - }, - recordFailure: (cycle: FirstDisplayGptBoundCycleV1): boolean => { - const attempt = attempts.get(cycle.bid.rendererReservationId); - return Boolean( - attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') - ); - }, - retire: retireCommitted, - sweepCommittedArtifacts, - sealTsAdmission: (): void => { - if (disposed || sealed || [...attempts.values()].some(({ active }) => active)) { - throw new TypeError('tsjs'); - } - sealed = true; - }, - closeIngress: (): boolean => { - if (disposed || ingressClosed || !sealed) return false; - ingressClosed = true; - for (const handle of [...timers]) clearOwnedTimer(handle); - return true; - }, - captureHandoff: () => { - if (disposed || !ingressClosed || handoffCaptured) return undefined; - sweepCommittedArtifacts(); - const observedAt = readNow(); - if (observedAt === undefined) return undefined; - handoffCaptured = true; - return Object.freeze({ - artifacts: Object.freeze( - [...committedFrames.entries()].map(([slotId, { frame, token }]) => - Object.freeze({ - hostPosition: null, - hostPositionPriority: null, - identity: frame, - kind: 'gpt_adm' as const, - owner: 'trusted_server' as const, - slotId, - token, - }) - ) - ), - clockEpochMs: observedAt, - nextReservationOrdinal, - nextTicketOrdinal: 1, - tombstones: Object.freeze( - [...attempts.entries()] - .filter(([, attempt]) => !attempt.active && attempt.expiresAtMs > observedAt) - .map(([value, attempt]) => - Object.freeze({ - kind: 'reservation' as const, - value, - expiresAtMs: attempt.expiresAtMs, - ordinal: attempt.ordinal, - }) - ) - ), - }); - }, - detachCommittedArtifacts: (): boolean => { - if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { - return false; - } - if (sweepCommittedArtifacts() > 0) return false; - committedArtifactsDetached = true; - return true; - }, - dispose: (): void => { - if (disposed) return; - disposed = true; - for (const attempt of attempts.values()) { - if (attempt.active) settle(attempt, 'cancelled', 'navigation_disposed'); - } - for (const handle of [...timers]) clearOwnedTimer(handle); - if (!committedArtifactsDetached) { - for (const { frame } of committedFrames.values()) { - try { - frame.onload = null; - frame.onerror = null; - frame.remove(); - } catch { - // A terminal owner cannot regain authority through a retained node. - } - } - } - committedFrames.clear(); - attempts.clear(); - }, - }); -} diff --git a/crates/trusted-server-js/lib/src/first_display/agent.ts b/crates/trusted-server-js/lib/src/first_display/agent.ts index 9b4717ef7..e9407d0fd 100644 --- a/crates/trusted-server-js/lib/src/first_display/agent.ts +++ b/crates/trusted-server-js/lib/src/first_display/agent.ts @@ -29,10 +29,13 @@ import type { FirstDisplayGptDiagnosticCycleV1, FirstDisplayGptHandoffCycleV1, } from './adapters/googletag'; -import { createFirstDisplayAdmRenderBridge } from './adm_render_bridge'; import { createFirstDisplayProjectedDriver, type FirstDisplayRenderBridgeV1 } from './driver'; import type { FirstDisplayApsProtocolV1 } from './leaf/aps_protocol'; import type { FirstDisplayGptProtocolV1 } from './leaf/gpt_protocol'; +import type { + FirstDisplayRenderOwnerOptionsV1, + FirstDisplayRenderOwnerProtocolV1, +} from './render_journal'; import { registerCurrentFirstDisplayComponent } from './registration_client'; import type { FirstDisplaySliceHost, @@ -45,11 +48,11 @@ import { type FirstDisplayBatchOutcomeV1, type FirstDisplayBatchV1, } from './leaf/projection'; -import type { FirstDisplayRenderBridgeOptionsV1 } from './render_bridge'; const MAX_U32 = 4_294_967_295; const BUNDLE_PARTIAL: BootFailureReason = 'bundle_partial'; const AUCTION_PROTOCOLS = ['aps', 'gpt', 'prebid'] as const; +const SLICE_PROTOCOLS = ['render_owner', ...AUCTION_PROTOCOLS] as const; const ACTION_KINDS = new Set(['gpt_adm', 'aps']); const TERMINAL_RESULTS = new Set(['accepted', 'failed', 'cancelled']); @@ -159,7 +162,7 @@ export interface FirstDisplayAgentRegistrationHostV1 { function createBrowserMessageChannel( browser: Window -): ReturnType { +): ReturnType { const constructor = Reflect.get(browser, 'MessageChannel'); if (typeof constructor !== 'function') { throw new TypeError('tsjs'); @@ -170,7 +173,7 @@ function createBrowserMessageChannel( if (typeof port1 !== 'object' || port1 === null || typeof port2 !== 'object' || port2 === null) { throw new TypeError('tsjs'); } - return { port1, port2 } as ReturnType; + return { port1, port2 } as ReturnType; } function fillBrowserRandom(browser: Window, bytes: Uint8Array): void { @@ -249,9 +252,12 @@ function protocolIdentity(candidate: unknown, expected: FirstDisplayAuctionProto } } +type FirstDisplayRegisteredProtocolId = + FirstDisplayAuctionProtocolId | FirstDisplayRenderOwnerProtocolV1['id']; + function fullProtocolIdentity( candidate: unknown, - expected: FirstDisplayAuctionProtocolId + expected: FirstDisplayRegisteredProtocolId ): boolean { try { if ( @@ -280,8 +286,8 @@ function fullProtocolIdentity( function captureProtocolRegistration( candidate: unknown, - protocolId: FirstDisplayAuctionProtocolId, - protocols: Map + protocolId: FirstDisplayRegisteredProtocolId, + protocols: Map ): unknown { try { if ( @@ -960,6 +966,52 @@ export function createFirstDisplayAgent(options: FirstDisplayAgentOptions): Firs }); } +function createNonRenderingBridge(now: () => number): FirstDisplayRenderBridgeV1 { + let phase = 0; + return Object.freeze({ + bind: () => false, + recordGam: () => false, + recordFailure: () => false, + retire: () => false, + sweepCommittedArtifacts: () => 0, + sealTsAdmission: (): void => { + if (phase !== 0) throw new TypeError('tsjs'); + phase = 1; + }, + closeIngress: (): boolean => { + if (phase !== 1) return false; + phase = 2; + return true; + }, + captureHandoff: () => { + if (phase !== 2) return undefined; + let observedAt: number; + try { + observedAt = now(); + } catch { + return undefined; + } + if (!Number.isFinite(observedAt)) return undefined; + phase = 3; + return Object.freeze({ + artifacts: Object.freeze([]), + clockEpochMs: observedAt, + nextReservationOrdinal: 1, + nextTicketOrdinal: 1, + tombstones: Object.freeze([]), + }); + }, + detachCommittedArtifacts: (): boolean => { + if (phase !== 3) return false; + phase = 4; + return true; + }, + dispose: (): void => { + phase = 5; + }, + }); +} + function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { try { if ( @@ -989,7 +1041,7 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { const options = descriptor.value as FirstDisplayAgentRegistrationHostV1['options']; const sliceBindings = bindingsDescriptor.value as (id: string) => unknown; const auctionProtocols = new Map(); - const fullProtocols = new Map(); + const fullProtocols = new Map(); const parserState = createFirstDisplayParserStateCollector(); let agent: FirstDisplayAgent | undefined; const sliceConfigValid = (id: OptionalFirstDisplaySliceId, candidate: unknown): boolean => { @@ -1013,7 +1065,7 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { throw new TypeError('tsjs'); } const protocolId = id.endsWith('_initial') - ? (id.slice(0, -'_initial'.length) as FirstDisplayAuctionProtocolId) + ? (id.slice(0, -'_initial'.length) as FirstDisplayRegisteredProtocolId) : undefined; const transported = sliceBindings(id); if ( @@ -1047,17 +1099,21 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { } ); const installed = install( - protocolId && AUCTION_PROTOCOLS.includes(protocolId) + protocolId && SLICE_PROTOCOLS.includes(protocolId) ? captureProtocolRegistration(candidate, protocolId, fullProtocols) : candidate, own, config.value ); - if (protocolId && AUCTION_PROTOCOLS.includes(protocolId)) { - if (auctionProtocols.has(protocolId) || !protocolIdentity(installed, protocolId)) { + if (protocolId && SLICE_PROTOCOLS.includes(protocolId)) { + if (!fullProtocolIdentity(installed, protocolId)) { throw new TypeError('tsjs'); } - auctionProtocols.set(protocolId, installed); + if (AUCTION_PROTOCOLS.includes(protocolId as FirstDisplayAuctionProtocolId)) { + const auctionProtocolId = protocolId as FirstDisplayAuctionProtocolId; + if (auctionProtocols.has(auctionProtocolId)) throw new TypeError('tsjs'); + auctionProtocols.set(auctionProtocolId, installed); + } } }, }); @@ -1075,13 +1131,21 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { if (!batch) throw new TypeError('tsjs'); const gpt = fullProtocols.get('gpt'); const aps = fullProtocols.get('aps'); + const renderOwner = fullProtocols.get('render_owner'); + const requiresRenderOwner = batch.outcomes.some(({ kind }) => ACTION_KINDS.has(kind)); if (batch.requiredProtocols.includes('gpt') && !fullProtocolIdentity(gpt, 'gpt')) { throw new TypeError('tsjs'); } if (batch.requiredProtocols.includes('aps') && !fullProtocolIdentity(aps, 'aps')) { throw new TypeError('tsjs'); } - const renderOptions: Omit = { + if ( + requiresRenderOwner !== fullProtocolIdentity(renderOwner, 'render_owner') || + (aps !== undefined && renderOwner === undefined) + ) { + throw new TypeError('tsjs'); + } + const renderOptions: FirstDisplayRenderOwnerOptionsV1 = { browser: options.gptInput.browser, clearTimer: options.gptInput.clearTimer, createChannel: () => createBrowserMessageChannel(options.gptInput.browser), @@ -1094,9 +1158,13 @@ function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { const apsProtocol = fullProtocolIdentity(aps, 'aps') ? (aps as FirstDisplayApsProtocolV1) : undefined; - const renderer = apsProtocol - ? apsProtocol.createRenderBridge(renderOptions) - : createFirstDisplayAdmRenderBridge(renderOptions); + const renderStrategy = apsProtocol?.createRenderStrategy(renderOptions); + const renderer = renderOwner + ? (renderOwner as FirstDisplayRenderOwnerProtocolV1).createRenderBridge( + renderOptions, + renderStrategy + ) + : createNonRenderingBridge(renderOptions.now); rendererOwner.value = renderer; const driver = createFirstDisplayProjectedDriver({ batch, diff --git a/crates/trusted-server-js/lib/src/first_display/composition.ts b/crates/trusted-server-js/lib/src/first_display/composition.ts index 01b6805f1..53e0c71fc 100644 --- a/crates/trusted-server-js/lib/src/first_display/composition.ts +++ b/crates/trusted-server-js/lib/src/first_display/composition.ts @@ -11,10 +11,12 @@ import { LOCKR_INITIAL_SLICE } from './slices/lockr'; import { OSANO_INITIAL_SLICE } from './slices/osano'; import { PERMUTIVE_INITIAL_SLICE } from './slices/permutive'; import { PREBID_INITIAL_SLICE } from './slices/prebid'; +import { RENDER_OWNER_INITIAL_SLICE } from './slices/render_owner'; import { SOURCEPOINT_INITIAL_SLICE } from './slices/sourcepoint'; import { TESTLIGHT_INITIAL_SLICE } from './slices/testlight'; export const INITIAL_SLICE_DEFINITIONS: readonly InitialSliceDefinition[] = Object.freeze([ + RENDER_OWNER_INITIAL_SLICE, APS_INITIAL_SLICE, CREATIVE_INITIAL_SLICE, DATADOME_INITIAL_SLICE, @@ -38,6 +40,11 @@ export function selectInitialSliceDefinitions( } const requested = new Set(selected.slice(1)); if (requested.size !== selected.length - 1) return undefined; + if ( + (requested.has('aps_initial') && !requested.has('render_owner_initial')) || + (requested.has('render_owner_initial') && !requested.has('gpt_initial')) + ) + return undefined; const definitions = INITIAL_SLICE_DEFINITIONS.filter(({ id }) => requested.has(id)); if (definitions.length !== requested.size) return undefined; const canonical = ['first_display', ...definitions.map(({ id }) => id)]; diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts index 1f2caeea8..d7ef24bc3 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts @@ -1,8 +1,9 @@ import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; -import { - createFirstDisplayRenderBridge, - type FirstDisplayRenderBridgeOptionsV1, -} from '../render_bridge'; +import { createFirstDisplayApsRenderStrategy } from '../render_bridge'; +import type { + FirstDisplayRenderOwnerOptionsV1, + FirstDisplayRenderStrategyV1, +} from '../render_journal'; export type FirstDisplayApsDocumentMessageV1 = | Readonly<{ kind: 'document_accepted' }> @@ -13,6 +14,14 @@ export type FirstDisplayApsDocumentMessageV1 = reason: 'descriptor_invalid' | 'runner_no_load' | 'runner_failed'; }>; +export type FirstDisplayApsWindowMessageV1 = + | Readonly<{ kind: 'bootstrap_ready'; bootstrap: string }> + | Readonly<{ + kind: 'container_ready'; + bootstrap: string; + renderer: string; + }>; + export interface FirstDisplayApsBootstrapPolicyV2 { readonly creativeOrigin: string; readonly tagType: 'iframe' | 'script'; @@ -26,13 +35,9 @@ export interface FirstDisplayApsProtocolV1 { readonly sandbox: string; readonly permanentSandbox: string; readonly deadlines: Readonly<{ - insertionMs: 1_000; documentAcceptanceMs: 3_000; completionMs: 10_000; - ownerSettlementMs: 20_000; }>; - readonly isReservationId: (candidate: unknown) => candidate is string; - readonly isLifecycleTicket: (candidate: unknown) => candidate is string; readonly isBootstrapNonce: (candidate: unknown) => candidate is string; readonly isRendererNonce: (candidate: unknown) => candidate is string; readonly bootstrapPolicy: ( @@ -42,9 +47,10 @@ export interface FirstDisplayApsProtocolV1 { candidate: unknown, expectedNonce: string ) => FirstDisplayApsDocumentMessageV1 | undefined; - readonly createRenderBridge: ( - options: Omit - ) => ReturnType; + readonly parseWindowMessage: (candidate: unknown) => FirstDisplayApsWindowMessageV1 | undefined; + readonly createRenderStrategy: ( + options: FirstDisplayRenderOwnerOptionsV1 + ) => FirstDisplayRenderStrategyV1; } interface ApsInitialBindings { @@ -53,7 +59,7 @@ interface ApsInitialBindings { readonly register: (protocol: FirstDisplayApsProtocolV1) => () => void; } -const OPAQUE_ID = /^[abrtn]1_[A-Za-z0-9_-]{22}$/; +const OPAQUE_ID = /^[bn]1_[A-Za-z0-9_-]{22}$/; const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/; const FAILURE_REASONS = new Set(['descriptor_invalid', 'runner_no_load', 'runner_failed']); const SANDBOX = @@ -130,10 +136,7 @@ function bindings(candidate: unknown): ApsInitialBindings | undefined { } } -function exactOpaqueId( - candidate: unknown, - prefix: 'r1_' | 't1_' | 'b1_' | 'n1_' -): candidate is string { +function exactOpaqueId(candidate: unknown, prefix: 'b1_' | 'n1_'): candidate is string { return typeof candidate === 'string' && candidate.startsWith(prefix) && OPAQUE_ID.test(candidate); } @@ -170,6 +173,51 @@ function parseDocumentMessage( return undefined; } +function parseWindowMessage(candidate: unknown): FirstDisplayApsWindowMessageV1 | undefined { + if (typeof candidate !== 'string' || candidate.length > 4_096) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(candidate) as unknown; + } catch { + return undefined; + } + const bootstrap = exactRecord(parsed, ['message', 'version', 'bootstrapNonce']); + if ( + bootstrap?.message === 'TS APS Bootstrap Ready' && + bootstrap.version === 1 && + exactOpaqueId(bootstrap.bootstrapNonce, 'b1_') && + candidate === + JSON.stringify({ + message: 'TS APS Bootstrap Ready', + version: 1, + bootstrapNonce: bootstrap.bootstrapNonce, + }) + ) { + return Object.freeze({ kind: 'bootstrap_ready', bootstrap: bootstrap.bootstrapNonce }); + } + const container = exactRecord(parsed, ['message', 'version', 'bootstrapNonce', 'rendererNonce']); + if ( + container?.message === 'TS APS Container Ready' && + container.version === 1 && + exactOpaqueId(container.bootstrapNonce, 'b1_') && + exactOpaqueId(container.rendererNonce, 'n1_') && + candidate === + JSON.stringify({ + message: 'TS APS Container Ready', + version: 1, + bootstrapNonce: container.bootstrapNonce, + rendererNonce: container.rendererNonce, + }) + ) { + return Object.freeze({ + kind: 'container_ready', + bootstrap: container.bootstrapNonce, + renderer: container.rendererNonce, + }); + } + return undefined; +} + function bootstrapPolicy( candidate: unknown, publisherOrigin: string @@ -243,19 +291,16 @@ export function installApsInitial( sandbox: SANDBOX, permanentSandbox: PERMANENT_SANDBOX, deadlines: Object.freeze({ - insertionMs: 1_000, documentAcceptanceMs: 3_000, completionMs: 10_000, - ownerSettlementMs: 20_000, }), - isReservationId: (input: unknown): input is string => exactOpaqueId(input, 'r1_'), - isLifecycleTicket: (input: unknown): input is string => exactOpaqueId(input, 't1_'), isBootstrapNonce: (input: unknown): input is string => exactOpaqueId(input, 'b1_'), isRendererNonce: (input: unknown): input is string => exactOpaqueId(input, 'n1_'), bootstrapPolicy: (renderer: unknown) => bootstrapPolicy(renderer, value.publisherOrigin), parseDocumentMessage, - createRenderBridge: (options: Omit) => - createFirstDisplayRenderBridge({ ...options, getAps: () => protocol }), + parseWindowMessage, + createRenderStrategy: (options: FirstDisplayRenderOwnerOptionsV1) => + createFirstDisplayApsRenderStrategy(options, protocol), }); const release = value.register(protocol); if (typeof release !== 'function') throw new TypeError('tsjs'); diff --git a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts index 38add67dc..64a74e53f 100644 --- a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts @@ -1,38 +1,19 @@ -import { PUC_DYNAMIC_OWNER } from '../kernel/contracts/puc_dynamic_owner'; - -import type { - FirstDisplayGptBoundCycleV1, - FirstDisplayGptRenderResult, -} from './adapters/googletag'; -import type { FirstDisplayRenderBridgeV1 } from './driver'; +import type { FirstDisplayGptBoundCycleV1 } from './adapters/googletag'; import type { FirstDisplayApsProtocolV1 } from './leaf/aps_protocol'; +import type { + FirstDisplayCommittedRenderArtifactV1, + FirstDisplayRenderOwnerOptionsV1, + FirstDisplayRenderStrategyAttemptV1, + FirstDisplayRenderStrategyCallbacksV1, + FirstDisplayRenderStrategyV1, +} from './render_journal'; -const ADM_SANDBOX = - 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; const RENDERER_DOCUMENT_NO_LOAD = 'renderer_document_no_load'; const INTERNAL_ERROR = 'internal_error'; const RUNNER_FAILED = 'runner_failed'; -const ADM_DOCUMENT_NO_LOAD = 'adm_document_no_load'; const WINNER_NOT_RENDERABLE = 'winner_not_renderable'; -const NAVIGATION_DISPOSED = 'navigation_disposed'; -const SLOT_UNRESOLVED = 'slot_unresolved'; -const CLAIM_DEADLINE_MS = 3_000; -const ADM_LOAD_DEADLINE_MS = 5_000; -const TICKET_TTL_MS = 3_000; -const RESERVATION_TTL_MS = 15 * 60 * 1_000; -const MAX_CAPABILITIES = 320; -const MAX_NONCES = 256; const MAX_DRAWS = 8; -const MAX_GLOBAL_MESSAGE_BYTES = 4_096; -const MAX_DOMAIN_BYTES = 2_048; -const MAX_OWNER_BYTES = 64 * 1_024; -const MAX_RESPONSE_BYTES = 72 * 1_024; -const RESERVATION_ID = /^r1_[A-Za-z0-9_-]{22}$/; -const TICKET_ID = /^t1_[A-Za-z0-9_-]{22}$/; -const BOOTSTRAP_NONCE_ID = /^b1_[A-Za-z0-9_-]{22}$/; -const NONCE_ID = /^n1_[A-Za-z0-9_-]{22}$/; const BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; -const textEncoder = new TextEncoder(); interface PortLike { readonly addEventListener?: (name: string, listener: (event: unknown) => void) => void; @@ -42,278 +23,31 @@ interface PortLike { readonly start?: () => void; } -interface ChannelLike { - readonly port1: PortLike; - readonly port2: PortLike; -} - -export interface FirstDisplayRenderBridgeOptionsV1 { - readonly browser: Window; - readonly clearTimer: (handle: unknown) => void; - readonly createChannel: () => ChannelLike; - readonly document: Document; - readonly fillRandom: (bytes: Uint8Array) => void; - readonly getAps: () => FirstDisplayApsProtocolV1 | undefined; - readonly now: () => number; - readonly onNativeMutation?: () => boolean; - readonly setTimer: (callback: () => void, delayMs: number) => unknown; -} - -interface PendingClaim { - readonly port: PortLike; - readonly source: object; -} - -interface Attempt { +interface ApsAttempt { + readonly callbacks: FirstDisplayRenderStrategyCallbacksV1; readonly cycle: FirstDisplayGptBoundCycleV1; - readonly onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void; - readonly reservationId: string; + readonly overlay: boolean; active: boolean; + accepted: boolean; bootstrapNavigated: boolean; - bootstrapNonce: string | undefined; + bootstrapNonceInternal: string; bootstrapSource: object | undefined; - claim: PendingClaim | undefined; - claimTimer: unknown; completionTimer: unknown; - controlPort: PortLike | undefined; - controlRelease: (() => void) | undefined; - directFrame: HTMLIFrameElement | undefined; documentAccepted: boolean; - documentAcceptancePending: boolean; documentPort: PortLike | undefined; documentRelease: (() => void) | undefined; documentTimer: unknown; - documentTransferred: PortLike | undefined; - gam: FirstDisplayGptRenderResult | undefined; + frame: HTMLIFrameElement; hostPositionOwned: boolean; - inserted: boolean; - insertionTimer: unknown; - ownerTicket: string | undefined; - overlay: boolean; - previousHostPosition: string; - previousHostPositionPriority: string; - rendererNonce: string | undefined; - ownerSource: object | undefined; - pendingDocumentTerminal: + pendingTerminal: | 'completed' | typeof WINNER_NOT_RENDERABLE | 'runner_no_load' | typeof RUNNER_FAILED | undefined; - phaseValue: - | 'waiting_for_gam_and_claim' - | 'waiting_for_owner' - | 'waiting_for_insertion' - | 'rendering_direct'; - ticket: string | undefined; -} - -interface LiveTicket { - readonly attempt: Attempt; - readonly expiresAtInternal: number; - readonly ordinalInternal: number; - readonly registryState: 'live'; - timer?: unknown; -} - -interface TicketTombstone { - readonly expiresAtInternal: number; - readonly ordinalInternal: number; - readonly registryState: 'tombstone'; - timer?: unknown; -} - -type TicketEntry = LiveTicket | TicketTombstone; - -interface ReservationEntry { - readonly expiresAtInternal: number; - readonly ordinalInternal: number; - readonly registryState: 'live' | 'tombstone'; -} - -function utf8Length(value: string): number { - return textEncoder.encode(value).byteLength; -} - -function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { - try { - if ( - typeof value !== 'object' || - value === null || - Array.isArray(value) || - Object.getPrototypeOf(value) !== Object.prototype || - Object.getOwnPropertySymbols(value).length !== 0 - ) { - return undefined; - } - const names = Object.getOwnPropertyNames(value).sort(); - const expected = [...keys].sort(); - if (names.length !== expected.length) return undefined; - const result: Record = {}; - for (let index = 0; index < expected.length; index += 1) { - const name = expected[index]; - if (!name || names[index] !== name) return undefined; - const descriptor = Object.getOwnPropertyDescriptor(value, name); - if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; - result[name] = descriptor.value; - } - return result; - } catch { - return undefined; - } -} - -function 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; -} - -function 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; -} - -function 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 { - key = JSON.parse(source.slice(index, keyEnd)) as unknown; - } catch { - 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; -} - -function parseJson(source: string): unknown { - if (utf8Length(source) > MAX_GLOBAL_MESSAGE_BYTES) return undefined; - const end = scanJsonValue(source, 0); - if (end === undefined || skipJsonWhitespace(source, end) !== source.length) return undefined; - try { - return JSON.parse(source) as unknown; - } catch { - return undefined; - } -} - -function routingMessage(data: unknown): Readonly<{ - adId?: string; - lifecycleTicket?: string; - message?: string; -}> { - const value = typeof data === 'string' ? parseJson(data) : data; - try { - if ( - typeof value !== 'object' || - value === null || - Array.isArray(value) || - Object.getPrototypeOf(value) !== Object.prototype - ) { - return Object.freeze({}); - } - const read = (name: string): string | undefined => { - const descriptor = Object.getOwnPropertyDescriptor(value, name); - return descriptor?.enumerable && 'value' in descriptor && typeof descriptor.value === 'string' - ? descriptor.value - : undefined; - }; - const message = read('message'); - const adId = read('adId'); - const lifecycleTicket = read('lifecycleTicket'); - return Object.freeze({ - ...(message ? { message } : {}), - ...(adId ? { adId } : {}), - ...(lifecycleTicket ? { lifecycleTicket } : {}), - }); - } catch { - return Object.freeze({}); - } -} - -function exactPrebidRequest(data: unknown): Record | undefined { - if (typeof data !== 'string') return undefined; - const fields = exactRecord(parseJson(data), ['message', 'adId', 'adServerDomain']); - return fields?.message === 'Prebid Request' && - typeof fields.adId === 'string' && - RESERVATION_ID.test(fields.adId) && - typeof fields.adServerDomain === 'string' && - fields.adServerDomain.length > 0 && - utf8Length(fields.adServerDomain) <= MAX_DOMAIN_BYTES - ? fields - : undefined; -} - -function exactOwnerRegistration(data: unknown): Record | undefined { - if (typeof data !== 'string') return undefined; - const fields = exactRecord(parseJson(data), ['message', 'adId', 'version', 'lifecycleTicket']); - return fields?.message === 'TS Render Owner Register' && - fields.version === 1 && - typeof fields.adId === 'string' && - RESERVATION_ID.test(fields.adId) && - typeof fields.lifecycleTicket === 'string' && - TICKET_ID.test(fields.lifecycleTicket) - ? fields - : undefined; + previousHostPosition: string; + previousHostPositionPriority: string; + rendererNonceInternal: string; } function eventField( @@ -347,22 +81,18 @@ function usablePort(value: unknown): value is PortLike { } } -function inspectPorts( +function exactPorts( event: unknown, - trustedPrototype: object | undefined -): - | Readonly<{ - exact: boolean; - originalCount: number; - ports: readonly PortLike[]; - }> - | undefined { + trustedPrototype: object | undefined, + expected: 0 | 1 +): readonly PortLike[] | undefined { const value = eventField(event, 'ports', trustedPrototype); try { if (!Array.isArray(value)) return undefined; const length = Object.getOwnPropertyDescriptor(value, 'length'); if (!length || !('value' in length) || !Number.isSafeInteger(length.value)) return undefined; let exact = + length.value === expected && Object.getPrototypeOf(value) === Array.prototype && Object.getOwnPropertySymbols(value).length === 0 && Object.getOwnPropertyNames(value).length === length.value + 1; @@ -381,7 +111,9 @@ function inspectPorts( seen.add(descriptor.value); ports.push(descriptor.value); } - return Object.freeze({ exact, originalCount: length.value, ports: Object.freeze(ports) }); + if (exact && ports.length === expected) return ports; + for (const port of ports) closePort(port); + return undefined; } catch { return undefined; } @@ -394,60 +126,31 @@ function eventSource(event: unknown, trustedPrototype: object | undefined): obje : 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 closePort(port: PortLike | undefined): void { try { port?.close(); } catch { - // Authority is already inert; endpoint cleanup remains best-effort. + // The endpoint is already generation-inert. } } -function post(port: PortLike, data: unknown, transfer: readonly PortLike[] = []): boolean { +function post(port: PortLike, message: unknown): boolean { try { - Reflect.apply(port.postMessage, port, [data, transfer]); + Reflect.apply(port.postMessage, port, [message, []]); return true; } catch { return false; } } -function listen( - port: PortLike, - receive: (event: unknown) => void, - receiveError: () => void -): (() => void) | undefined { +function postWindow(target: object, message: string): boolean { try { - if (typeof port.addEventListener !== 'function') return undefined; - Reflect.apply(port.addEventListener, port, ['message', receive]); - Reflect.apply(port.addEventListener, port, ['messageerror', receiveError]); - if (typeof port.start === 'function') Reflect.apply(port.start, port, []); - let live = true; - return () => { - if (!live) return; - live = false; - try { - if (typeof port.removeEventListener === 'function') { - Reflect.apply(port.removeEventListener, port, ['message', receive]); - Reflect.apply(port.removeEventListener, port, ['messageerror', receiveError]); - } - } catch { - // Port closure below remains authoritative. - } - }; + const postMessage = Reflect.get(target, 'postMessage'); + if (typeof postMessage !== 'function') return false; + Reflect.apply(postMessage, target, [message, '*', []]); + return true; } catch { - return undefined; + return false; } } @@ -468,155 +171,24 @@ function encodeOpaque(bytes: Uint8Array): string { return output; } -function validCycle(cycle: FirstDisplayGptBoundCycleV1): boolean { - try { - const source = cycle.bid.renderSource; - const document = cycle.element.ownerDocument; - let exactElementMatches = 0; - const elements = document.getElementsByTagName('*'); - for (let index = 0; index < elements.length; index += 1) { - const candidate = elements.item(index); - if (candidate?.id !== cycle.element.id) continue; - if (candidate !== cycle.element) return false; - exactElementMatches += 1; - } - return ( - cycle.isCurrent() && - cycle.slotId === cycle.bid.slot && - cycle.slotId === cycle.placement.slot && - exactElementMatches === 1 && - document.getElementById(cycle.element.id) === cycle.element && - RESERVATION_ID.test(cycle.bid.rendererReservationId) && - (source.type === 'adm' || source.type === 'aps') - ); - } catch { - return false; - } -} - -function refusedResponse(adId: string): string { - return JSON.stringify({ - message: 'Prebid Response', - adId, - rendererVersion: '4', - tsOwner: { version: 1, status: 'refused' }, - }); -} - -function ownerRefused(adId: string): string { - return JSON.stringify({ message: 'TS Render Owner Refused', adId, version: 1 }); -} - -function resizeCollapsedPucShell( - document: Document, - source: object, - width: number, - height: number -): boolean { +function snapshotFrameAttributes(frame: HTMLIFrameElement): string | undefined { try { - const browser = document.defaultView; - if ( - !browser || - !Number.isFinite(width) || - !Number.isFinite(height) || - width <= 0 || - height <= 0 - ) { - return false; - } - const frames = document.querySelectorAll('iframe'); - let selected: HTMLIFrameElement | undefined; - for (let index = 0; index < frames.length; index += 1) { - const candidate = frames.item(index); - if (candidate?.isConnected && candidate.contentWindow === source) { - if (selected) return false; - selected = candidate; - } - } - const onePixelAttribute = (element: Element, name: 'width' | 'height'): boolean => { - const value = element.getAttribute(name); - if (value === null || !/^\d+(?:\.\d+)?$/.test(value)) return false; - const parsed = Number(value); - return Number.isFinite(parsed) && parsed <= 1; - }; - const ordinaryCollapsed = (element: HTMLElement): boolean => { - const style = browser.getComputedStyle(element); - const pixel = (value: string): boolean => { - const match = /^(\d+(?:\.\d+)?)px$/.exec(value); - return match !== null && Number(match[1]) <= 1; - }; - return ( - style.position !== 'fixed' && - style.position !== 'sticky' && - pixel(style.width) && - pixel(style.height) - ); - }; - if ( - !selected || - !onePixelAttribute(selected, 'width') || - !onePixelAttribute(selected, 'height') || - !ordinaryCollapsed(selected) || - selected.closest('a,[data-anchor-status]') !== null - ) { - return false; - } - const wrapper = selected.parentElement; - if ( - !wrapper || - wrapper === document.body || - wrapper === document.documentElement || - wrapper.tagName === 'A' || - !wrapper.isConnected || - !ordinaryCollapsed(wrapper) || - wrapper.closest('a,[data-anchor-status]') !== null - ) { - return false; - } - selected.style.setProperty('width', `${width}px`); - selected.style.setProperty('height', `${height}px`); - wrapper.style.setProperty('width', `${width}px`); - wrapper.style.setProperty('height', `${height}px`); - return true; + return frame.outerHTML; } catch { - return false; + return undefined; } } -/** Own the bounded APS/ADM authority used only by the immutable first-display batch. */ -export function createFirstDisplayRenderBridge( - options: FirstDisplayRenderBridgeOptionsV1 -): FirstDisplayRenderBridgeV1 { - const attempts = new Map(); - const reservations = new Map(); - const tickets = new Map(); - const bootstrapNonces = new Map(); - const rendererNonces = new Map(); - const committedFrames = new Map< - string, - Readonly<{ - frame: HTMLIFrameElement; - frameAttributes: string; - frameContentWindow: Window; - frameSource: string; - frameSourceDocument: string; - host: HTMLElement; - hostPosition: string | null; - hostPositionPriority: string | null; - kind: 'gpt_adm' | 'aps'; - owner: 'trusted_server'; - token: string; - }> - >(); +/** Create the APS-owned URL, nonce, document-port, and overlay strategy. */ +export function createFirstDisplayApsRenderStrategy( + options: FirstDisplayRenderOwnerOptionsV1, + aps: FirstDisplayApsProtocolV1 +): FirstDisplayRenderStrategyV1 { + const attempts = new Set(); + const bootstrapNonces = new Map(); + const rendererNonces = new Map(); const timers = new Set(); let disposed = false; - let sealed = false; - let ingressClosed = false; - let handoffCaptured = false; - let committedArtifactsDetached = false; - let nextTicketOrdinal = 1; - let nextReservationOrdinal = 1; - let lastNow = Number.NEGATIVE_INFINITY; const messageEventPrototype = (() => { try { const constructor = Reflect.get(options.browser, 'MessageEvent'); @@ -628,128 +200,12 @@ export function createFirstDisplayRenderBridge( } })(); - const apsProtocol = (): FirstDisplayApsProtocolV1 | undefined => { - try { - return options.getAps(); - } catch { - return undefined; - } - }; - - const readNow = (): number | undefined => { - try { - const value = options.now(); - if (!Number.isFinite(value) || value < 0 || value < lastNow) return undefined; - lastNow = value; - return value; - } catch { - return undefined; - } - }; - const notifyNativeMutation = (): void => { try { options.onNativeMutation?.(); } catch { - // Mutation observation cannot alter the admitted publisher or browser event. - } - }; - - const disposeCommittedFrame = ( - entry: Readonly<{ - frame: HTMLIFrameElement; - host: HTMLElement; - hostPosition: string | null; - hostPositionPriority: string | null; - }> - ): void => { - try { - entry.frame.onload = null; - entry.frame.onerror = null; - entry.frame.remove(); - } catch { - // The exact TS node loses authority even when hostile DOM cleanup fails. - } - if (entry.hostPosition === null) return; - try { - const style = entry.host.style; - if ( - style.getPropertyValue('position') !== 'relative' || - style.getPropertyPriority('position') !== '' - ) { - return; - } - if (entry.hostPosition === '') style.removeProperty('position'); - else style.setProperty('position', entry.hostPosition, entry.hostPositionPriority ?? ''); - } catch { - // Compare-owned restoration cannot overwrite publisher style changes. - } - }; - - const snapshotFrameAttributes = (frame: HTMLIFrameElement): string | undefined => { - try { - return JSON.stringify( - [...frame.attributes] - .map((attribute) => [attribute.name, attribute.value] as const) - .sort(([left], [right]) => left.localeCompare(right)) - ); - } catch { - return undefined; - } - }; - - const retireCommitted = (cycle: FirstDisplayGptBoundCycleV1): boolean => { - if (disposed || committedArtifactsDetached) return false; - const entry = committedFrames.get(cycle.slotId); - if (!entry || entry.token !== cycle.bid.rendererReservationId) return false; - committedFrames.delete(cycle.slotId); - disposeCommittedFrame(entry); - notifyNativeMutation(); - return true; - }; - - const committedFrameCurrent = (entry: { - readonly frame: HTMLIFrameElement; - readonly frameAttributes: string; - readonly frameContentWindow: Window; - readonly frameSource: string; - readonly frameSourceDocument: string; - readonly host: HTMLElement; - readonly hostPosition: string | null; - }): boolean => { - try { - return ( - entry.frame.ownerDocument === options.document && - entry.host.ownerDocument === options.document && - entry.frame.parentNode === entry.host && - entry.frame.isConnected && - entry.frame.contentWindow === entry.frameContentWindow && - entry.frame.src === entry.frameSource && - entry.frame.srcdoc === entry.frameSourceDocument && - snapshotFrameAttributes(entry.frame) === entry.frameAttributes && - entry.host.isConnected && - entry.host.id.length > 0 && - options.document.getElementById(entry.host.id) === entry.host && - (entry.hostPosition === null || - (entry.host.style.getPropertyValue('position') === 'relative' && - entry.host.style.getPropertyPriority('position') === '')) - ); - } catch { - return false; - } - }; - - const sweepCommittedArtifacts = (): number => { - if (disposed || committedArtifactsDetached) return 0; - let retired = 0; - for (const [slotId, entry] of [...committedFrames.entries()]) { - if (committedFrames.get(slotId) !== entry || committedFrameCurrent(entry)) continue; - committedFrames.delete(slotId); - disposeCommittedFrame(entry); - retired += 1; + // Observation cannot alter admitted APS state. } - if (retired > 0) notifyNativeMutation(); - return retired; }; const clearOwnedTimer = (handle: unknown): void => { @@ -757,26 +213,42 @@ export function createFirstDisplayRenderBridge( try { options.clearTimer(handle); } catch { - // The state guarded by the timer is already generation-inert. + // Timer state is already detached. } }; const arm = (callback: () => void, delayMs: number): unknown => { let handle: unknown; + let scheduling = true; + let firedSynchronously = false; try { handle = options.setTimer(() => { + if (scheduling) { + firedSynchronously = true; + return; + } if (!timers.delete(handle)) return; callback(); }, delayMs); } catch { handle = undefined; } - if (handle !== undefined) timers.add(handle); + scheduling = false; + if (handle === undefined) return undefined; + if (firedSynchronously) { + try { + options.clearTimer(handle); + } catch { + // Synchronous timers are refused regardless of cleanup outcome. + } + return undefined; + } + timers.add(handle); return handle; }; const mint = ( - prefix: 't1_' | 'b1_' | 'n1_', + prefix: 'b1_' | 'n1_', registry: ReadonlyMap ): string | undefined => { for (let draw = 0; draw < MAX_DRAWS; draw += 1) { @@ -792,879 +264,413 @@ export function createFirstDisplayRenderBridge( return undefined; }; - const retireTicket = (attempt: Attempt): void => { - const ticket = attempt.ticket; - attempt.ticket = undefined; - if (!ticket) return; - const entry = tickets.get(ticket); - if (entry?.registryState !== 'live' || entry.attempt !== attempt) return; - tickets.set(ticket, { - registryState: 'tombstone', - expiresAtInternal: entry.expiresAtInternal, - ordinalInternal: entry.ordinalInternal, - timer: entry.timer, - }); - notifyNativeMutation(); - }; - - const retireReservation = (attempt: Attempt): void => { - const entry = reservations.get(attempt.reservationId); - if (entry?.registryState !== 'live') return; - reservations.set(attempt.reservationId, { - expiresAtInternal: entry.expiresAtInternal, - ordinalInternal: entry.ordinalInternal, - registryState: 'tombstone', - }); - notifyNativeMutation(); + const configureFrame = ( + frame: HTMLIFrameElement, + width: number, + height: number, + overlay: boolean + ): void => { + frame.setAttribute('sandbox', aps.sandbox); + frame.setAttribute('referrerpolicy', 'no-referrer'); + frame.setAttribute('width', String(width)); + frame.setAttribute('height', String(height)); + frame.setAttribute('scrolling', 'no'); + frame.setAttribute('frameborder', '0'); + frame.setAttribute('marginwidth', '0'); + frame.setAttribute('marginheight', '0'); + frame.setAttribute('title', 'Ad content'); + frame.setAttribute('aria-label', 'Advertisement'); + frame.setAttribute( + 'style', + overlay + ? `border: 0; display: block; height: ${height}px; inset: 0; margin: 0; overflow: hidden; position: absolute; visibility: hidden; width: ${width}px; z-index: 2147483647;` + : `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + ); }; - const releaseAttempt = (attempt: Attempt, removeFrame: boolean): void => { - clearOwnedTimer(attempt.claimTimer); - clearOwnedTimer(attempt.completionTimer); - clearOwnedTimer(attempt.documentTimer); - clearOwnedTimer(attempt.insertionTimer); - attempt.claimTimer = undefined; - attempt.completionTimer = undefined; - attempt.documentTimer = undefined; - attempt.insertionTimer = undefined; - const claim = attempt.claim; - attempt.claim = undefined; - closePort(claim?.port); - attempt.controlRelease?.(); - attempt.documentRelease?.(); - attempt.controlRelease = undefined; - attempt.documentRelease = undefined; - closePort(attempt.controlPort); - closePort(attempt.documentPort); - closePort(attempt.documentTransferred); - attempt.controlPort = undefined; - attempt.documentPort = undefined; - attempt.documentTransferred = undefined; - if (attempt.bootstrapNonce && bootstrapNonces.get(attempt.bootstrapNonce) === attempt) { - bootstrapNonces.delete(attempt.bootstrapNonce); - } - if (attempt.rendererNonce && rendererNonces.get(attempt.rendererNonce) === attempt) { - rendererNonces.delete(attempt.rendererNonce); - } - attempt.bootstrapNavigated = false; - attempt.bootstrapNonce = undefined; - attempt.bootstrapSource = undefined; - attempt.rendererNonce = undefined; - retireTicket(attempt); - retireReservation(attempt); - attempts.delete(attempt.reservationId); - if (attempt.directFrame) { - try { - attempt.directFrame.onload = null; - attempt.directFrame.onerror = null; - if (removeFrame) attempt.directFrame.remove(); - } catch { - // The exact frame is no longer authoritative after terminal settlement. - } - } - if (removeFrame && attempt.hostPositionOwned) { - attempt.hostPositionOwned = false; - try { - const style = attempt.cycle.element.style; - if ( - style.getPropertyValue('position') === 'relative' && - style.getPropertyPriority('position') === '' - ) { - if (attempt.previousHostPosition === '') style.removeProperty('position'); - else { - style.setProperty( - 'position', - attempt.previousHostPosition, - attempt.previousHostPositionPriority - ); - } - } - } catch { - // Compare-owned publisher style restoration is best-effort. - } + const restoreHostPosition = (attempt: ApsAttempt): void => { + if (!attempt.hostPositionOwned) return; + attempt.hostPositionOwned = false; + try { + const style = attempt.cycle.element.style; + if ( + style.getPropertyValue('position') !== 'relative' || + style.getPropertyPriority('position') !== '' + ) + return; + if (attempt.previousHostPosition === '') style.removeProperty('position'); + else + style.setProperty( + 'position', + attempt.previousHostPosition, + attempt.previousHostPositionPriority + ); + } catch { + // Compare-owned restoration never overwrites publisher changes. } - attempt.directFrame = undefined; }; - const settle = ( - attempt: Attempt, - result: 'accepted' | 'failed' | 'cancelled', - reason = result === 'cancelled' ? NAVIGATION_DISPOSED : INTERNAL_ERROR - ): boolean => { - if (!attempt.active) return false; - attempt.active = false; - if (attempt.controlPort && attempt.ownerTicket) { - const settlement: Record = { - message: 'TS Owner Settled', - version: 1, - lifecycleTicket: attempt.ownerTicket, - outcome: result, - }; - if (result !== 'accepted') settlement.reason = reason; - post(attempt.controlPort, settlement); - } - const committedFrame = result === 'accepted' ? attempt.directFrame : undefined; - releaseAttempt(attempt, result !== 'accepted'); - const frameAttributes = committedFrame ? snapshotFrameAttributes(committedFrame) : undefined; - const frameContentWindow = committedFrame?.contentWindow; - if (committedFrame?.isConnected && frameAttributes !== undefined && frameContentWindow) { - committedFrames.set( - attempt.cycle.slotId, - Object.freeze({ - frame: committedFrame, - frameAttributes, - frameContentWindow, - frameSource: committedFrame.src, - frameSourceDocument: committedFrame.srcdoc, - host: attempt.cycle.element, - hostPosition: - attempt.overlay && attempt.hostPositionOwned ? attempt.previousHostPosition : null, - hostPositionPriority: - attempt.overlay && attempt.hostPositionOwned - ? attempt.previousHostPositionPriority - : null, - kind: attempt.cycle.bid.renderSource.type === 'adm' ? 'gpt_adm' : 'aps', - owner: 'trusted_server', - token: attempt.reservationId, - }) - ); - } + const acquireHostPosition = (attempt: ApsAttempt): boolean => { + if (!attempt.overlay) return true; try { - attempt.onTerminal(result, result === 'accepted' ? null : reason); + const host = attempt.cycle.element; + const browser = host.ownerDocument.defaultView; + if (!browser || !attempt.cycle.isCurrent()) return false; + if (browser.getComputedStyle(host).position !== 'static') return true; + attempt.previousHostPosition = host.style.getPropertyValue('position'); + attempt.previousHostPositionPriority = host.style.getPropertyPriority('position'); + host.style.setProperty('position', 'relative'); + attempt.hostPositionOwned = + host.style.getPropertyValue('position') === 'relative' && + host.style.getPropertyPriority('position') === ''; + return attempt.hostPositionOwned && attempt.cycle.isCurrent(); } catch { - // A consumer callback cannot restore released render authority. + return false; } - notifyNativeMutation(); - return true; }; - const fail = (attempt: Attempt, reason: string, refuseClaim = false): boolean => { - if (!attempt.active) return false; - if (refuseClaim && attempt.claim) { - const claim = attempt.claim; - attempt.claim = undefined; - post(claim.port, refusedResponse(attempt.reservationId)); - closePort(claim.port); - } - return settle(attempt, 'failed', reason); - }; + const exactFrame = (attempt: ApsAttempt, permanent: boolean): boolean => { + try { + return ( + attempt.active && + attempt.cycle.isCurrent() && + attempt.frame.isConnected && + attempt.frame.parentNode === attempt.cycle.element && + attempt.frame.contentWindow === attempt.bootstrapSource && + attempt.frame.getAttribute('src') === + `${aps.rendererUrl}#${attempt.bootstrapNonceInternal}` && + attempt.frame.src === `${aps.rendererUrl}#${attempt.bootstrapNonceInternal}` && + attempt.frame.getAttribute('sandbox') === + (permanent ? aps.permanentSandbox : aps.sandbox) && + (!attempt.hostPositionOwned || + (attempt.cycle.element.style.getPropertyValue('position') === 'relative' && + attempt.cycle.element.style.getPropertyPriority('position') === '')) + ); + } catch { + return false; + } + }; - const issueBootstrapNonce = (attempt: Attempt): string | undefined => { - if (bootstrapNonces.size >= MAX_NONCES || attempt.bootstrapNonce) return undefined; - const nonce = mint('b1_', bootstrapNonces); - if (!nonce || !BOOTSTRAP_NONCE_ID.test(nonce)) return undefined; - bootstrapNonces.set(nonce, attempt); - attempt.bootstrapNonce = nonce; - return nonce; + const releasePort = (attempt: ApsAttempt): void => { + const release = attempt.documentRelease; + const port = attempt.documentPort; + attempt.documentRelease = undefined; + attempt.documentPort = undefined; + try { + release?.(); + } catch { + // Port closure remains authoritative. + } + closePort(port); }; - const issueRendererNonce = (attempt: Attempt): string | undefined => { - if (rendererNonces.size >= MAX_NONCES || attempt.rendererNonce) return undefined; - const nonce = mint('n1_', rendererNonces); - if (!nonce || !NONCE_ID.test(nonce)) return undefined; - rendererNonces.set(nonce, attempt); - attempt.rendererNonce = nonce; - return nonce; + const detachAttempt = (attempt: ApsAttempt): void => { + clearOwnedTimer(attempt.documentTimer); + clearOwnedTimer(attempt.completionTimer); + attempt.documentTimer = undefined; + attempt.completionTimer = undefined; + if (bootstrapNonces.get(attempt.bootstrapNonceInternal) === attempt) { + bootstrapNonces.delete(attempt.bootstrapNonceInternal); + } + if (rendererNonces.get(attempt.rendererNonceInternal) === attempt) { + rendererNonces.delete(attempt.rendererNonceInternal); + } + attempts.delete(attempt); + releasePort(attempt); }; - const exactApsFrame = (attempt: Attempt, permanent: boolean): boolean => { - const aps = apsProtocol(); - const frame = attempt.directFrame; - const bootstrapNonce = attempt.bootstrapNonce; - if (!aps || !frame || !bootstrapNonce || !attempt.bootstrapSource) return false; + const cancelAttempt = (attempt: ApsAttempt): void => { + if (!attempt.active && !attempt.accepted) return; + attempt.active = false; + attempt.accepted = false; + detachAttempt(attempt); + attempt.frame.onload = null; + attempt.frame.onerror = null; try { - return ( - attempt.active && - (attempt.phaseValue === 'rendering_direct' || - (attempt.overlay && attempt.phaseValue === 'waiting_for_insertion')) && - validCycle(attempt.cycle) && - attempt.inserted && - frame.isConnected && - frame.parentNode === attempt.cycle.element && - frame.contentWindow === attempt.bootstrapSource && - frame.getAttribute('src') === aps.rendererUrl + '#' + bootstrapNonce && - frame.src === aps.rendererUrl + '#' + bootstrapNonce && - frame.getAttribute('sandbox') === (permanent ? aps.permanentSandbox : aps.sandbox) - ); + attempt.frame.remove(); } catch { - return false; + // The exact node cannot regain authority. } + restoreHostPosition(attempt); + notifyNativeMutation(); }; - const postWindow = (target: object, message: string): boolean => { + const fail = (attempt: ApsAttempt, reason: string): void => { + if (!attempt.active) return; + attempt.active = false; + detachAttempt(attempt); + attempt.frame.onload = null; + attempt.frame.onerror = null; try { - const postMessage = Reflect.get(target, 'postMessage'); - if (typeof postMessage !== 'function') return false; - Reflect.apply(postMessage, target, [message, '*', []]); - return true; + attempt.frame.remove(); } catch { - return false; + // Failed APS identity is already detached. + } + restoreHostPosition(attempt); + notifyNativeMutation(); + try { + attempt.callbacks.fail(reason); + } catch { + // Consumers cannot restore APS authority. } }; - const handleApsWindowMessage = ( - event: unknown, - data: unknown, - message: string | undefined + const installDocumentPort = ( + attempt: ApsAttempt, + port: PortLike, + receive: (event: unknown) => void, + receiveError: () => void ): boolean => { - if (message !== 'TS APS Bootstrap Ready' && message !== 'TS APS Container Ready') { + try { + if (typeof port.addEventListener !== 'function') return false; + let live = true; + const release = (): void => { + if (!live) return; + live = false; + try { + if (typeof port.removeEventListener === 'function') { + Reflect.apply(port.removeEventListener, port, ['message', receive]); + Reflect.apply(port.removeEventListener, port, ['messageerror', receiveError]); + } + } catch { + // Port closure remains authoritative. + } + }; + Reflect.apply(port.addEventListener, port, ['message', receive]); + Reflect.apply(port.addEventListener, port, ['messageerror', receiveError]); + attempt.documentRelease = release; + if (typeof port.start === 'function') Reflect.apply(port.start, port, []); + return live && attempt.active && attempt.documentPort === port; + } catch { return false; } - if (typeof data !== 'string') return true; - const origin = eventField(event, 'origin', messageEventPrototype); - const source = eventSource(event, messageEventPrototype); - if (origin !== 'null' || !source) return true; + }; - if (message === 'TS APS Bootstrap Ready') { - const fields = exactRecord(parseJson(data), ['message', 'version', 'bootstrapNonce']); - const bootstrapNonce = fields?.['bootstrapNonce']; - if ( - fields?.message !== message || - fields.version !== 1 || - typeof bootstrapNonce !== 'string' || - !BOOTSTRAP_NONCE_ID.test(bootstrapNonce) || - data !== - JSON.stringify({ - message, - version: 1, - ['bootstrapNonce']: bootstrapNonce, - }) - ) { - return true; - } - const attempt = bootstrapNonces.get(bootstrapNonce); - if (!attempt || source !== attempt.bootstrapSource) return true; - const inspection = inspectPorts(event, messageEventPrototype); - if (!inspection?.exact || inspection.originalCount !== 0 || inspection.ports.length !== 0) { - for (const port of inspection?.ports ?? []) closePort(port); - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; - } - const aps = apsProtocol(); - if (!aps || attempt.bootstrapNavigated || !exactApsFrame(attempt, false)) { - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; - } - const rendererNonce = attempt.rendererNonce; - const policy = aps.bootstrapPolicy(attempt.cycle.bid.renderSource); - if (!rendererNonce || !policy) { - fail(attempt, WINNER_NOT_RENDERABLE); - return true; - } + const complete = (attempt: ApsAttempt): void => { + if (!attempt.active || !exactFrame(attempt, true)) { + fail(attempt, 'slot_unresolved'); + return; + } + if (attempt.overlay) { try { - attempt.directFrame?.setAttribute('sandbox', aps.permanentSandbox); + attempt.frame.style.setProperty('visibility', 'visible'); + if (attempt.frame.style.getPropertyValue('visibility') !== 'visible') { + fail(attempt, INTERNAL_ERROR); + return; + } } catch { - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; - } - const navigation = JSON.stringify({ - message: 'TS APS Bootstrap Configure', - version: 2, - ['bootstrapNonce']: bootstrapNonce, - ['rendererNonce']: rendererNonce, - creativeOrigin: policy.creativeOrigin, - tagType: policy.tagType, - }); - if (!exactApsFrame(attempt, true) || !postWindow(source, navigation)) { - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; + fail(attempt, INTERNAL_ERROR); + return; } - attempt.bootstrapNavigated = true; - return true; - } - - const fields = exactRecord(parseJson(data), [ - 'message', - 'version', - 'bootstrapNonce', - 'rendererNonce', - ]); - const bootstrapNonce = fields?.['bootstrapNonce']; - const rendererNonce = fields?.['rendererNonce']; - if ( - fields?.message !== message || - fields.version !== 1 || - typeof bootstrapNonce !== 'string' || - !BOOTSTRAP_NONCE_ID.test(bootstrapNonce) || - typeof rendererNonce !== 'string' || - !NONCE_ID.test(rendererNonce) || - data !== - JSON.stringify({ - message, - version: 1, - ['bootstrapNonce']: bootstrapNonce, - ['rendererNonce']: rendererNonce, - }) - ) { - return true; } - const attempt = bootstrapNonces.get(bootstrapNonce); - if ( - !attempt || - rendererNonces.get(rendererNonce) !== attempt || - source !== attempt.bootstrapSource - ) { - return true; - } - const inspection = inspectPorts(event, messageEventPrototype); - const port = inspection?.ports[0]; - if ( - !inspection?.exact || - inspection.originalCount !== 1 || - inspection.ports.length !== 1 || - !port || - !attempt.bootstrapNavigated || - attempt.documentPort || - !exactApsFrame(attempt, true) - ) { - for (const candidate of inspection?.ports ?? []) closePort(candidate); - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; - } - attempt.documentPort = port; - attempt.documentRelease = listen( - port, - (portEvent) => handleApsDocument(attempt, portEvent), - () => fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD) - ); - if (!attempt.documentRelease) { - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - return true; + const attributes = snapshotFrameAttributes(attempt.frame); + const frameWindow = attempt.frame.contentWindow; + const frameSource = attempt.frame.src; + const frameSourceDocument = attempt.frame.srcdoc; + if (attributes === undefined || !frameWindow) { + fail(attempt, INTERNAL_ERROR); + return; } - bootstrapNonces.delete(bootstrapNonce); - const aps = apsProtocol(); - if ( - !aps || - !post(port, { - version: 1, - nonce: rendererNonce, - publisherOrigin: aps.publisherOrigin, - renderer: attempt.cycle.bid.renderSource, - }) || - !exactApsFrame(attempt, true) - ) { - fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + attempt.active = false; + attempt.accepted = true; + detachAttempt(attempt); + attempt.frame.onload = null; + attempt.frame.onerror = null; + let artifactLive = true; + const retire = (): void => { + if (!artifactLive) return; + artifactLive = false; + attempt.accepted = false; + try { + attempt.frame.remove(); + } catch { + // Exact-node retirement is best-effort. + } + restoreHostPosition(attempt); + notifyNativeMutation(); + }; + const artifact: FirstDisplayCommittedRenderArtifactV1 = Object.freeze({ + hostPosition: attempt.hostPositionOwned ? attempt.previousHostPosition : null, + hostPositionPriority: attempt.hostPositionOwned ? attempt.previousHostPositionPriority : null, + identity: attempt.frame, + kind: 'aps', + owner: 'trusted_server', + slotId: attempt.cycle.slotId, + token: attempt.cycle.bid.rendererReservationId, + current: () => { + try { + return ( + artifactLive && + attempt.accepted && + attempt.cycle.isCurrent() && + attempt.frame.isConnected && + attempt.frame.parentNode === attempt.cycle.element && + attempt.frame.contentWindow === frameWindow && + attempt.frame.src === frameSource && + attempt.frame.srcdoc === frameSourceDocument && + snapshotFrameAttributes(attempt.frame) === attributes && + (!attempt.hostPositionOwned || + (attempt.cycle.element.style.getPropertyValue('position') === 'relative' && + attempt.cycle.element.style.getPropertyPriority('position') === '')) + ); + } catch { + return false; + } + }, + retire, + }); + notifyNativeMutation(); + try { + attempt.callbacks.accept(artifact); + } catch { + retire(); } - return true; }; - function handleApsDocument(attempt: Attempt, event: unknown): void { - const aps = apsProtocol(); - if (!attempt.active || !attempt.rendererNonce || !aps) return; - if (!exactApsFrame(attempt, true)) { + const handleDocument = (attempt: ApsAttempt, event: unknown): void => { + if (!attempt.active || !exactFrame(attempt, true)) { fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } - const inspection = inspectPorts(event, messageEventPrototype); - if (!inspection?.exact || inspection.originalCount !== 0 || inspection.ports.length !== 0) { + if (!exactPorts(event, messageEventPrototype, 0)) { fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } const parsed = aps.parseDocumentMessage( eventField(event, 'data', messageEventPrototype), - attempt.rendererNonce + attempt.rendererNonceInternal ); if (!parsed) { fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD); return; } - const acceptDocument = (): void => { - if (!attempt.active || attempt.documentAccepted || !attempt.inserted) return; + if (parsed.kind === 'document_accepted') { + if (attempt.documentAccepted) return; attempt.documentAccepted = true; - if (rendererNonces.get(attempt.rendererNonce!) === attempt) { - rendererNonces.delete(attempt.rendererNonce!); + if (rendererNonces.get(attempt.rendererNonceInternal) === attempt) { + rendererNonces.delete(attempt.rendererNonceInternal); } - attempt.documentAcceptancePending = false; clearOwnedTimer(attempt.documentTimer); attempt.documentTimer = undefined; attempt.completionTimer = arm(() => fail(attempt, RUNNER_FAILED), aps.deadlines.completionMs); - const pending = attempt.pendingDocumentTerminal; - attempt.pendingDocumentTerminal = undefined; - if (pending === 'completed') completeAps(attempt); + if (attempt.completionTimer === undefined) { + fail(attempt, INTERNAL_ERROR); + return; + } + const pending = attempt.pendingTerminal; + attempt.pendingTerminal = undefined; + if (pending === 'completed') complete(attempt); else if (pending) fail(attempt, pending); - }; - if (parsed.kind === 'document_accepted') { - if (attempt.documentAccepted) return; - if (!attempt.inserted) attempt.documentAcceptancePending = true; - else acceptDocument(); - return; - } - if (parsed.kind === 'runner_loaded') { return; } + if (parsed.kind === 'runner_loaded') return; if (parsed.kind === 'render_completed') { - if (attempt.documentAccepted) completeAps(attempt); - else if (attempt.documentAcceptancePending && !attempt.pendingDocumentTerminal) { - attempt.pendingDocumentTerminal = 'completed'; - } else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + if (attempt.documentAccepted) complete(attempt); + else if (!attempt.pendingTerminal) attempt.pendingTerminal = 'completed'; + else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return; } const failureReason = parsed.reason === 'descriptor_invalid' ? WINNER_NOT_RENDERABLE : parsed.reason; if (attempt.documentAccepted) fail(attempt, failureReason); - else if (attempt.documentAcceptancePending && !attempt.pendingDocumentTerminal) { - attempt.pendingDocumentTerminal = failureReason; - } else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - } + else if (!attempt.pendingTerminal) attempt.pendingTerminal = failureReason; + else fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + }; - function completeAps(attempt: Attempt): void { - if (!attempt.active || !exactApsFrame(attempt, true)) { - fail(attempt, SLOT_UNRESOLVED); - return; - } - if (attempt.overlay) { + const dispatch = (event: unknown): void => { + if (disposed) return; + const data = eventField(event, 'data', messageEventPrototype); + const message = aps.parseWindowMessage(data); + if (!message) return; + if (message.kind === 'bootstrap_ready') { + const nonce = message.bootstrap; + const attempt = bootstrapNonces.get(nonce); + const source = eventSource(event, messageEventPrototype); + if ( + !attempt || + eventField(event, 'origin', messageEventPrototype) !== 'null' || + source !== attempt.bootstrapSource + ) + return; + if (!exactPorts(event, messageEventPrototype, 0)) { + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; + } + if (attempt.bootstrapNavigated || !exactFrame(attempt, false)) { + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; + } + const policy = aps.bootstrapPolicy(attempt.cycle.bid.renderSource); + if (!policy) { + fail(attempt, WINNER_NOT_RENDERABLE); + return; + } try { - const frame = attempt.directFrame; - if (!frame) { - fail(attempt, SLOT_UNRESOLVED); - return; - } - frame.style.setProperty('visibility', 'visible'); - if (frame.style.getPropertyValue('visibility') !== 'visible') { - fail(attempt, INTERNAL_ERROR); - return; - } + attempt.frame.setAttribute('sandbox', aps.permanentSandbox); } catch { - fail(attempt, INTERNAL_ERROR); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return; } - } - settle(attempt, 'accepted'); - } - - const ownerInserted = (attempt: Attempt): void => { - if (!attempt.active || attempt.inserted) return; - attempt.inserted = true; - clearOwnedTimer(attempt.insertionTimer); - attempt.insertionTimer = undefined; - if (attempt.cycle.bid.renderSource.type === 'adm') { - attempt.documentTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); - } else if (attempt.documentAcceptancePending) { - const nonce = attempt.rendererNonce; - if (nonce) { - handleApsDocument(attempt, { - data: { message: 'TS APS Document Accepted', version: 1, nonce }, - ports: [], - }); + const navigation = JSON.stringify({ + message: 'TS APS Bootstrap Configure', + version: 2, + bootstrapNonce: nonce, + rendererNonce: attempt.rendererNonceInternal, + creativeOrigin: policy.creativeOrigin, + tagType: policy.tagType, + }); + if (!exactFrame(attempt, true) || !postWindow(source!, navigation)) { + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + return; } - } else { - const aps = apsProtocol(); - attempt.documentTimer = arm( - () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD), - aps?.deadlines.documentAcceptanceMs ?? 3_000 - ); - } - }; - - const handleOwnerControl = (attempt: Attempt, event: unknown): void => { - if (!attempt.active) return; - const ports = inspectPorts(event, messageEventPrototype); - if (!ports?.exact || ports.originalCount !== 0 || ports.ports.length !== 0) { - fail(attempt, INTERNAL_ERROR); - return; - } - const data = eventField(event, 'data', messageEventPrototype); - const ticket = attempt.ownerTicket; - const inserted = exactRecord(data, ['message', 'version', 'lifecycleTicket']); - if ( - attempt.cycle.bid.renderSource.type === 'adm' && - inserted?.message === 'TS Owner Inserted' && - inserted.version === 1 && - inserted.lifecycleTicket === ticket - ) { - ownerInserted(attempt); - return; - } - if (attempt.cycle.bid.renderSource.type !== 'adm') { - fail(attempt, INTERNAL_ERROR); - return; - } - const loaded = exactRecord(data, ['message', 'version', 'lifecycleTicket']); - if ( - loaded?.message === 'TS ADM Loaded' && - loaded.version === 1 && - loaded.lifecycleTicket === ticket && - attempt.inserted - ) { - settle(attempt, 'accepted'); - return; - } - if ( - loaded?.message === 'TS ADM Failed' && - loaded.version === 1 && - loaded.lifecycleTicket === ticket - ) { - fail(attempt, ADM_DOCUMENT_NO_LOAD); + attempt.bootstrapNavigated = true; return; } - fail(attempt, ADM_DOCUMENT_NO_LOAD); - }; - const startOwner = (attempt: Attempt): boolean => { - const controlPort = attempt.controlPort; - const ticket = attempt.ownerTicket; - if (!controlPort || !ticket || !attempt.active) return false; - const aps = apsProtocol(); - attempt.insertionTimer = arm( - () => fail(attempt, 'owner_insertion_timeout'), - aps?.deadlines.insertionMs ?? 1_000 - ); - if (attempt.cycle.bid.renderSource.type === 'adm') { - return post(controlPort, { - message: 'TS ADM Start', - version: 1, - lifecycleTicket: ticket, - source: attempt.cycle.bid.renderSource, - }); - } - if (!aps) return fail(attempt, WINNER_NOT_RENDERABLE); - attempt.overlay = true; - if (!renderAps(attempt)) return false; - return ( - post(controlPort, { - message: 'TS APS Top Mount Started', - version: 1, - lifecycleTicket: ticket, - }) || fail(attempt, INTERNAL_ERROR) - ); - }; - - const handleOwnerRegistration = ( - event: unknown, - data: unknown, - routing: Readonly<{ adId?: string; lifecycleTicket?: string }> - ): void => { - const ticket = routing.lifecycleTicket; - if (!ticket) return; - const entry = tickets.get(ticket); - if (!entry || !suppress(event)) return; - const inspection = inspectPorts(event, messageEventPrototype); - const responsePort = inspection?.ports[0]; - const refuse = (): void => { - if (responsePort) post(responsePort, ownerRefused(routing.adId ?? '')); - for (const port of inspection?.ports ?? []) closePort(port); - }; - if (entry.registryState !== 'live') { - refuse(); + const { bootstrap: bootstrapNonce, renderer: rendererNonce } = message; + const attempt = bootstrapNonces.get(bootstrapNonce); + const source = eventSource(event, messageEventPrototype); + if ( + !attempt || + rendererNonces.get(rendererNonce) !== attempt || + eventField(event, 'origin', messageEventPrototype) !== 'null' || + source !== attempt.bootstrapSource + ) return; - } - const exact = exactOwnerRegistration(data); - const attempt = entry.attempt; + const ports = exactPorts(event, messageEventPrototype, 1); + const port = ports?.[0]; if ( - !exact || - !inspection?.exact || - inspection.originalCount !== 1 || - inspection.ports.length !== 1 || - !responsePort || - exact.adId !== attempt.reservationId || - exact.lifecycleTicket !== ticket || - eventSource(event, messageEventPrototype) !== attempt.ownerSource || - !attempt.active || - attempt.phaseValue !== 'waiting_for_owner' + !port || + !attempt.bootstrapNavigated || + attempt.documentPort || + !exactFrame(attempt, true) ) { - refuse(); - if (attempt.active) fail(attempt, 'bridge_id_mismatch'); - return; - } - retireTicket(attempt); - let channel: ChannelLike; - try { - channel = options.createChannel(); - } catch { - post(responsePort, ownerRefused(attempt.reservationId)); - closePort(responsePort); - fail(attempt, INTERNAL_ERROR); + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return; } - attempt.ownerTicket = ticket; - attempt.controlPort = channel.port1; - attempt.controlRelease = listen( - channel.port1, - (message) => handleOwnerControl(attempt, message), - () => - fail( - attempt, - attempt.cycle.bid.renderSource.type === 'adm' ? ADM_DOCUMENT_NO_LOAD : INTERNAL_ERROR - ) - ); - attempt.phaseValue = 'waiting_for_insertion'; - const registered = JSON.stringify({ - message: 'TS Render Owner Registered', - adId: attempt.reservationId, - version: 1, - lifecycleTicket: ticket, - }); - const posted = - Boolean(attempt.controlRelease) && post(responsePort, registered, [channel.port2]); - closePort(responsePort); - closePort(channel.port2); - if (!posted || !startOwner(attempt)) { - if (attempt.active) fail(attempt, INTERNAL_ERROR); - } - }; - - const issueTicket = (attempt: Attempt): string | undefined => { - if (tickets.size >= MAX_CAPABILITIES || utf8Length(PUC_DYNAMIC_OWNER) > MAX_OWNER_BYTES) { - return undefined; - } - const ticket = mint('t1_', tickets); - if (!ticket || !TICKET_ID.test(ticket)) return undefined; - const issuedAt = readNow(); - if (issuedAt === undefined) return undefined; - const expiresAt = issuedAt + TICKET_TTL_MS; - const ordinal = nextTicketOrdinal; - nextTicketOrdinal += 1; - const entry: LiveTicket = { - registryState: 'live', + attempt.documentPort = port; + const listening = installDocumentPort( attempt, - expiresAtInternal: expiresAt, - ordinalInternal: ordinal, - }; - tickets.set(ticket, entry); - attempt.ticket = ticket; - entry.timer = arm(() => { - const current = tickets.get(ticket); - if (current !== entry) { - const observedAt = readNow(); - if ( - current?.registryState === 'tombstone' && - observedAt !== undefined && - current.expiresAtInternal <= observedAt - ) { - tickets.delete(ticket); - notifyNativeMutation(); - } - return; - } - tickets.delete(ticket); - attempt.ticket = undefined; - notifyNativeMutation(); - fail(attempt, 'owner_registration_timeout'); - }, TICKET_TTL_MS); - if (entry.timer === undefined) { - tickets.delete(ticket); - attempt.ticket = undefined; - return undefined; - } - return ticket; - }; - - const join = (attempt: Attempt): boolean => { - const claim = attempt.claim; - if ( - !attempt.active || - !claim || - attempt.gam !== 'nonempty_gam' || - attempt.phaseValue !== 'waiting_for_gam_and_claim' - ) { - return false; - } - clearOwnedTimer(attempt.claimTimer); - attempt.claimTimer = undefined; - const ticket = issueTicket(attempt); - if (!ticket) return fail(attempt, 'capability_registry_full', true); - const owner = { - version: 1, - status: 'ready', - kind: attempt.cycle.bid.renderSource.type, - lifecycleTicket: ticket, - }; - const response = JSON.stringify({ - message: 'Prebid Response', - adId: attempt.reservationId, - renderer: PUC_DYNAMIC_OWNER, - rendererVersion: '4', - tsOwner: owner, - }); - attempt.claim = undefined; - const posted = utf8Length(response) <= MAX_RESPONSE_BYTES && post(claim.port, response); - closePort(claim.port); - if (!posted || !attempt.active) { - if (attempt.active) fail(attempt, INTERNAL_ERROR); - return false; - } - attempt.ownerSource = claim.source; - attempt.phaseValue = 'waiting_for_owner'; - retireReservation(attempt); - const source = attempt.cycle.bid.renderSource; - resizeCollapsedPucShell(options.document, claim.source, source.width, source.height); - return true; - }; - - const configureFrame = ( - frame: HTMLIFrameElement, - width: number, - height: number, - sandbox: string, - overlay = false - ): void => { - frame.setAttribute('sandbox', sandbox); - frame.setAttribute('referrerpolicy', 'no-referrer'); - frame.setAttribute('width', String(width)); - frame.setAttribute('height', String(height)); - frame.setAttribute('scrolling', 'no'); - frame.setAttribute('frameborder', '0'); - frame.setAttribute('marginwidth', '0'); - frame.setAttribute('marginheight', '0'); - frame.setAttribute('title', 'Ad content'); - frame.setAttribute('aria-label', 'Advertisement'); - frame.setAttribute( - 'style', - overlay - ? `border: 0; display: block; height: ${height}px; inset: 0; margin: 0; overflow: hidden; position: absolute; visibility: hidden; width: ${width}px; z-index: 2147483647;` - : `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + port, + (portEvent) => handleDocument(attempt, portEvent), + () => fail(attempt, attempt.documentAccepted ? RUNNER_FAILED : RENDERER_DOCUMENT_NO_LOAD) ); - }; - - const acquireOverlayPosition = (attempt: Attempt): boolean => { - if (!attempt.overlay) return true; - try { - if (!validCycle(attempt.cycle)) return false; - const host = attempt.cycle.element; - const browser = host.ownerDocument.defaultView; - if (!browser) return false; - if (browser.getComputedStyle(host).position !== 'static') return true; - attempt.previousHostPosition = host.style.getPropertyValue('position'); - attempt.previousHostPositionPriority = host.style.getPropertyPriority('position'); - host.style.setProperty('position', 'relative'); - attempt.hostPositionOwned = - host.style.getPropertyValue('position') === 'relative' && - host.style.getPropertyPriority('position') === ''; - return attempt.hostPositionOwned && validCycle(attempt.cycle); - } catch { - return false; - } - }; - - const renderDirectAdm = (attempt: Attempt): boolean => { - const source = attempt.cycle.bid.renderSource; - if (source.type !== 'adm') return false; - try { - const frame = options.document.createElement('iframe'); - configureFrame(frame, source.width, source.height, ADM_SANDBOX); - const intended = `${source.adm}`; - frame.onload = () => { - if ( - attempt.active && - attempt.directFrame === frame && - frame.parentNode === attempt.cycle.element && - frame.srcdoc === intended && - frame.getAttribute('src') === null - ) { - settle(attempt, 'accepted'); - } - }; - frame.onerror = () => fail(attempt, ADM_DOCUMENT_NO_LOAD); - frame.srcdoc = intended; - attempt.directFrame = frame; - attempt.documentTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); - attempt.cycle.element.appendChild(frame); - return true; - } catch { - return fail(attempt, ADM_DOCUMENT_NO_LOAD); - } - }; - - const renderAps = (attempt: Attempt): boolean => { - const source = attempt.cycle.bid.renderSource; - const aps = apsProtocol(); - if (source.type !== 'aps' || !aps) return false; - if (attempt.insertionTimer === undefined) { - attempt.insertionTimer = arm( - () => fail(attempt, 'owner_insertion_timeout'), - aps.deadlines.insertionMs - ); - } - if (attempt.insertionTimer === undefined) return fail(attempt, INTERNAL_ERROR); - const bootstrapNonce = issueBootstrapNonce(attempt); - const rendererNonce = issueRendererNonce(attempt); - if (!bootstrapNonce || !rendererNonce) return fail(attempt, 'capability_registry_full'); - let policy: ReturnType; - try { - policy = aps.bootstrapPolicy(source); - } catch { - policy = undefined; - } - if (!policy) return fail(attempt, WINNER_NOT_RENDERABLE); - try { - const frame = options.document.createElement('iframe'); - configureFrame(frame, source.width, source.height, aps.sandbox, attempt.overlay); - const intended = aps.rendererUrl + '#' + bootstrapNonce; - frame.onerror = () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - frame.src = intended; - attempt.directFrame = frame; - if (!acquireOverlayPosition(attempt)) return fail(attempt, SLOT_UNRESOLVED); - attempt.cycle.element.appendChild(frame); - const intendedWindow = frame.contentWindow; - if (!intendedWindow) return fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - attempt.bootstrapSource = intendedWindow; - ownerInserted(attempt); - return exactApsFrame(attempt, false) || fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - } catch { - return fail(attempt, RENDERER_DOCUMENT_NO_LOAD); - } - }; - - const renderDirectFallback = (attempt: Attempt): boolean => { - attempt.phaseValue = 'rendering_direct'; - retireReservation(attempt); - const claim = attempt.claim; - attempt.claim = undefined; - if (claim) { - post(claim.port, refusedResponse(attempt.reservationId)); - closePort(claim.port); - } - return attempt.cycle.bid.renderSource.type === 'adm' - ? renderDirectAdm(attempt) - : renderAps(attempt); - }; - - const dispatch = (event: unknown): void => { - if (disposed || ingressClosed) return; - const data = eventField(event, 'data', messageEventPrototype); - const routing = routingMessage(data); - if (handleApsWindowMessage(event, data, routing.message)) return; - if (routing.message === 'TS Render Owner Register') { - notifyNativeMutation(); - handleOwnerRegistration(event, data, routing); + if (!listening || !attempt.active) { + if (attempt.active) fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return; } - if (routing.message !== 'Prebid Request' || !routing.adId) return; - const reservationState = reservations.get(routing.adId); - if (!reservationState || !suppress(event)) return; - notifyNativeMutation(); - const inspection = inspectPorts(event, messageEventPrototype); - const responsePort = inspection?.ports[0]; - const exact = exactPrebidRequest(data); - const refuse = (): void => { - if (responsePort) post(responsePort, refusedResponse(routing.adId!)); - for (const port of inspection?.ports ?? []) closePort(port); - }; - if ( - reservationState.registryState !== 'live' || - !inspection?.exact || - inspection.originalCount !== 1 || - inspection.ports.length !== 1 || - !responsePort || - !exact || - exact.adId !== routing.adId - ) { - refuse(); - return; - } - const attempt = attempts.get(routing.adId); - const source = eventSource(event, messageEventPrototype); + bootstrapNonces.delete(bootstrapNonce); if ( - !attempt?.active || - attempt.phaseValue !== 'waiting_for_gam_and_claim' || - attempt.claim || - !source - ) { - refuse(); - return; - } - attempt.claim = Object.freeze({ port: responsePort, source }); - if (attempt.gam === 'nonempty_gam') join(attempt); + !post(port, { + version: 1, + nonce: rendererNonce, + publisherOrigin: aps.publisherOrigin, + renderer: attempt.cycle.bid.renderSource, + }) || + !attempt.active || + !exactFrame(attempt, true) + ) + fail(attempt, RENDERER_DOCUMENT_NO_LOAD); }; try { @@ -1674,207 +680,102 @@ export function createFirstDisplayRenderBridge( } return Object.freeze({ - bind: ( - cycle: FirstDisplayGptBoundCycleV1, - onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void - ): boolean => { - if ( - disposed || - ingressClosed || - sealed || - typeof onTerminal !== 'function' || - !validCycle(cycle) || - reservations.size >= MAX_CAPABILITIES || - reservations.has(cycle.bid.rendererReservationId) - ) { + supports: (source: unknown): boolean => { + try { + return aps.bootstrapPolicy(source) !== undefined; + } catch { return false; } - const observedAt = readNow(); - if (observedAt === undefined) return false; - const expiresAt = observedAt + RESERVATION_TTL_MS; - const reservationOrdinal = nextReservationOrdinal; + }, + start: ( + cycle: FirstDisplayGptBoundCycleV1, + overlay: boolean, + callbacks: FirstDisplayRenderStrategyCallbacksV1 + ): FirstDisplayRenderStrategyAttemptV1 | undefined => { + const source = cycle.bid.renderSource; + if (disposed || source.type !== 'aps' || !cycle.isCurrent() || !aps.bootstrapPolicy(source)) { + return undefined; + } + const bootstrapNonce = mint('b1_', bootstrapNonces); + const rendererNonce = mint('n1_', rendererNonces); if ( - !Number.isFinite(expiresAt) || - expiresAt <= observedAt || - reservationOrdinal > 4_294_967_295 - ) { - return false; + !bootstrapNonce || + !rendererNonce || + !aps.isBootstrapNonce(bootstrapNonce) || + !aps.isRendererNonce(rendererNonce) + ) + return undefined; + let frame: HTMLIFrameElement; + try { + frame = options.document.createElement('iframe'); + configureFrame(frame, source.width, source.height, overlay); + } catch { + return undefined; } - const attempt: Attempt = { + const attempt: ApsAttempt = { active: true, + accepted: false, bootstrapNavigated: false, - bootstrapNonce: undefined, + bootstrapNonceInternal: bootstrapNonce, bootstrapSource: undefined, - claim: undefined, - claimTimer: undefined, + callbacks, completionTimer: undefined, - controlPort: undefined, - controlRelease: undefined, cycle, - directFrame: undefined, documentAccepted: false, - documentAcceptancePending: false, documentPort: undefined, documentRelease: undefined, documentTimer: undefined, - documentTransferred: undefined, - gam: undefined, + frame, hostPositionOwned: false, - inserted: false, - insertionTimer: undefined, - ownerTicket: undefined, - overlay: false, + overlay, + pendingTerminal: undefined, previousHostPosition: '', previousHostPositionPriority: '', - rendererNonce: undefined, - onTerminal, - ownerSource: undefined, - pendingDocumentTerminal: undefined, - reservationId: cycle.bid.rendererReservationId, - phaseValue: 'waiting_for_gam_and_claim', - ticket: undefined, + rendererNonceInternal: rendererNonce, }; - attempts.set(attempt.reservationId, attempt); - reservations.set(attempt.reservationId, { - expiresAtInternal: expiresAt, - ordinalInternal: reservationOrdinal, - registryState: 'live', - }); - nextReservationOrdinal += 1; - return true; - }, - recordGam: ( - cycle: FirstDisplayGptBoundCycleV1, - result: FirstDisplayGptRenderResult - ): boolean => { - const attempt = attempts.get(cycle.bid.rendererReservationId); - if ( - !attempt?.active || - attempt.cycle !== cycle || - attempt.gam || - attempt.phaseValue !== 'waiting_for_gam_and_claim' - ) { - return false; - } - attempt.gam = result; - if (result === 'gam_empty') return renderDirectFallback(attempt); - if (attempt.claim) return join(attempt); - attempt.claimTimer = arm(() => fail(attempt, 'bridge_claim_timeout'), CLAIM_DEADLINE_MS); - return attempt.claimTimer !== undefined || fail(attempt, INTERNAL_ERROR); - }, - recordFailure: (cycle: FirstDisplayGptBoundCycleV1): boolean => { - const attempt = attempts.get(cycle.bid.rendererReservationId); - return Boolean( - attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') - ); - }, - retire: retireCommitted, - sweepCommittedArtifacts, - sealTsAdmission: (): void => { - if (disposed || [...attempts.values()].some((attempt) => attempt.active)) { - throw new TypeError('tsjs'); - } - sealed = true; - }, - closeIngress: (): boolean => { - if ( - disposed || - ingressClosed || - !sealed || - [...attempts.values()].some((attempt) => attempt.active) - ) { - return false; - } - ingressClosed = true; + attempts.add(attempt); + bootstrapNonces.set(bootstrapNonce, attempt); + rendererNonces.set(rendererNonce, attempt); try { - options.browser.removeEventListener('message', dispatch as EventListener, true); - } catch { - // The closed generation cannot regain authority through a failed physical removal. - } - for (const handle of [...timers]) clearOwnedTimer(handle); - return true; - }, - captureHandoff: () => { - if (disposed || !ingressClosed || handoffCaptured) return undefined; - sweepCommittedArtifacts(); - const observedAt = readNow(); - if (observedAt === undefined) return undefined; - const reservationTombstones = [...reservations.entries()] - .filter((entry): entry is [string, ReservationEntry] => { - const value = entry[1]; - return value.registryState === 'tombstone' && value.expiresAtInternal > observedAt; - }) - .map(([value, entry]) => - Object.freeze({ - kind: 'reservation' as const, - value, - expiresAtMs: entry.expiresAtInternal, - ordinal: entry.ordinalInternal, - }) - ); - const ticketTombstones = [...tickets.entries()] - .filter((entry): entry is [string, TicketTombstone] => { - const value = entry[1]; - return value.registryState === 'tombstone' && value.expiresAtInternal > observedAt; - }) - .map(([value, entry]) => - Object.freeze({ - kind: 'ticket' as const, - value, - expiresAtMs: entry.expiresAtInternal, - ordinal: entry.ordinalInternal, - }) + frame.onerror = () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD); + frame.src = `${aps.rendererUrl}#${bootstrapNonce}`; + if (!acquireHostPosition(attempt)) { + cancelAttempt(attempt); + return undefined; + } + cycle.element.appendChild(frame); + const frameWindow = frame.contentWindow; + if (!frameWindow) { + cancelAttempt(attempt); + return undefined; + } + attempt.bootstrapSource = frameWindow; + attempt.documentTimer = arm( + () => fail(attempt, RENDERER_DOCUMENT_NO_LOAD), + aps.deadlines.documentAcceptanceMs ); - const artifacts = [...committedFrames.entries()].map(([slotId, entry]) => - Object.freeze({ - hostPosition: entry.hostPosition, - hostPositionPriority: entry.hostPositionPriority, - identity: entry.frame, - kind: entry.kind, - owner: entry.owner, - slotId, - token: entry.token, - }) - ); - handoffCaptured = true; - return Object.freeze({ - artifacts: Object.freeze(artifacts), - clockEpochMs: observedAt, - nextReservationOrdinal, - nextTicketOrdinal, - tombstones: Object.freeze([...reservationTombstones, ...ticketTombstones]), - }); - }, - detachCommittedArtifacts: (): boolean => { - if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { - return false; + if (attempt.documentTimer === undefined || !exactFrame(attempt, false)) { + cancelAttempt(attempt); + return undefined; + } + } catch { + cancelAttempt(attempt); + return undefined; } - if (sweepCommittedArtifacts() > 0) return false; - committedArtifactsDetached = true; - return true; + notifyNativeMutation(); + return Object.freeze({ cancel: () => cancelAttempt(attempt) }); }, dispose: (): void => { if (disposed) return; disposed = true; - if (!ingressClosed) { - ingressClosed = true; - try { - options.browser.removeEventListener('message', dispatch as EventListener, true); - } catch { - // Generation latching makes a failed physical removal inert. - } - } - for (const attempt of [...attempts.values()]) { - if (attempt.active) settle(attempt, 'cancelled', NAVIGATION_DISPOSED); + try { + options.browser.removeEventListener('message', dispatch as EventListener, true); + } catch { + // Generation state remains authoritative. } + for (const attempt of [...attempts]) cancelAttempt(attempt); for (const handle of [...timers]) clearOwnedTimer(handle); - if (!committedArtifactsDetached) { - for (const entry of committedFrames.values()) disposeCommittedFrame(entry); - } - committedFrames.clear(); attempts.clear(); - reservations.clear(); - tickets.clear(); bootstrapNonces.clear(); rendererNonces.clear(); }, diff --git a/crates/trusted-server-js/lib/src/first_display/render_journal.ts b/crates/trusted-server-js/lib/src/first_display/render_journal.ts new file mode 100644 index 000000000..b41eb203a --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/render_journal.ts @@ -0,0 +1,1611 @@ +import { PUC_DYNAMIC_OWNER } from '../kernel/contracts/puc_dynamic_owner'; +import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; + +import type { + FirstDisplayGptBoundCycleV1, + FirstDisplayGptRenderResult, +} from './adapters/googletag'; +import type { FirstDisplayRenderBridgeV1, FirstDisplayRenderHandoffArtifactV1 } from './driver'; + +const ADM_SANDBOX = + 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; +const INTERNAL_ERROR = 'internal_error'; +const ADM_DOCUMENT_NO_LOAD = 'adm_document_no_load'; +const NAVIGATION_DISPOSED = 'navigation_disposed'; +const CLAIM_DEADLINE_MS = 3_000; +const INSERTION_DEADLINE_MS = 1_000; +const ADM_LOAD_DEADLINE_MS = 5_000; +const TICKET_TTL_MS = 3_000; +const RESERVATION_TTL_MS = 15 * 60 * 1_000; +const MAX_CAPABILITIES = 320; +const MAX_DRAWS = 8; +const MAX_GLOBAL_MESSAGE_BYTES = 4_096; +const MAX_DOMAIN_BYTES = 2_048; +const MAX_OWNER_BYTES = 64 * 1_024; +const MAX_RESPONSE_BYTES = 72 * 1_024; +const RESERVATION_ID = /^r1_[A-Za-z0-9_-]{22}$/; +const TICKET_ID = /^t1_[A-Za-z0-9_-]{22}$/; +const BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const textEncoder = new TextEncoder(); + +export interface FirstDisplayPortLikeV1 { + readonly addEventListener?: (name: string, listener: (event: unknown) => void) => void; + readonly close: () => void; + readonly postMessage: (message: unknown, transfer?: readonly FirstDisplayPortLikeV1[]) => void; + readonly removeEventListener?: (name: string, listener: (event: unknown) => void) => void; + readonly start?: () => void; +} + +export interface FirstDisplayChannelLikeV1 { + readonly port1: FirstDisplayPortLikeV1; + readonly port2: FirstDisplayPortLikeV1; +} + +export interface FirstDisplayRenderOwnerOptionsV1 { + readonly browser: Window; + readonly clearTimer: (handle: unknown) => void; + readonly createChannel: () => FirstDisplayChannelLikeV1; + readonly document: Document; + readonly fillRandom: (bytes: Uint8Array) => void; + readonly now: () => number; + readonly onNativeMutation?: () => boolean; + readonly setTimer: (callback: () => void, delayMs: number) => unknown; +} + +export interface FirstDisplayCommittedRenderArtifactV1 extends FirstDisplayRenderHandoffArtifactV1 { + readonly current: () => boolean; + readonly retire: () => void; +} + +export interface FirstDisplayRenderStrategyCallbacksV1 { + readonly accept: (artifact: FirstDisplayCommittedRenderArtifactV1) => void; + readonly fail: (reason: string) => void; +} + +export interface FirstDisplayRenderStrategyAttemptV1 { + readonly cancel: () => void; +} + +/** Source-specific authority is narrowed before it reaches the render owner. */ +export interface FirstDisplayRenderStrategyV1 { + readonly supports: (source: unknown) => boolean; + readonly start: ( + cycle: FirstDisplayGptBoundCycleV1, + overlay: boolean, + callbacks: FirstDisplayRenderStrategyCallbacksV1 + ) => FirstDisplayRenderStrategyAttemptV1 | undefined; + readonly dispose: () => void; +} + +export interface FirstDisplayRenderOwnerProtocolV1 { + readonly version: 1; + readonly id: 'render_owner'; + readonly createRenderBridge: ( + options: FirstDisplayRenderOwnerOptionsV1, + strategy?: FirstDisplayRenderStrategyV1 + ) => FirstDisplayRenderBridgeV1; +} + +interface RenderOwnerInitialBindings { + readonly observe: (name: 'protocol_version', value: number) => void; + readonly register: (protocol: FirstDisplayRenderOwnerProtocolV1) => () => void; +} + +interface PendingClaim { + readonly port: FirstDisplayPortLikeV1; + readonly source: object; +} + +interface Attempt { + readonly cycle: FirstDisplayGptBoundCycleV1; + readonly onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void; + readonly reservationId: string; + active: boolean; + claim: PendingClaim | undefined; + claimTimer: unknown; + controlPort: FirstDisplayPortLikeV1 | undefined; + controlRelease: (() => void) | undefined; + execution: FirstDisplayRenderStrategyAttemptV1 | undefined; + gam: FirstDisplayGptRenderResult | undefined; + insertionTimer: unknown; + inserted: boolean; + ownerSource: object | undefined; + ownerTicket: string | undefined; + phaseValue: + | 'waiting_for_gam_and_claim' + | 'waiting_for_owner' + | 'waiting_for_insertion' + | 'rendering_direct'; + ticket: string | undefined; +} + +interface LiveTicket { + readonly attempt: Attempt; + readonly expiresAtInternal: number; + readonly ordinalInternal: number; + readonly registryState: 'live'; + timer?: unknown; +} + +interface TicketTombstone { + readonly expiresAtInternal: number; + readonly ordinalInternal: number; + readonly registryState: 'tombstone'; + timer?: unknown; +} + +type TicketEntry = LiveTicket | TicketTombstone; + +interface ReservationEntry { + readonly expiresAtInternal: number; + readonly ordinalInternal: number; + readonly registryState: 'live' | 'tombstone'; +} + +function utf8Length(value: string): number { + return textEncoder.encode(value).byteLength; +} + +function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) { + return undefined; + } + const names = Object.getOwnPropertyNames(value).sort(); + const expected = [...keys].sort(); + if (names.length !== expected.length) return undefined; + const result: Record = {}; + for (let index = 0; index < expected.length; index += 1) { + const name = expected[index]; + if (!name || names[index] !== name) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + result[name] = descriptor.value; + } + return result; + } catch { + return undefined; + } +} + +function parseJson(source: string): unknown { + if (utf8Length(source) > MAX_GLOBAL_MESSAGE_BYTES) return undefined; + try { + return JSON.parse(source) as unknown; + } catch { + return undefined; + } +} + +function routingMessage(data: unknown): Readonly<{ + adId?: string; + lifecycleTicket?: string; + message?: string; +}> { + const value = typeof data === 'string' ? parseJson(data) : data; + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return {}; + } + const read = (name: string): string | undefined => { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + return descriptor?.enumerable && 'value' in descriptor && typeof descriptor.value === 'string' + ? descriptor.value + : undefined; + }; + const message = read('message'); + const adId = read('adId'); + const lifecycleTicket = read('lifecycleTicket'); + return { + ...(message ? { message } : {}), + ...(adId ? { adId } : {}), + ...(lifecycleTicket ? { lifecycleTicket } : {}), + }; + } catch { + return {}; + } +} + +function exactPrebidRequest(data: unknown): Record | undefined { + if (typeof data !== 'string') return undefined; + const fields = exactRecord(parseJson(data), ['message', 'adId', 'adServerDomain']); + return fields?.message === 'Prebid Request' && + typeof fields.adId === 'string' && + RESERVATION_ID.test(fields.adId) && + typeof fields.adServerDomain === 'string' && + fields.adServerDomain.length > 0 && + utf8Length(fields.adServerDomain) <= MAX_DOMAIN_BYTES && + data === + JSON.stringify({ + message: 'Prebid Request', + adId: fields.adId, + adServerDomain: fields.adServerDomain, + }) + ? fields + : undefined; +} + +function exactOwnerRegistration(data: unknown): Record | undefined { + if (typeof data !== 'string') return undefined; + const fields = exactRecord(parseJson(data), ['message', 'adId', 'version', 'lifecycleTicket']); + return fields?.message === 'TS Render Owner Register' && + fields.version === 1 && + typeof fields.adId === 'string' && + RESERVATION_ID.test(fields.adId) && + typeof fields.lifecycleTicket === 'string' && + TICKET_ID.test(fields.lifecycleTicket) && + data === + JSON.stringify({ + message: 'TS Render Owner Register', + adId: fields.adId, + version: 1, + lifecycleTicket: fields.lifecycleTicket, + }) + ? fields + : undefined; +} + +function eventField( + event: unknown, + name: 'data' | 'ports' | 'source', + trustedPrototype: object | undefined +): unknown { + try { + if (typeof event !== 'object' || event === null) return undefined; + const own = Object.getOwnPropertyDescriptor(event, name); + if (own) return 'value' in own ? own.value : undefined; + const prototype = Object.getPrototypeOf(event); + if (!trustedPrototype || prototype !== trustedPrototype) return undefined; + const inherited = Object.getOwnPropertyDescriptor(trustedPrototype, name); + return inherited?.get ? Reflect.apply(inherited.get, event, []) : undefined; + } catch { + return undefined; + } +} + +function usablePort(value: unknown): value is FirstDisplayPortLikeV1 { + try { + return ( + typeof value === 'object' && + value !== null && + typeof Reflect.get(value, 'postMessage') === 'function' && + typeof Reflect.get(value, 'close') === 'function' + ); + } catch { + return false; + } +} + +function inspectPorts( + event: unknown, + trustedPrototype: object | undefined +): + | Readonly<{ + exact: boolean; + originalCount: number; + ports: readonly FirstDisplayPortLikeV1[]; + }> + | undefined { + const value = eventField(event, 'ports', trustedPrototype); + try { + if (!Array.isArray(value)) return undefined; + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if (!length || !('value' in length) || !Number.isSafeInteger(length.value)) return undefined; + let exact = + Object.getPrototypeOf(value) === Array.prototype && + Object.getOwnPropertySymbols(value).length === 0 && + Object.getOwnPropertyNames(value).length === length.value + 1; + const ports: FirstDisplayPortLikeV1[] = []; + const seen = new Set(); + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor) || !usablePort(descriptor.value)) { + exact = false; + continue; + } + if (seen.has(descriptor.value)) { + exact = false; + continue; + } + seen.add(descriptor.value); + ports.push(descriptor.value); + } + return { exact, originalCount: length.value, ports }; + } catch { + return undefined; + } +} + +function eventSource(event: unknown, trustedPrototype: object | undefined): object | undefined { + const value = eventField(event, 'source', trustedPrototype); + return (typeof value === 'object' || typeof value === 'function') && value !== null + ? value + : 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 closePort(port: FirstDisplayPortLikeV1 | undefined): void { + try { + port?.close(); + } catch { + // The endpoint is already generation-inert. + } +} + +function post( + port: FirstDisplayPortLikeV1, + data: unknown, + transfer: readonly FirstDisplayPortLikeV1[] = [] +): boolean { + try { + Reflect.apply(port.postMessage, port, [data, transfer]); + return true; + } catch { + return false; + } +} + +function installPortListeners( + port: FirstDisplayPortLikeV1, + receive: (event: unknown) => void, + receiveError: () => void, + publishRelease: (release: () => void) => void +): boolean { + try { + if (typeof port.addEventListener !== 'function') return false; + let live = true; + const release = (): void => { + if (!live) return; + live = false; + try { + if (typeof port.removeEventListener === 'function') { + Reflect.apply(port.removeEventListener, port, ['message', receive]); + Reflect.apply(port.removeEventListener, port, ['messageerror', receiveError]); + } + } catch { + // Port closure remains authoritative. + } + }; + Reflect.apply(port.addEventListener, port, ['message', receive]); + Reflect.apply(port.addEventListener, port, ['messageerror', receiveError]); + publishRelease(release); + if (typeof port.start === 'function') Reflect.apply(port.start, port, []); + return live; + } catch { + return false; + } +} + +function encodeOpaque(bytes: Uint8Array): string { + let output = ''; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 6) { + bits -= 6; + output += BASE64URL[(buffer >>> bits) & 63]; + } + buffer &= (1 << bits) - 1; + } + if (bits > 0) output += BASE64URL[(buffer << (6 - bits)) & 63]; + return output; +} + +function refusedResponse(adId: string): string { + return JSON.stringify({ + message: 'Prebid Response', + adId, + rendererVersion: '4', + tsOwner: { version: 1, status: 'refused' }, + }); +} + +function ownerRefused(adId: string): string { + return JSON.stringify({ message: 'TS Render Owner Refused', adId, version: 1 }); +} + +function snapshotFrameAttributes(frame: HTMLIFrameElement): string | undefined { + try { + return JSON.stringify( + [...frame.attributes] + .map((attribute) => [attribute.name, attribute.value] as const) + .sort(([left], [right]) => left.localeCompare(right)) + ); + } catch { + return undefined; + } +} + +function exactPublisherFrame(document: Document, source: object): HTMLIFrameElement | undefined { + try { + const frames = document.querySelectorAll('iframe'); + let selected: HTMLIFrameElement | undefined; + for (let index = 0; index < frames.length; index += 1) { + const candidate = frames.item(index); + if (candidate?.isConnected && candidate.contentWindow === source) { + if (selected) return undefined; + selected = candidate; + } + } + return selected; + } catch { + return undefined; + } +} + +function resizeCollapsedPucShell( + document: Document, + source: object, + width: number, + height: number +): boolean { + try { + const browser = document.defaultView; + const selected = exactPublisherFrame(document, source); + if (!browser || !selected || width <= 0 || height <= 0) return false; + const onePixelAttribute = (element: Element, name: 'width' | 'height'): boolean => { + const value = element.getAttribute(name); + if (value === null || !/^\d+(?:\.\d+)?$/.test(value)) return false; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed <= 1; + }; + const ordinaryCollapsed = (element: HTMLElement): boolean => { + const style = browser.getComputedStyle(element); + const pixel = (value: string): boolean => { + const match = /^(\d+(?:\.\d+)?)px$/.exec(value); + return match !== null && Number(match[1]) <= 1; + }; + return ( + style.position !== 'fixed' && + style.position !== 'sticky' && + pixel(style.width) && + pixel(style.height) + ); + }; + if ( + !onePixelAttribute(selected, 'width') || + !onePixelAttribute(selected, 'height') || + !ordinaryCollapsed(selected) || + selected.closest('a,[data-anchor-status]') !== null + ) + return false; + const wrapper = selected.parentElement; + if ( + !wrapper || + wrapper === document.body || + wrapper === document.documentElement || + wrapper.tagName === 'A' || + !wrapper.isConnected || + !ordinaryCollapsed(wrapper) || + wrapper.closest('a,[data-anchor-status]') !== null + ) + return false; + selected.style.setProperty('width', `${width}px`); + selected.style.setProperty('height', `${height}px`); + wrapper.style.setProperty('width', `${width}px`); + wrapper.style.setProperty('height', `${height}px`); + return true; + } catch { + return false; + } +} + +function configureAdmFrame(frame: HTMLIFrameElement, width: number, height: number): void { + frame.setAttribute('sandbox', ADM_SANDBOX); + frame.setAttribute('referrerpolicy', 'no-referrer'); + frame.setAttribute('width', String(width)); + frame.setAttribute('height', String(height)); + frame.setAttribute('scrolling', 'no'); + frame.setAttribute('frameborder', '0'); + frame.setAttribute('marginwidth', '0'); + frame.setAttribute('marginheight', '0'); + frame.setAttribute('title', 'Ad content'); + frame.setAttribute('aria-label', 'Advertisement'); + frame.setAttribute( + 'style', + `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;` + ); +} + +function validCycle( + cycle: FirstDisplayGptBoundCycleV1, + strategy: FirstDisplayRenderStrategyV1 | undefined +): boolean { + try { + const source = cycle.bid.renderSource; + const document = cycle.element.ownerDocument; + let exactElementMatches = 0; + const elements = document.getElementsByTagName('*'); + for (let index = 0; index < elements.length; index += 1) { + const candidate = elements.item(index); + if (candidate?.id !== cycle.element.id) continue; + if (candidate !== cycle.element) return false; + exactElementMatches += 1; + } + return ( + cycle.isCurrent() && + cycle.slotId === cycle.bid.slot && + cycle.slotId === cycle.placement.slot && + exactElementMatches === 1 && + document.getElementById(cycle.element.id) === cycle.element && + RESERVATION_ID.test(cycle.bid.rendererReservationId) && + (source.type === 'adm' || strategy?.supports(source) === true) + ); + } catch { + return false; + } +} + +function publisherArtifact( + document: Document, + attempt: Attempt +): FirstDisplayCommittedRenderArtifactV1 | undefined { + const source = attempt.ownerSource; + if (!source) return undefined; + const frame = exactPublisherFrame(document, source); + const attributes = frame ? snapshotFrameAttributes(frame) : undefined; + const parent = frame?.parentNode; + const frameWindow = frame?.contentWindow; + if (!frame || attributes === undefined || !parent || !frameWindow) return undefined; + return Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + identity: frame, + kind: 'gpt_adm' as const, + owner: 'publisher' as const, + slotId: attempt.cycle.slotId, + token: attempt.reservationId, + current: () => { + try { + return ( + frame.ownerDocument === document && + frame.isConnected && + frame.parentNode === parent && + frame.contentWindow === frameWindow && + frame.contentWindow === source && + snapshotFrameAttributes(frame) === attributes + ); + } catch { + return false; + } + }, + retire: () => undefined, + }); +} + +function directAdmExecution( + document: Document, + cycle: FirstDisplayGptBoundCycleV1, + callbacks: FirstDisplayRenderStrategyCallbacksV1, + setTimer: (callback: () => void, delayMs: number) => unknown, + clearTimer: (handle: unknown) => void +): FirstDisplayRenderStrategyAttemptV1 | undefined { + const source = cycle.bid.renderSource; + if (source.type !== 'adm') return undefined; + let live = true; + let timer: unknown; + let frame: HTMLIFrameElement | undefined; + const retire = (): void => { + if (!live) return; + live = false; + try { + if (timer !== undefined) clearTimer(timer); + } catch { + // Exact-node retirement remains authoritative. + } + if (!frame) return; + frame.onload = null; + frame.onerror = null; + try { + frame.remove(); + } catch { + // The exact node cannot regain authority. + } + }; + try { + const created = document.createElement('iframe'); + frame = created; + configureAdmFrame(created, source.width, source.height); + const intended = `${source.adm}`; + created.onload = () => { + if (!live) return; + const attributes = snapshotFrameAttributes(created); + const frameWindow = created.contentWindow; + if ( + !cycle.isCurrent() || + created.parentNode !== cycle.element || + created.srcdoc !== intended || + created.getAttribute('src') !== null || + attributes === undefined || + !frameWindow + ) { + callbacks.fail(ADM_DOCUMENT_NO_LOAD); + return; + } + if (timer !== undefined) clearTimer(timer); + timer = undefined; + created.onload = null; + created.onerror = null; + callbacks.accept( + Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + identity: created, + kind: 'gpt_adm' as const, + owner: 'trusted_server' as const, + slotId: cycle.slotId, + token: cycle.bid.rendererReservationId, + current: () => { + try { + return ( + live && + cycle.isCurrent() && + created.isConnected && + created.parentNode === cycle.element && + created.contentWindow === frameWindow && + created.srcdoc === intended && + created.getAttribute('src') === null && + snapshotFrameAttributes(created) === attributes + ); + } catch { + return false; + } + }, + retire, + }) + ); + }; + created.onerror = () => callbacks.fail(ADM_DOCUMENT_NO_LOAD); + created.srcdoc = intended; + cycle.element.appendChild(created); + let scheduling = true; + let firedSynchronously = false; + timer = setTimer(() => { + if (scheduling) { + firedSynchronously = true; + return; + } + if (live) callbacks.fail(ADM_DOCUMENT_NO_LOAD); + }, ADM_LOAD_DEADLINE_MS); + scheduling = false; + if (timer === undefined || firedSynchronously) { + if (timer !== undefined) clearTimer(timer); + retire(); + return undefined; + } + return Object.freeze({ cancel: retire }); + } catch { + retire(); + return undefined; + } +} + +/** Own the bounded, source-neutral render journal for one first-display batch. */ +export function createFirstDisplayRenderJournal( + options: FirstDisplayRenderOwnerOptionsV1, + strategy?: FirstDisplayRenderStrategyV1 +): FirstDisplayRenderBridgeV1 { + const attempts = new Map(); + const reservations = new Map(); + const tickets = new Map(); + const committed = new Map(); + const timers = new Set(); + let disposed = false; + let sealed = false; + let ingressClosed = false; + let handoffCaptured = false; + let committedArtifactsDetached = false; + let nextTicketOrdinal = 1; + let nextReservationOrdinal = 1; + let lastNow = Number.NEGATIVE_INFINITY; + const messageEventPrototype = (() => { + try { + const constructor = Reflect.get(options.browser, 'MessageEvent'); + const prototype = + typeof constructor === 'function' ? Reflect.get(constructor, 'prototype') : undefined; + return typeof prototype === 'object' && prototype !== null ? prototype : undefined; + } catch { + return undefined; + } + })(); + + const readNow = (): number | undefined => { + try { + const value = options.now(); + if (!Number.isFinite(value) || value < 0 || value < lastNow) return undefined; + lastNow = value; + return value; + } catch { + return undefined; + } + }; + + const notifyNativeMutation = (): void => { + try { + options.onNativeMutation?.(); + } catch { + // Observation cannot alter the admitted event. + } + }; + + const clearOwnedTimer = (handle: unknown): void => { + if (handle === undefined || !timers.delete(handle)) return; + try { + options.clearTimer(handle); + } catch { + // Timer state is already generation-inert. + } + }; + + const arm = (callback: () => void, delayMs: number): unknown => { + let handle: unknown; + let scheduling = true; + let firedSynchronously = false; + try { + handle = options.setTimer(() => { + if (scheduling) { + firedSynchronously = true; + return; + } + if (!timers.delete(handle)) return; + callback(); + }, delayMs); + } catch { + handle = undefined; + } + scheduling = false; + if (handle === undefined) return undefined; + if (firedSynchronously) { + try { + options.clearTimer(handle); + } catch { + // Synchronous timers are refused regardless of cleanup outcome. + } + return undefined; + } + timers.add(handle); + return handle; + }; + + const mint = (registry: ReadonlyMap): string | undefined => { + for (let draw = 0; draw < MAX_DRAWS; draw += 1) { + const bytes = new Uint8Array(16); + try { + options.fillRandom(bytes); + } catch { + return undefined; + } + const candidate = `t1_${encodeOpaque(bytes)}`; + if (!registry.has(candidate)) return candidate; + } + return undefined; + }; + + const retireTicket = (attempt: Attempt): void => { + const ticket = attempt.ticket; + attempt.ticket = undefined; + if (!ticket) return; + const entry = tickets.get(ticket); + if (entry?.registryState !== 'live' || entry.attempt !== attempt) return; + tickets.set(ticket, { + registryState: 'tombstone', + expiresAtInternal: entry.expiresAtInternal, + ordinalInternal: entry.ordinalInternal, + timer: entry.timer, + }); + }; + + const retireReservation = (attempt: Attempt): void => { + const entry = reservations.get(attempt.reservationId); + if (entry?.registryState !== 'live') return; + reservations.set(attempt.reservationId, { + expiresAtInternal: entry.expiresAtInternal, + ordinalInternal: entry.ordinalInternal, + registryState: 'tombstone', + }); + }; + + const releaseAttempt = ( + attempt: Attempt, + cancelExecution: boolean + ): Readonly<{ + claim?: PendingClaim; + controlPort?: FirstDisplayPortLikeV1; + controlRelease?: () => void; + execution?: FirstDisplayRenderStrategyAttemptV1; + }> => { + clearOwnedTimer(attempt.claimTimer); + clearOwnedTimer(attempt.insertionTimer); + attempt.claimTimer = undefined; + attempt.insertionTimer = undefined; + const claim = attempt.claim; + const controlPort = attempt.controlPort; + const controlRelease = attempt.controlRelease; + const execution = cancelExecution ? attempt.execution : undefined; + attempt.claim = undefined; + attempt.controlPort = undefined; + attempt.controlRelease = undefined; + attempt.execution = undefined; + attempt.ownerSource = undefined; + attempt.ownerTicket = undefined; + retireTicket(attempt); + retireReservation(attempt); + attempts.delete(attempt.reservationId); + return { + ...(claim ? { claim } : {}), + ...(controlPort ? { controlPort } : {}), + ...(controlRelease ? { controlRelease } : {}), + ...(execution ? { execution } : {}), + }; + }; + + const settle = ( + attempt: Attempt, + result: 'accepted' | 'failed' | 'cancelled', + reason = result === 'cancelled' ? NAVIGATION_DISPOSED : INTERNAL_ERROR, + candidateArtifact?: FirstDisplayCommittedRenderArtifactV1 + ): boolean => { + if (!attempt.active) return false; + if (result === 'accepted') { + if ( + !candidateArtifact || + candidateArtifact.slotId !== attempt.cycle.slotId || + candidateArtifact.token !== attempt.reservationId || + candidateArtifact.current() !== true + ) { + return settle(attempt, 'failed', INTERNAL_ERROR); + } + committed.set(attempt.cycle.slotId, candidateArtifact); + } + attempt.active = false; + const ticket = attempt.ownerTicket; + const released = releaseAttempt(attempt, result !== 'accepted'); + notifyNativeMutation(); + try { + released.controlRelease?.(); + } catch { + // State was detached before publisher-controlled cleanup. + } + try { + released.execution?.cancel(); + } catch { + // Strategy authority is detached from this generation. + } + if (released.controlPort && ticket) { + const settlement: Record = { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: ticket, + outcome: result, + }; + if (result !== 'accepted') settlement.reason = reason; + post(released.controlPort, settlement); + } + closePort(released.claim?.port); + closePort(released.controlPort); + try { + attempt.onTerminal(result, result === 'accepted' ? null : reason); + } catch { + // A terminal observer cannot restore detached authority. + } + return true; + }; + + const fail = (attempt: Attempt, reason: string, refuseClaim = false): boolean => { + if (!attempt.active) return false; + if (refuseClaim && attempt.claim) { + const claim = attempt.claim; + attempt.claim = undefined; + post(claim.port, refusedResponse(attempt.reservationId)); + closePort(claim.port); + if (!attempt.active) return false; + } + return settle(attempt, 'failed', reason); + }; + + const issueTicket = (attempt: Attempt): string | undefined => { + if (tickets.size >= MAX_CAPABILITIES || utf8Length(PUC_DYNAMIC_OWNER) > MAX_OWNER_BYTES) { + return undefined; + } + const ticket = mint(tickets); + if (!ticket || !TICKET_ID.test(ticket)) return undefined; + const issuedAt = readNow(); + if (issuedAt === undefined) return undefined; + const expiresAt = issuedAt + TICKET_TTL_MS; + const ordinal = nextTicketOrdinal; + nextTicketOrdinal += 1; + const entry: LiveTicket = { + registryState: 'live', + attempt, + expiresAtInternal: expiresAt, + ordinalInternal: ordinal, + }; + tickets.set(ticket, entry); + attempt.ticket = ticket; + entry.timer = arm(() => { + const current = tickets.get(ticket); + if (current !== entry) { + const observedAt = readNow(); + if ( + current?.registryState === 'tombstone' && + observedAt !== undefined && + current.expiresAtInternal <= observedAt + ) { + tickets.delete(ticket); + notifyNativeMutation(); + } + return; + } + tickets.delete(ticket); + attempt.ticket = undefined; + notifyNativeMutation(); + fail(attempt, 'owner_registration_timeout'); + }, TICKET_TTL_MS); + if (entry.timer === undefined) { + tickets.delete(ticket); + attempt.ticket = undefined; + return undefined; + } + return ticket; + }; + + const join = (attempt: Attempt): boolean => { + const claim = attempt.claim; + if ( + !attempt.active || + !claim || + attempt.gam !== 'nonempty_gam' || + attempt.phaseValue !== 'waiting_for_gam_and_claim' + ) + return false; + clearOwnedTimer(attempt.claimTimer); + attempt.claimTimer = undefined; + const ticket = issueTicket(attempt); + if (!ticket) return fail(attempt, 'capability_registry_full', true); + const response = JSON.stringify({ + message: 'Prebid Response', + adId: attempt.reservationId, + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: attempt.cycle.bid.renderSource.type, + lifecycleTicket: ticket, + }, + }); + attempt.claim = undefined; + attempt.ownerSource = claim.source; + attempt.phaseValue = 'waiting_for_owner'; + retireReservation(attempt); + const source = attempt.cycle.bid.renderSource; + resizeCollapsedPucShell(options.document, claim.source, source.width, source.height); + if ( + !attempt.active || + attempt.phaseValue !== 'waiting_for_owner' || + attempt.ownerSource !== claim.source || + attempt.ticket !== ticket + ) { + closePort(claim.port); + return false; + } + const posted = utf8Length(response) <= MAX_RESPONSE_BYTES && post(claim.port, response); + closePort(claim.port); + if (!posted && attempt.active) return fail(attempt, INTERNAL_ERROR); + return posted; + }; + + const beginExecution = (attempt: Attempt, overlay: boolean): boolean => { + type Pending = + | Readonly<{ kind: 'accept'; artifact: FirstDisplayCommittedRenderArtifactV1 }> + | Readonly<{ kind: 'fail'; reason: string }>; + let starting = true; + let pending: Pending | undefined; + let duplicate = false; + const enqueue = (next: Pending): void => { + if (!attempt.active) return; + if (starting) { + if (pending) duplicate = true; + else pending = next; + return; + } + if (next.kind === 'accept') settle(attempt, 'accepted', INTERNAL_ERROR, next.artifact); + else fail(attempt, next.reason); + }; + const callbacks = Object.freeze({ + accept: (artifact: FirstDisplayCommittedRenderArtifactV1) => + enqueue(Object.freeze({ kind: 'accept' as const, artifact })), + fail: (reason: string) => enqueue(Object.freeze({ kind: 'fail' as const, reason })), + }); + let execution: FirstDisplayRenderStrategyAttemptV1 | undefined; + if (attempt.cycle.bid.renderSource.type === 'adm') { + if (overlay) return false; + execution = directAdmExecution( + options.document, + attempt.cycle, + callbacks, + options.setTimer, + options.clearTimer + ); + } else if (strategy?.supports(attempt.cycle.bid.renderSource) === true) { + try { + execution = strategy.start(attempt.cycle, overlay, callbacks); + } catch { + execution = undefined; + } + } + starting = false; + if (!execution || duplicate) { + try { + execution?.cancel(); + } catch { + // Refused execution cannot retain authority. + } + return false; + } + attempt.execution = execution; + if (pending?.kind === 'accept') settle(attempt, 'accepted', INTERNAL_ERROR, pending.artifact); + else if (pending?.kind === 'fail') fail(attempt, pending.reason); + return true; + }; + + const ownerInserted = (attempt: Attempt): void => { + if (!attempt.active || attempt.phaseValue !== 'waiting_for_insertion' || attempt.inserted) + return; + attempt.inserted = true; + clearOwnedTimer(attempt.insertionTimer); + attempt.insertionTimer = undefined; + if (attempt.cycle.bid.renderSource.type === 'adm') { + attempt.insertionTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); + if (attempt.insertionTimer === undefined) fail(attempt, INTERNAL_ERROR); + } + }; + + const handleOwnerControl = (attempt: Attempt, event: unknown): void => { + if (!attempt.active) return; + const ports = inspectPorts(event, messageEventPrototype); + if (!ports?.exact || ports.originalCount !== 0 || ports.ports.length !== 0) { + fail(attempt, INTERNAL_ERROR); + return; + } + const message = exactRecord(eventField(event, 'data', messageEventPrototype), [ + 'message', + 'version', + 'lifecycleTicket', + ]); + const ticket = attempt.ownerTicket; + if ( + message?.message === 'TS Owner Inserted' && + message.version === 1 && + message.lifecycleTicket === ticket + ) { + ownerInserted(attempt); + return; + } + if (attempt.cycle.bid.renderSource.type !== 'adm') { + fail(attempt, INTERNAL_ERROR); + return; + } + if ( + message?.message === 'TS ADM Loaded' && + message.version === 1 && + message.lifecycleTicket === ticket && + attempt.inserted + ) { + clearOwnedTimer(attempt.insertionTimer); + attempt.insertionTimer = undefined; + const artifact = publisherArtifact(options.document, attempt); + if (artifact) settle(attempt, 'accepted', INTERNAL_ERROR, artifact); + else fail(attempt, ADM_DOCUMENT_NO_LOAD); + return; + } + if ( + message?.message === 'TS ADM Failed' && + message.version === 1 && + message.lifecycleTicket === ticket + ) { + fail(attempt, ADM_DOCUMENT_NO_LOAD); + return; + } + fail(attempt, ADM_DOCUMENT_NO_LOAD); + }; + + const startOwner = (attempt: Attempt): boolean => { + const controlPort = attempt.controlPort; + const ticket = attempt.ownerTicket; + if (!controlPort || !ticket || !attempt.active) return false; + if (!validCycle(attempt.cycle, strategy)) return fail(attempt, 'slot_unresolved'); + attempt.insertionTimer = arm( + () => fail(attempt, 'owner_insertion_timeout'), + INSERTION_DEADLINE_MS + ); + if (attempt.insertionTimer === undefined) return fail(attempt, INTERNAL_ERROR); + if (attempt.cycle.bid.renderSource.type === 'adm') { + return ( + post(controlPort, { + message: 'TS ADM Start', + version: 1, + lifecycleTicket: ticket, + source: attempt.cycle.bid.renderSource, + }) || fail(attempt, INTERNAL_ERROR) + ); + } + if (!beginExecution(attempt, true)) return fail(attempt, 'winner_not_renderable'); + if (attempt.active) ownerInserted(attempt); + return ( + post(controlPort, { + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: ticket, + }) || fail(attempt, INTERNAL_ERROR) + ); + }; + + const handleOwnerRegistration = ( + event: unknown, + data: unknown, + routing: Readonly<{ adId?: string; lifecycleTicket?: string }> + ): void => { + const ticket = routing.lifecycleTicket; + if (!ticket) return; + const beforeSuppress = tickets.get(ticket); + if (!beforeSuppress || !suppress(event)) return; + const entry = tickets.get(ticket); + const inspection = inspectPorts(event, messageEventPrototype); + const responsePort = inspection?.ports[0]; + const refuse = (): void => { + if (responsePort) post(responsePort, ownerRefused(routing.adId ?? '')); + for (const port of inspection?.ports ?? []) closePort(port); + }; + if (entry !== beforeSuppress || entry.registryState !== 'live') { + refuse(); + return; + } + const exact = exactOwnerRegistration(data); + const attempt = entry.attempt; + if ( + !exact || + !inspection?.exact || + inspection.originalCount !== 1 || + inspection.ports.length !== 1 || + !responsePort || + exact.adId !== attempt.reservationId || + exact.lifecycleTicket !== ticket || + eventSource(event, messageEventPrototype) !== attempt.ownerSource || + !attempt.active || + attempt.phaseValue !== 'waiting_for_owner' + ) { + refuse(); + if (attempt.active) fail(attempt, 'bridge_id_mismatch'); + return; + } + let channel: FirstDisplayChannelLikeV1; + try { + channel = options.createChannel(); + } catch { + post(responsePort, ownerRefused(attempt.reservationId)); + closePort(responsePort); + fail(attempt, INTERNAL_ERROR); + return; + } + if ( + tickets.get(ticket) !== entry || + !attempt.active || + attempt.phaseValue !== 'waiting_for_owner' + ) { + closePort(channel.port1); + closePort(channel.port2); + refuse(); + return; + } + retireTicket(attempt); + attempt.ownerTicket = ticket; + attempt.controlPort = channel.port1; + attempt.phaseValue = 'waiting_for_insertion'; + const listening = installPortListeners( + channel.port1, + (message) => handleOwnerControl(attempt, message), + () => + fail( + attempt, + attempt.cycle.bid.renderSource.type === 'adm' ? ADM_DOCUMENT_NO_LOAD : INTERNAL_ERROR + ), + (release) => { + attempt.controlRelease = release; + } + ); + if (!listening || !attempt.active) { + closePort(responsePort); + closePort(channel.port2); + if (attempt.active) fail(attempt, INTERNAL_ERROR); + return; + } + const registered = JSON.stringify({ + message: 'TS Render Owner Registered', + adId: attempt.reservationId, + version: 1, + lifecycleTicket: ticket, + }); + const posted = post(responsePort, registered, [channel.port2]); + closePort(responsePort); + closePort(channel.port2); + if (!posted || !attempt.active || !startOwner(attempt)) { + if (attempt.active) fail(attempt, INTERNAL_ERROR); + } + }; + + const dispatch = (event: unknown): void => { + if (disposed || ingressClosed) return; + const data = eventField(event, 'data', messageEventPrototype); + const routing = routingMessage(data); + if (routing.message === 'TS Render Owner Register') { + notifyNativeMutation(); + handleOwnerRegistration(event, data, routing); + return; + } + if (routing.message !== 'Prebid Request' || !routing.adId) return; + const beforeSuppress = reservations.get(routing.adId); + if (!beforeSuppress || !suppress(event)) return; + notifyNativeMutation(); + const reservationState = reservations.get(routing.adId); + const inspection = inspectPorts(event, messageEventPrototype); + const responsePort = inspection?.ports[0]; + const refuse = (): void => { + if (responsePort) post(responsePort, refusedResponse(routing.adId!)); + for (const port of inspection?.ports ?? []) closePort(port); + }; + if ( + reservationState !== beforeSuppress || + reservationState.registryState !== 'live' || + !inspection?.exact || + inspection.originalCount !== 1 || + inspection.ports.length !== 1 || + !responsePort + ) { + refuse(); + return; + } + const exact = exactPrebidRequest(data); + const attempt = attempts.get(routing.adId); + const source = eventSource(event, messageEventPrototype); + if ( + !exact || + exact.adId !== routing.adId || + !attempt?.active || + attempt.phaseValue !== 'waiting_for_gam_and_claim' || + attempt.claim || + !source + ) { + refuse(); + return; + } + attempt.claim = Object.freeze({ port: responsePort, source }); + if (attempt.gam === 'nonempty_gam') join(attempt); + }; + + try { + options.browser.addEventListener('message', dispatch as EventListener, true); + } catch { + throw new TypeError('tsjs'); + } + + const sweepCommittedArtifacts = (): number => { + if (disposed || committedArtifactsDetached) return 0; + let retired = 0; + for (const [slotId, artifact] of [...committed.entries()]) { + if (committed.get(slotId) !== artifact || artifact.current()) continue; + committed.delete(slotId); + try { + artifact.retire(); + } catch { + // Invalidated identity is already detached from the journal. + } + retired += 1; + } + if (retired > 0) notifyNativeMutation(); + return retired; + }; + + return Object.freeze({ + bind: ( + cycle: FirstDisplayGptBoundCycleV1, + onTerminal: (result: 'accepted' | 'failed' | 'cancelled', reason: string | null) => void + ): boolean => { + if ( + disposed || + ingressClosed || + sealed || + typeof onTerminal !== 'function' || + !validCycle(cycle, strategy) || + reservations.size >= MAX_CAPABILITIES || + reservations.has(cycle.bid.rendererReservationId) + ) + return false; + const observedAt = readNow(); + if (observedAt === undefined) return false; + const expiresAt = observedAt + RESERVATION_TTL_MS; + const ordinal = nextReservationOrdinal; + if (!Number.isFinite(expiresAt) || expiresAt <= observedAt || ordinal > 4_294_967_295) { + return false; + } + const attempt: Attempt = { + active: true, + claim: undefined, + claimTimer: undefined, + controlPort: undefined, + controlRelease: undefined, + cycle, + execution: undefined, + gam: undefined, + insertionTimer: undefined, + inserted: false, + onTerminal, + ownerSource: undefined, + ownerTicket: undefined, + phaseValue: 'waiting_for_gam_and_claim', + reservationId: cycle.bid.rendererReservationId, + ticket: undefined, + }; + attempts.set(attempt.reservationId, attempt); + reservations.set(attempt.reservationId, { + expiresAtInternal: expiresAt, + ordinalInternal: ordinal, + registryState: 'live', + }); + nextReservationOrdinal += 1; + return true; + }, + recordGam: ( + cycle: FirstDisplayGptBoundCycleV1, + result: FirstDisplayGptRenderResult + ): boolean => { + const attempt = attempts.get(cycle.bid.rendererReservationId); + if ( + !attempt?.active || + attempt.cycle !== cycle || + attempt.gam || + attempt.phaseValue !== 'waiting_for_gam_and_claim' + ) + return false; + attempt.gam = result; + if (result === 'gam_empty') { + attempt.phaseValue = 'rendering_direct'; + retireReservation(attempt); + const claim = attempt.claim; + attempt.claim = undefined; + if (claim) { + post(claim.port, refusedResponse(attempt.reservationId)); + closePort(claim.port); + if (!attempt.active) return false; + } + return beginExecution(attempt, false) || fail(attempt, 'winner_not_renderable'); + } + if (attempt.claim) return join(attempt); + attempt.claimTimer = arm(() => fail(attempt, 'bridge_claim_timeout'), CLAIM_DEADLINE_MS); + return attempt.claimTimer !== undefined || fail(attempt, INTERNAL_ERROR); + }, + recordFailure: (cycle: FirstDisplayGptBoundCycleV1): boolean => { + const attempt = attempts.get(cycle.bid.rendererReservationId); + return Boolean( + attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') + ); + }, + retire: (cycle: FirstDisplayGptBoundCycleV1): boolean => { + if (disposed || committedArtifactsDetached) return false; + const artifact = committed.get(cycle.slotId); + if (!artifact || artifact.token !== cycle.bid.rendererReservationId) return false; + committed.delete(cycle.slotId); + try { + artifact.retire(); + } catch { + // Exact identity is already retired from the journal. + } + notifyNativeMutation(); + return true; + }, + sweepCommittedArtifacts, + sealTsAdmission: (): void => { + if (disposed || [...attempts.values()].some((attempt) => attempt.active)) { + throw new TypeError('tsjs'); + } + sealed = true; + }, + closeIngress: (): boolean => { + if ( + disposed || + ingressClosed || + !sealed || + [...attempts.values()].some((attempt) => attempt.active) + ) + return false; + ingressClosed = true; + try { + options.browser.removeEventListener('message', dispatch as EventListener, true); + } catch { + // Closed generation state remains authoritative. + } + for (const handle of [...timers]) clearOwnedTimer(handle); + return true; + }, + captureHandoff: () => { + if (disposed || !ingressClosed || handoffCaptured) return undefined; + sweepCommittedArtifacts(); + const observedAt = readNow(); + if (observedAt === undefined) return undefined; + const reservationTombstones = [...reservations.entries()] + .filter( + ([, entry]) => entry.registryState === 'tombstone' && entry.expiresAtInternal > observedAt + ) + .map(([value, entry]) => + Object.freeze({ + kind: 'reservation' as const, + value, + expiresAtMs: entry.expiresAtInternal, + ordinal: entry.ordinalInternal, + }) + ); + const ticketTombstones = [...tickets.entries()] + .filter( + ([, entry]) => entry.registryState === 'tombstone' && entry.expiresAtInternal > observedAt + ) + .map(([value, entry]) => + Object.freeze({ + kind: 'ticket' as const, + value, + expiresAtMs: entry.expiresAtInternal, + ordinal: entry.ordinalInternal, + }) + ); + const artifacts = [...committed.values()].map((artifact) => + Object.freeze({ + hostPosition: artifact.hostPosition, + hostPositionPriority: artifact.hostPositionPriority, + identity: artifact.identity, + kind: artifact.kind, + owner: artifact.owner, + slotId: artifact.slotId, + token: artifact.token, + }) + ); + handoffCaptured = true; + return Object.freeze({ + artifacts: Object.freeze(artifacts), + clockEpochMs: observedAt, + nextReservationOrdinal, + nextTicketOrdinal, + tombstones: Object.freeze([...reservationTombstones, ...ticketTombstones]), + }); + }, + detachCommittedArtifacts: (): boolean => { + if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { + return false; + } + if (sweepCommittedArtifacts() > 0) return false; + committedArtifactsDetached = true; + return true; + }, + dispose: (): void => { + if (disposed) return; + disposed = true; + if (!ingressClosed) { + ingressClosed = true; + try { + options.browser.removeEventListener('message', dispatch as EventListener, true); + } catch { + // Generation latching keeps a failed physical removal inert. + } + } + for (const attempt of [...attempts.values()]) { + if (attempt.active) settle(attempt, 'cancelled', NAVIGATION_DISPOSED); + } + for (const handle of [...timers]) clearOwnedTimer(handle); + if (!committedArtifactsDetached) { + for (const artifact of committed.values()) { + try { + artifact.retire(); + } catch { + // Exact artifact retirement is best-effort after ownership removal. + } + } + } + committed.clear(); + attempts.clear(); + reservations.clear(); + tickets.clear(); + try { + strategy?.dispose(); + } catch { + // Strategy authority is generation-latched. + } + }, + }); +} + +function exactBindings(candidate: unknown): RenderOwnerInitialBindings | undefined { + try { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + !Object.isFrozen(candidate) || + Reflect.ownKeys(candidate).length !== 2 + ) + return undefined; + const observe = Object.getOwnPropertyDescriptor(candidate, 'observe'); + const register = Object.getOwnPropertyDescriptor(candidate, 'register'); + if ( + !observe?.enumerable || + !('value' in observe) || + typeof observe.value !== 'function' || + !register?.enumerable || + !('value' in register) || + typeof register.value !== 'function' + ) + return undefined; + return candidate as RenderOwnerInitialBindings; + } catch { + return undefined; + } +} + +/** Install the one-use release-private source-neutral initial render owner. */ +export function installRenderOwnerInitial( + candidate: unknown, + own: FirstDisplaySliceActivationContext['own'] +): Readonly<{ version: 1; id: 'render_owner' }> { + const bindings = exactBindings(candidate); + if (!bindings || typeof own !== 'function') throw new TypeError('tsjs'); + let consumed = false; + let created: FirstDisplayRenderBridgeV1 | undefined; + const protocol: FirstDisplayRenderOwnerProtocolV1 = Object.freeze({ + version: 1, + id: 'render_owner', + createRenderBridge: ( + options: FirstDisplayRenderOwnerOptionsV1, + strategy?: FirstDisplayRenderStrategyV1 + ) => { + if (consumed) throw new TypeError('tsjs'); + consumed = true; + created = createFirstDisplayRenderJournal(options, strategy); + return created; + }, + }); + const release = bindings.register(protocol); + if (typeof release !== 'function') throw new TypeError('tsjs'); + own(() => { + const bridge = created; + created = undefined; + try { + bridge?.dispose(); + } finally { + release(); + } + }); + bindings.observe('protocol_version', 1); + return Object.freeze({ version: 1, id: 'render_owner' }); +} diff --git a/crates/trusted-server-js/lib/src/first_display/slices/aps.ts b/crates/trusted-server-js/lib/src/first_display/slices/aps.ts index 03d318ec0..b8b18f2d0 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/aps.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/aps.ts @@ -4,4 +4,4 @@ import { defineInitialSlice, registerInitialSlice } from './definition'; export const APS_INITIAL_SLICE = defineInitialSlice('aps_initial', installApsInitial); -registerInitialSlice(APS_INITIAL_SLICE, 2); +registerInitialSlice(APS_INITIAL_SLICE, 3); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/creative.ts b/crates/trusted-server-js/lib/src/first_display/slices/creative.ts index ef924f406..047a5703e 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/creative.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/creative.ts @@ -7,4 +7,4 @@ export const CREATIVE_INITIAL_SLICE = defineInitialSlice( installCreativeInitial ); -registerInitialSlice(CREATIVE_INITIAL_SLICE, 3); +registerInitialSlice(CREATIVE_INITIAL_SLICE, 4); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts b/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts index 3a3c46936..d9c9ed793 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts @@ -7,4 +7,4 @@ export const DATADOME_INITIAL_SLICE = defineInitialSlice( installDataDomeInitial ); -registerInitialSlice(DATADOME_INITIAL_SLICE, 4); +registerInitialSlice(DATADOME_INITIAL_SLICE, 5); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts b/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts index 044315d21..5dc1999bd 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts @@ -4,4 +4,4 @@ import { defineInitialSlice, registerInitialSlice } from './definition'; export const DIDOMI_INITIAL_SLICE = defineInitialSlice('didomi_initial', installDidomiInitial); -registerInitialSlice(DIDOMI_INITIAL_SLICE, 5); +registerInitialSlice(DIDOMI_INITIAL_SLICE, 6); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts b/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts index ad3239453..c314ad4cb 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts @@ -7,4 +7,4 @@ export const GOOGLE_TAG_MANAGER_INITIAL_SLICE = defineInitialSlice( installGoogleTagManagerInitial ); -registerInitialSlice(GOOGLE_TAG_MANAGER_INITIAL_SLICE, 6); +registerInitialSlice(GOOGLE_TAG_MANAGER_INITIAL_SLICE, 7); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts index 15c319846..748186fe2 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts @@ -12,4 +12,4 @@ export const GPT_INITIAL_SLICE = defineInitialSlice('gpt_initial', (candidate, o ) ); -registerInitialSlice(GPT_INITIAL_SLICE, 7); +registerInitialSlice(GPT_INITIAL_SLICE, 8); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts b/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts index 7c0443e2d..e612a4399 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts @@ -4,4 +4,4 @@ import { defineInitialSlice, registerInitialSlice } from './definition'; export const LOCKR_INITIAL_SLICE = defineInitialSlice('lockr_initial', installLockrInitial); -registerInitialSlice(LOCKR_INITIAL_SLICE, 8); +registerInitialSlice(LOCKR_INITIAL_SLICE, 9); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/osano.ts b/crates/trusted-server-js/lib/src/first_display/slices/osano.ts index 5826690bb..ca2541646 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/osano.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/osano.ts @@ -4,4 +4,4 @@ import { defineInitialSlice, registerInitialSlice } from './definition'; export const OSANO_INITIAL_SLICE = defineInitialSlice('osano_initial', installOsanoInitial); -registerInitialSlice(OSANO_INITIAL_SLICE, 9); +registerInitialSlice(OSANO_INITIAL_SLICE, 10); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts b/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts index 2ef87996e..2bd29b352 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts @@ -7,4 +7,4 @@ export const PERMUTIVE_INITIAL_SLICE = defineInitialSlice( installPermutiveInitial ); -registerInitialSlice(PERMUTIVE_INITIAL_SLICE, 10); +registerInitialSlice(PERMUTIVE_INITIAL_SLICE, 11); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts b/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts index 6d5068e76..f66d0d0fe 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts @@ -4,4 +4,4 @@ import { defineInitialSlice, registerInitialSlice } from './definition'; export const PREBID_INITIAL_SLICE = defineInitialSlice('prebid_initial', installPrebidInitial); -registerInitialSlice(PREBID_INITIAL_SLICE, 12); +registerInitialSlice(PREBID_INITIAL_SLICE, 13); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts b/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts new file mode 100644 index 000000000..54a956ca4 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts @@ -0,0 +1,10 @@ +import { installRenderOwnerInitial } from '../render_journal'; + +import { defineInitialSlice, registerInitialSlice } from './definition'; + +export const RENDER_OWNER_INITIAL_SLICE = defineInitialSlice( + 'render_owner_initial', + installRenderOwnerInitial +); + +registerInitialSlice(RENDER_OWNER_INITIAL_SLICE, 2); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts b/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts index e5afbd1b8..7a4460df0 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts @@ -7,4 +7,4 @@ export const SOURCEPOINT_INITIAL_SLICE = defineInitialSlice( installSourcepointInitial ); -registerInitialSlice(SOURCEPOINT_INITIAL_SLICE, 11); +registerInitialSlice(SOURCEPOINT_INITIAL_SLICE, 12); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts b/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts index eaea6bc7e..738689aba 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts @@ -7,4 +7,4 @@ export const TESTLIGHT_INITIAL_SLICE = defineInitialSlice( installTestlightInitial ); -registerInitialSlice(TESTLIGHT_INITIAL_SLICE, 13); +registerInitialSlice(TESTLIGHT_INITIAL_SLICE, 14); diff --git a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts index 75c31417e..fe58751d7 100644 --- a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts @@ -318,7 +318,8 @@ export function adoptInitialRenderArtifactsFromHandoff( if ( typeof slotId !== 'string' || (kind !== 'aps' && kind !== 'gpt_adm') || - owner !== 'trusted_server' || + (owner !== 'trusted_server' && owner !== 'publisher') || + (owner === 'publisher' && kind !== 'gpt_adm') || typeof token !== 'string' || !/^r1_[A-Za-z0-9_-]{22}$/.test(token) || (hostPosition === null && hostPositionPriority !== null) || @@ -336,6 +337,7 @@ export function adoptInitialRenderArtifactsFromHandoff( const previousHostPosition = hostPosition as string | null; const previousHostPositionPriority = hostPositionPriority as '' | 'important' | null; const frame = identity; + const expectedParent = frame.parentNode; const expectedContentWindow = frame.contentWindow; const expectedSourceAttribute = frame.getAttribute('src'); const expectedSource = frame.src; @@ -346,7 +348,8 @@ export function adoptInitialRenderArtifactsFromHandoff( if ( !host.isConnected || host.ownerDocument !== document || - frame.parentNode !== host || + !expectedParent || + (owner === 'trusted_server' && expectedParent !== host) || !frame.isConnected || frame.ownerDocument !== document || !expectedContentWindow || @@ -368,7 +371,7 @@ export function adoptInitialRenderArtifactsFromHandoff( dispose: (): void => { if (disposed) return; disposed = true; - if (!armed) return; + if (!armed || owner === 'publisher') return; try { frame.remove(); } catch { @@ -398,7 +401,7 @@ export function adoptInitialRenderArtifactsFromHandoff( document.getElementById(domId as string) === host && host.isConnected && host.ownerDocument === document && - frame.parentNode === host && + frame.parentNode === expectedParent && frame.isConnected && frame.ownerDocument === document && frame.contentWindow === expectedContentWindow && diff --git a/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts b/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts index 5f7797054..0c7a4a039 100644 --- a/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts +++ b/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts @@ -12,54 +12,9 @@ function installPucDynamicOwner(): void { 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 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', - 'adm_document_no_load', - 'abi_mismatch', - 'bundle_partial', - ]); - const cancellationReasons = new Set(['caller_aborted', 'superseded', 'navigation_disposed']); + const renderFailureReason = + /^(?: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|adm_document_no_load|abi_mismatch|bundle_partial)$/; + const cancellationReason = /^(?: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') @@ -195,109 +150,6 @@ function installPucDynamicOwner(): void { } } }; - 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 { - key = JSON.parse(source.slice(index, keyEnd)) as unknown; - } catch { - 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 { - return undefined; - } - }; - const parseRegistration = (value: unknown): Record | undefined => { - try { - if (typeof value !== 'string' || new TextEncoder().encode(value).byteLength > 4096) { - return undefined; - } - return exactRecord(parseJsonWithoutDuplicateKeys(value), [ - '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 => { @@ -586,7 +438,7 @@ function installPucDynamicOwner(): void { if ( message['outcome'] === 'failed' && typeof message['reason'] === 'string' && - renderFailureReasons.has(message['reason']) + renderFailureReason.test(message['reason']) ) { finish(false, message['reason']); return; @@ -594,7 +446,7 @@ function installPucDynamicOwner(): void { if ( message['outcome'] === 'cancelled' && typeof message['reason'] === 'string' && - cancellationReasons.has(message['reason']) + cancellationReason.test(message['reason']) ) { finish(false, String(message['reason'])); return; @@ -613,14 +465,20 @@ function installPucDynamicOwner(): void { clearTimer(registrationTimer); const ports = eventPorts(event, 1); const dataValue = eventDataValue(event); - const response = parseRegistration(dataValue); + const response = + typeof dataValue === 'string' && + dataValue === + JSON.stringify({ + message: 'TS Render Owner Registered', + adId, + version: 1, + lifecycleTicket, + }); if ( !ports || !response || - response['message'] !== 'TS Render Owner Registered' || - response['adId'] !== adId || - response['version'] !== 1 || - response['lifecycleTicket'] !== lifecycleTicket + typeof adId !== 'string' || + typeof lifecycleTicket !== 'string' ) { closeEventPorts(event); finish(false, 'TS render owner registration refused'); diff --git a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts index de77b224b..a8241084b 100644 --- a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts +++ b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts @@ -448,7 +448,7 @@ export function validateRuntimeManifestV1( firstDisplay = null; } else { const fields = readExactDataFields(manifestFields.firstDisplay, ['src', 'slices']); - const slices = snapshotExactArray(fields?.slices, 13); + const slices = snapshotExactArray(fields?.slices, 14); if ( !fields || typeof fields.src !== 'string' || diff --git a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts index 8be1b1054..89deec0e5 100644 --- a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts +++ b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts @@ -61,6 +61,7 @@ export interface ReleaseCatalogSelection { export type FirstDisplaySliceId = | 'first_display' + | 'render_owner_initial' | 'aps_initial' | 'creative_initial' | 'datadome_initial' @@ -76,6 +77,7 @@ export type FirstDisplaySliceId = export type FirstDisplayIncludePredicate = | 'eligible_batch' + | 'render_owner_participates' | 'aps_participates' | 'creative_guard' | 'gpt_initial' @@ -104,6 +106,7 @@ export interface FirstDisplayCatalogSelection { readonly eligibleBatch: boolean; readonly integrations: readonly string[]; readonly apsParticipates?: boolean; + readonly renderOwnerParticipates?: boolean; readonly prebidParticipates?: boolean; readonly creative?: Readonly<{ enabled: boolean; @@ -132,7 +135,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object 'shared/first_display_registration', 'shared/first_display_transaction', 'shared/integration_config_validators', - 'first_display/adm_render_bridge', 'first_display/driver', 'first_display/leaf/projection', 'first_display/registration_client', @@ -145,6 +147,22 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object }), firstDisplayRow({ order: 2, + id: 'render_owner_initial', + include: 'render_owner_participates', + allowedImports: [ + 'shared/first_display_contracts', + 'first_display/registration_client', + 'first_display/slices/definition', + 'first_display/render_journal', + 'kernel/contracts/puc_dynamic_owner', + ], + inputs: ['first_display.control.v1'], + outputs: ['render_owner.initial.v1'], + obligation: + 'Own source-neutral reservation, ticket, v4 PUC, render journal, handoff, and retirement state', + }), + firstDisplayRow({ + order: 3, id: 'aps_initial', include: 'aps_participates', allowedImports: [ @@ -153,14 +171,13 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object 'first_display/slices/definition', 'first_display/leaf/aps_protocol', 'first_display/render_bridge', - 'kernel/contracts/puc_dynamic_owner', ], - inputs: ['first_display.control.v1'], + inputs: ['first_display.control.v1', 'render_owner.initial.v1'], outputs: ['aps.initial.v1'], - obligation: 'Own initial reservation, PUC, and APS document protocols', + obligation: 'Own APS URL, nonce, renderer-document, and top-mount authority', }), firstDisplayRow({ - order: 3, + order: 4, id: 'creative_initial', include: 'creative_guard', allowedImports: [ @@ -174,7 +191,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install current parser-time creative guards and record initial observations only', }), firstDisplayRow({ - order: 4, + order: 5, id: 'datadome_initial', include: 'integration:datadome', allowedImports: [ @@ -188,7 +205,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install the initial DataDome script and preload route guard', }), firstDisplayRow({ - order: 5, + order: 6, id: 'didomi_initial', include: 'integration:didomi', allowedImports: [ @@ -202,7 +219,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install the configured Didomi SDK path before SDK evaluation', }), firstDisplayRow({ - order: 6, + order: 7, id: 'google_tag_manager_initial', include: 'integration:google_tag_manager', allowedImports: [ @@ -216,7 +233,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install initial GTM script, preload, beacon, and fetch guards', }), firstDisplayRow({ - order: 7, + order: 8, id: 'gpt_initial', include: 'gpt_initial', allowedImports: [ @@ -231,7 +248,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Sole provisional GPT adapter, listeners, targeting, request, and handoff capture', }), firstDisplayRow({ - order: 8, + order: 9, id: 'lockr_initial', include: 'integration:lockr', allowedImports: [ @@ -245,7 +262,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install the initial Lockr script guard and bounded readiness observation', }), firstDisplayRow({ - order: 9, + order: 10, id: 'osano_initial', include: 'integration:osano', allowedImports: [ @@ -259,7 +276,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Capture the initial consent mirrors required by the protected batch', }), firstDisplayRow({ - order: 10, + order: 11, id: 'permutive_initial', include: 'integration:permutive', allowedImports: [ @@ -273,7 +290,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install initial guard/readiness and capture normalized segments', }), firstDisplayRow({ - order: 11, + order: 12, id: 'sourcepoint_initial', include: 'integration:sourcepoint', allowedImports: [ @@ -287,7 +304,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object obligation: 'Install the initial SDK guard and capture the GPP mirror', }), firstDisplayRow({ - order: 12, + order: 13, id: 'prebid_initial', include: 'prebid_participates', allowedImports: [ @@ -302,7 +319,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object 'Own initial artifact admission, queue, bidder, identity, EID, TS bid, and PUC setup', }), firstDisplayRow({ - order: 13, + order: 14, id: 'testlight_initial', include: 'integration:testlight', allowedImports: [ @@ -347,6 +364,12 @@ export function selectFirstDisplayCatalog( const include = (predicate: FirstDisplayIncludePredicate): boolean => { if (predicate === 'eligible_batch') return true; + if (predicate === 'render_owner_participates') { + return ( + selection.renderOwnerParticipates === true || + (selection.apsParticipates === true && integrations.has('aps')) + ); + } if (predicate === 'aps_participates') { return selection.apsParticipates === true && integrations.has('aps'); } diff --git a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts index c4c1260f4..6e0060678 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts @@ -3,6 +3,7 @@ import type { FirstDisplayGptDiagnosticsV1, FirstDisplayGptFactV1 } from '../sha export const FIRST_DISPLAY_CONTRACT_IDS: readonly FirstDisplaySliceId[] = Object.freeze([ 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -199,6 +200,13 @@ function snapshotSlices(value: unknown): readonly FirstDisplaySliceId[] | undefi if (!order || order <= previous) return undefined; previous = order; } + const renderOwner = slices.includes('render_owner_initial'); + if ( + (slices.includes('aps_initial') && !renderOwner) || + (renderOwner && !slices.includes('gpt_initial')) + ) { + return undefined; + } return slices as readonly FirstDisplaySliceId[]; } @@ -467,6 +475,7 @@ function validParserValues( const integer = (value: string | number | boolean | null): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 0; switch (sliceId) { + case 'render_owner_initial': case 'aps_initial': case 'prebid_initial': return single('protocol_version', (value) => value === 1); @@ -519,7 +528,9 @@ function snapshotParserState(value: unknown): Readonly> const entries = exactArray(fields?.values, 256); if ( !fields || - !snapshotSlices(['first_display', fields.sliceId]) || + typeof fields.sliceId !== 'string' || + fields.sliceId === 'first_display' || + !FIRST_DISPLAY_ORDER.has(fields.sliceId as FirstDisplaySliceId) || !observations || !entries || observations.length !== entries.length diff --git a/crates/trusted-server-js/lib/src/shared/first_display_registration.ts b/crates/trusted-server-js/lib/src/shared/first_display_registration.ts index a1997c2f6..04b4d44c2 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_registration.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_registration.ts @@ -6,6 +6,7 @@ const COMPONENT_ID = /^[a-z][a-z0-9_]{0,63}$/; const FIRST_DISPLAY_SRC = /^\/static\/tsjs=tsjs-first-display\.min\.js\?m=[0-9a-f]{4}&v=[0-9a-f]{64}$/; const PARSER_SLICE_ORDER = Object.freeze([ + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -187,7 +188,7 @@ export function snapshotFirstDisplayComponentRegistration( !HASH.test(registration.releaseId) || !Number.isInteger(registration.order) || registration.order < 1 || - registration.order > 13 || + registration.order > 14 || typeof registration.prepare !== 'function' ) { return undefined; diff --git a/crates/trusted-server-js/lib/src/shared/first_display_transaction.ts b/crates/trusted-server-js/lib/src/shared/first_display_transaction.ts index eea9d59a2..fac8e0a28 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_transaction.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_transaction.ts @@ -8,6 +8,7 @@ const FIRST_DISPLAY_SRC = const FIRST_DISPLAY_ORDER = new Map( [ 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -167,6 +168,7 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { ) { return this.reject(); } + if (this.stateValue !== 'collecting') return false; const prepared: PreparedFirstDisplaySliceV1[] = []; this.stateValue = 'preparing'; try { @@ -178,6 +180,7 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { sliceId: registration.id, }) ); + if (this.stateValue !== 'preparing') throw new TypeError('stale slice preparation'); const fields = exactRecord(candidate, ['activate']); if (!fields || typeof fields.activate !== 'function') throw new TypeError('invalid prepared slice'); @@ -185,42 +188,56 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { Object.freeze({ activate: fields.activate as PreparedFirstDisplaySliceV1['activate'] }) ); } - if (!this.authenticated()) throw new TypeError('stale first-display owner'); + if (!this.authenticated() || this.stateValue !== 'preparing') { + throw new TypeError('stale first-display owner'); + } this.stateValue = 'activating'; let ownershipOpen = true; let afterActivation: (() => void) | undefined; - for (let index = 0; index < prepared.length; index += 1) { - const slice = prepared[index]; - if (!slice) throw new TypeError('missing prepared first-display slice'); - slice.activate( - Object.freeze({ - own: (dispose: () => void): void => { - if (!ownershipOpen || typeof dispose !== 'function') { - throw new TypeError('first-display disposer registration is closed'); - } - this.disposers.push(dispose); - }, - afterActivate: (callback: () => void): void => { - if ( - !ownershipOpen || - index !== 0 || - afterActivation !== undefined || - typeof callback !== 'function' - ) { - throw new TypeError('invalid first-display post-activation callback'); - } - afterActivation = callback; - }, - }) - ); + try { + for (let index = 0; index < prepared.length; index += 1) { + const slice = prepared[index]; + if (!slice) throw new TypeError('missing prepared first-display slice'); + slice.activate( + Object.freeze({ + own: (dispose: () => void): void => { + if (!ownershipOpen || typeof dispose !== 'function') { + throw new TypeError('first-display disposer registration is closed'); + } + this.disposers.push(dispose); + }, + afterActivate: (callback: () => void): void => { + if ( + !ownershipOpen || + index !== 0 || + afterActivation !== undefined || + typeof callback !== 'function' + ) { + throw new TypeError('invalid first-display post-activation callback'); + } + afterActivation = callback; + }, + }) + ); + if (this.stateValue !== 'activating') { + throw new TypeError('stale first-display activation'); + } + } + } finally { + ownershipOpen = false; } - ownershipOpen = false; afterActivation?.(); - if (!this.authenticated()) throw new TypeError('stale first-display activation'); + if ( + !this.stateCurrent('activating') || + !this.authenticated() || + !this.stateCurrent('activating') + ) { + throw new TypeError('stale first-display activation'); + } this.stateValue = 'active'; return true; } catch { - this.stateValue = 'failed'; + if (!this.stateCurrent('disposed')) this.stateValue = 'failed'; this.unwind(); return false; } @@ -233,6 +250,10 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { this.registrations.length = 0; } + private stateCurrent(expected: FirstDisplayTransactionState): boolean { + return this.stateValue === expected; + } + private authenticated(): boolean { try { const { document, script, releaseId, generation, isCurrentGeneration } = this.options; @@ -262,9 +283,10 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { } private unwind(): void { - for (let index = this.disposers.length - 1; index >= 0; index -= 1) { + while (this.disposers.length > 0) { + const dispose = this.disposers.pop(); try { - this.disposers[index]?.(); + dispose?.(); } catch (error) { try { this.options.onDisposalError?.(error); @@ -273,7 +295,6 @@ class FirstDisplayTransactionOwner implements FirstDisplayTransaction { } } } - this.disposers.length = 0; } } diff --git a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs index 662db2f65..2205476ef 100644 --- a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs +++ b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs @@ -321,6 +321,56 @@ test('generated takeover completion leaves terminal fallback ownership with boot dom.window.close(); }); +test('generated bootstrap stops slice activation after a reentrant terminal callback', () => { + const { dom, selected } = createDocument(); + const target = { que: [] }; + const events = []; + const src = `/static/tsjs=tsjs-first-display.min.js?m=0081&v=${'c'.repeat(64)}`; + selected.src = src; + const bootValue = boot(); + bootValue.manifest.firstDisplay = { + src, + slices: ['first_display', 'gpt_initial'], + }; + const outlineValue = outline(); + outlineValue.slices = ['first_display', 'gpt_initial']; + dom.window.tsjs = target; + evaluateTransport(dom, transport(bootValue, outlineValue)); + + const base = { + abi: 1, + id: 'first_display', + releaseId: manifest.releaseId, + prepare: (host) => + Object.freeze({ + sliceHost: Object.freeze({}), + activate: ({ own }) => { + own(() => events.push('dispose-base')); + events.push('activate-base'); + host.options.onFailure('bundle_partial'); + }, + }), + }; + const gpt = { + abi: 1, + id: 'gpt_initial', + releaseId: manifest.releaseId, + prepare: () => + Object.freeze({ + activate: () => events.push('activate-gpt'), + }), + }; + + assert.equal(target._registerFirstDisplay.call(target, base, selected), true); + assert.equal(target._registerFirstDisplay.call(target, gpt, selected), false); + assert.deepEqual(events, ['activate-base', 'dispose-base']); + assert.equal( + Object.getOwnPropertyDescriptor(target, '_internal')?.value.reason, + 'bundle_partial' + ); + dom.window.close(); +}); + test('generated bootstrap commits one non-rendering terminal shell after registration failure', async () => { const { dom, selected } = createDocument(); const drained = []; 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 d0d029694..520ed7256 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 @@ -39,6 +39,7 @@ const bundle = (id, logical, role = 'integration', phase = 'takeover', trigger = const EXPECTED_RELEASE_BUNDLE_ORDER = [ 'bootstrap', 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -206,7 +207,7 @@ test('generated release inventory pins the server bundle order', () => { ); assert.equal(manifest.artifacts.filter(({ role }) => role === 'bootstrap').length, 1); assert.equal(manifest.artifacts.filter(({ role }) => role === 'first_display_base').length, 1); - assert.equal(manifest.artifacts.filter(({ role }) => role === 'first_display_slice').length, 12); + assert.equal(manifest.artifacts.filter(({ role }) => role === 'first_display_slice').length, 13); assert.equal(manifest.artifacts.filter(({ role }) => role === 'core').length, 1); assert.equal(manifest.artifacts.filter(({ role }) => role === 'integration').length, 20); for (const artifact of manifest.artifacts) { @@ -243,7 +244,7 @@ test('generated first-display components self-register through one authenticated ); const firstDisplay = release.artifacts.filter(({ phase }) => phase === 'first_display'); const dom = new JSDOM( - ``, + ``, { runScripts: 'outside-only', url: 'https://publisher.example/article', @@ -418,6 +419,18 @@ test('generated first-display components self-register through one authenticated onFailure: function() {} }), sliceBindings: function(id) { + if (id === 'render_owner_initial') { + return Object.freeze({ + bindings: Object.freeze({ + observe: function() {}, + register: function(protocol) { + window.__firstDisplayEvents.push('render_owner:' + protocol.id); + return function() {}; + } + }), + config: Object.freeze({}) + }); + } if (id === 'aps_initial') { return Object.freeze({ bindings: Object.freeze({ @@ -457,10 +470,14 @@ test('generated first-display components self-register through one authenticated }); `); const activatedBase = registrations[0].prepare(dom.window.__firstDisplayBaseHost); + const activatedRenderOwner = registrations + .find(({ id }) => id === 'render_owner_initial') + .prepare(activatedBase.sliceHost); const activatedGpt = registrations .find(({ id }) => id === 'gpt_initial') .prepare(activatedBase.sliceHost); activatedBase.activate(dom.window.__firstDisplayActivation); + activatedRenderOwner.activate(dom.window.__firstDisplaySliceActivation); activatedGpt.activate(dom.window.__firstDisplaySliceActivation); dom.window.__firstDisplayAfterActivate(); const directFrame = dom.window.document.querySelector('#slot-1 iframe'); @@ -468,7 +485,13 @@ test('generated first-display components self-register through one authenticated directFrame.dispatchEvent(new dom.window.Event('load')); assert.deepEqual( [...dom.window.__firstDisplayEvents], - ['gpt:gpt', 'bootstrap:register', 'bootstrap:action', 'gpt:display'] + [ + 'render_owner:render_owner', + 'gpt:gpt', + 'bootstrap:register', + 'bootstrap:action', + 'gpt:display', + ] ); } finally { dom.window.close(); @@ -967,16 +990,23 @@ test('bundle metrics enumerate and hash every reachable first-display mask', () const masks = metrics.firstDisplay.masks; assert.ok(Array.isArray(masks)); - assert.equal(masks.length, 2_560); + assert.equal(masks.length, 3_584); assert.equal(new Set(masks.map(({ mask }) => mask)).size, masks.length); assert.equal(new Set(masks.map(({ sha256 }) => sha256)).size, masks.length); for (const measurement of masks) { assert.match(measurement.mask, /^[0-9a-f]{4}$/u); assert.equal(measurement.ids[0], 'first_display'); if (!measurement.ids.includes('gpt_initial')) { + assert.equal(measurement.ids.includes('render_owner_initial'), false); assert.equal(measurement.ids.includes('aps_initial'), false); assert.equal(measurement.ids.includes('prebid_initial'), false); } + if (measurement.ids.includes('aps_initial')) { + assert.equal(measurement.ids.includes('render_owner_initial'), true); + } + if (measurement.ids.includes('render_owner_initial')) { + assert.equal(measurement.ids.includes('gpt_initial'), true); + } assert.deepEqual( measurement.files, measurement.ids.map((id) => `tsjs-${id}.js`) @@ -1005,7 +1035,7 @@ test('candidate architecture obeys every independent absolute transfer ceiling', }); assert.doesNotThrow(() => bundleBudgets.enforceCandidateArchitectureSizeCeilings(report)); - assert.equal(report.firstDisplay.masks.length, 2_560); + assert.equal(report.firstDisplay.masks.length, 3_584); assert.ok(report.firstDisplay.permittedMasks.length > 0); assert.ok(report.firstDisplay.permittedMasks.length < report.firstDisplay.masks.length); for (const name of ['minimal', 'reference', 'aps']) { @@ -1020,6 +1050,7 @@ test('candidate architecture obeys every independent absolute transfer ceiling', ]); assert.deepEqual(report.firstDisplay.named.aps.ids, [ 'first_display', + 'render_owner_initial', 'aps_initial', 'creative_initial', 'gpt_initial', @@ -1038,6 +1069,15 @@ test('candidate architecture obeys every independent absolute transfer ceiling', 'referencePersistent', 'maximalTotal', ]); + const maximalNonBootstrap = bundleMetrics.measureBundleSet( + release.artifacts.filter(({ role }) => role !== 'bootstrap').map(({ file }) => file), + currentArtifactContents + ); + assert.deepEqual(report.maximalTotal, { + rawBytes: maximalNonBootstrap.rawBytes, + gzipBytes: maximalNonBootstrap.gzipBytes, + brotliBytes: maximalNonBootstrap.brotliBytes, + }); }); test('absolute transfer ceilings reject independent one-byte regressions', () => { @@ -1833,6 +1873,54 @@ test('production bundle graphs reject every current forbidden edge', () => { ); }); +test('first-display render ownership is physically split at the source-neutral boundary', () => { + const { metrics, release } = readBuildEvidence(); + const sources = (id) => + new Set( + metrics.modules.find(({ file }) => file === `tsjs-${id}.js`).sources.map(({ file }) => file) + ); + const ownerSources = sources('render_owner_initial'); + const apsSources = sources('aps_initial'); + const baseSources = sources('first_display'); + + assert.equal(ownerSources.has('src/first_display/render_journal.ts'), true); + assert.equal(ownerSources.has('src/kernel/contracts/puc_dynamic_owner.ts'), true); + assert.equal(ownerSources.has('src/first_display/render_bridge.ts'), false); + assert.equal(ownerSources.has('src/first_display/leaf/aps_protocol.ts'), false); + assert.equal(apsSources.has('src/first_display/render_bridge.ts'), true); + assert.equal(apsSources.has('src/first_display/leaf/aps_protocol.ts'), true); + assert.equal(apsSources.has('src/first_display/render_journal.ts'), false); + assert.equal(apsSources.has('src/kernel/contracts/puc_dynamic_owner.ts'), false); + assert.equal(baseSources.has('src/first_display/render_journal.ts'), false); + assert.equal(baseSources.has('src/first_display/adm_render_bridge.ts'), false); + + const ownerBody = fs.readFileSync( + path.resolve(libDirectory, '../dist/tsjs-render_owner_initial.js'), + 'utf8' + ); + const apsLiterals = [...ownerBody.matchAll(/TS APS [A-Za-z ]+/gu)].map(([value]) => value); + assert.ok(apsLiterals.length > 0); + assert.deepEqual(new Set(apsLiterals), new Set(['TS APS Top Mount Started'])); + + const movedJournal = structuredClone(metrics); + movedJournal.modules + .find(({ file }) => file === 'tsjs-aps_initial.js') + .sources.push({ file: 'src/first_display/render_journal.ts', renderedBytes: 1 }); + assert.match( + bundleBudgets.findProductionGraphViolations(movedJournal, release).join('\n'), + /render_owner_initial-owned source src\/first_display\/render_journal\.ts/ + ); + + const movedAps = structuredClone(metrics); + movedAps.modules + .find(({ file }) => file === 'tsjs-render_owner_initial.js') + .sources.push({ file: 'src/first_display/render_bridge.ts', renderedBytes: 1 }); + assert.match( + bundleBudgets.findProductionGraphViolations(movedAps, release).join('\n'), + /aps_initial-owned source src\/first_display\/render_bridge\.ts/ + ); +}); + test('production bundle graphs scan bootstrap sources for test and fake seams', () => { const { metrics, release } = readBuildEvidence(); metrics.bootstrap.sources.push({ file: 'src/test/fake_adapter.ts', renderedBytes: 1 }); @@ -1976,7 +2064,7 @@ test('bundle check authenticates frozen captures and reports both without enforc } const commandReport = bundleBudgets.summarizeBundleBudgetCommandReport(result); - assert.equal(commandReport.candidateArchitecture.firstDisplay.reachableMaskCount, 2_560); + assert.equal(commandReport.candidateArchitecture.firstDisplay.reachableMaskCount, 3_584); assert.equal( commandReport.candidateArchitecture.firstDisplay.permittedMaskCount, result.candidateArchitecture.firstDisplay.permittedMasks.length diff --git a/crates/trusted-server-js/lib/test/core/bootstrap.test.ts b/crates/trusted-server-js/lib/test/core/bootstrap.test.ts index be3e643ca..4738e3fa4 100644 --- a/crates/trusted-server-js/lib/test/core/bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/core/bootstrap.test.ts @@ -113,6 +113,21 @@ function transportInput(firstDisplay = false): Record { }; } +function selectFirstDisplay( + input: Record, + mask: string, + slices: readonly string[] +): void { + const boot = input.boot as Record; + const manifest = boot.manifest as Record; + manifest.firstDisplay = { + src: `/static/tsjs=tsjs-first-display.min.js?m=${mask}&v=${'c'.repeat(64)}`, + slices: [...slices], + }; + const outline = input.outline as Record; + outline.slices = [...slices]; +} + describe('sealed production boot transport', () => { it.each([false, true])('parses and recursively freezes a fresh %s transport', (firstDisplay) => { const original = transportInput(firstDisplay); @@ -188,6 +203,26 @@ describe('sealed production boot transport', () => { deferred.src = `/static/tsjs=tsjs-evil.min.js?v=${'e'.repeat(64)}`; expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); }); + + it.each([ + ['APS without the render owner', '0085', ['first_display', 'aps_initial', 'gpt_initial']], + ['the render owner without GPT', '0003', ['first_display', 'render_owner_initial']], + ])('rejects %s before exposing the compact boot', (_name, mask, slices) => { + const value = transportInput(true); + selectFirstDisplay(value, mask, slices); + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); + }); + + it.each([ + ['0081', ['first_display', 'gpt_initial']], + ['0083', ['first_display', 'render_owner_initial', 'gpt_initial']], + ])('admits the closed non-APS slice relationship %s', (mask, slices) => { + const value = transportInput(true); + selectFirstDisplay(value, mask, slices); + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeDefined(); + }); }); describe('bootstrap integration configuration admission', () => { @@ -257,6 +292,16 @@ describe('bootstrap integration configuration admission', () => { expect(snapshotBootstrapInputV1(input, RELEASE)).toBeUndefined(); }); + it.each([ + ['APS without the render owner', '0085', ['first_display', 'aps_initial', 'gpt_initial']], + ['the render owner without GPT', '0003', ['first_display', 'render_owner_initial']], + ])('rejects %s before retaining the full boot', (_name, mask, slices) => { + const input = firstDisplayInput(); + selectFirstDisplay(input, mask, slices); + + expect(snapshotBootstrapInputV1(input, RELEASE)).toBeUndefined(); + }); + it('preserves poison-named config data properties and rejects them outside config values', () => { const config = Object.defineProperty({}, '__proto__', { configurable: true, diff --git a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts index 7f16bd08c..30c4b7d84 100644 --- a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; import type { FirstDisplayGptBoundCycleV1 } from '../../src/first_display/adapters/googletag'; -import { createFirstDisplayAdmRenderBridge } from '../../src/first_display/adm_render_bridge'; + +import { createTestFirstDisplayRenderBridge } from './helpers/render_owner_composition'; const RESERVATION_ID = `r1_${'a'.repeat(22)}`; @@ -48,9 +49,15 @@ function harness() { let now = 0; const terminal = vi.fn(); const mutation = vi.fn(() => true); - const bridge = createFirstDisplayAdmRenderBridge({ + const bridge = createTestFirstDisplayRenderBridge({ + browser: dom.window as unknown as Window, clearTimer: (handle) => timers.delete(handle as object), + createChannel: () => { + throw new Error('ADM fallback must not create an owner channel'); + }, document: dom.window.document, + fillRandom: () => undefined, + getAps: () => undefined, now: () => now, onNativeMutation: mutation, setTimer: (callback) => { @@ -74,33 +81,7 @@ function harness() { }; } -describe('ADM-only first-display render bridge', () => { - it('accepts a nonempty GAM result without installing PUC/message authority', () => { - const h = harness(); - - expect(h.bridge.recordGam(h.cycle, 'nonempty_gam')).toBe(true); - expect(h.terminal).toHaveBeenCalledWith('accepted', null); - expect(h.element.querySelector('iframe')).toBeNull(); - expect(h.timers).toHaveLength(0); - - expect(() => h.bridge.sealTsAdmission()).not.toThrow(); - expect(h.bridge.closeIngress()).toBe(true); - expect(h.bridge.captureHandoff()).toEqual({ - artifacts: [], - clockEpochMs: 0, - nextReservationOrdinal: 2, - nextTicketOrdinal: 1, - tombstones: [ - { - expiresAtMs: 900_000, - kind: 'reservation', - ordinal: 1, - value: RESERVATION_ID, - }, - ], - }); - }); - +describe('ADM-only shared first-display render journal', () => { it('owns only the attributable empty-GAM ADM frame and transfers it once', () => { const h = harness(); diff --git a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts index 818f3d92a..64ea576fb 100644 --- a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts @@ -191,6 +191,19 @@ describe('first-display immutable contracts', () => { expect( snapshotTakeoverOutlineV1(outline({ slices: ['gpt_initial', 'first_display'] })) ).toBeUndefined(); + expect( + snapshotTakeoverOutlineV1( + outline({ slices: ['first_display', 'aps_initial', 'gpt_initial'] }) + ) + ).toBeUndefined(); + expect( + snapshotTakeoverOutlineV1(outline({ slices: ['first_display', 'render_owner_initial'] })) + ).toBeUndefined(); + expect( + snapshotTakeoverOutlineV1( + outline({ slices: ['first_display', 'render_owner_initial', 'gpt_initial'] }) + )?.slices + ).toEqual(['first_display', 'render_owner_initial', 'gpt_initial']); expect( snapshotTakeoverOutlineV1(outline({ capabilities: ['gpt_slot', 'gpt_slot'] })) ).toBeUndefined(); @@ -345,6 +358,25 @@ describe('first-display immutable contracts', () => { ).toBeUndefined(); }); + it('validates the render-owner parser row inside a complete owner-to-GPT handoff', () => { + const gptParserState = (handoff().parserState as object[])[0]!; + const accepted = snapshotFirstDisplayHandoffV1( + handoff({ + slices: ['first_display', 'render_owner_initial', 'gpt_initial'], + parserState: [ + { + sliceId: 'render_owner_initial', + observations: ['protocol_version'], + values: [['protocol_version', 1]], + }, + gptParserState, + ], + }) + ); + + expect(accepted?.slices).toEqual(['first_display', 'render_owner_initial', 'gpt_initial']); + }); + it('enforces 255/256/257 slot and outcome boundaries and strict high-water counters', () => { const base = handoff(); const slot = (base.slots as object[])[0]!; diff --git a/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts b/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts new file mode 100644 index 000000000..e22141a78 --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts @@ -0,0 +1,17 @@ +import type { FirstDisplayApsProtocolV1 } from '../../../src/first_display/leaf/aps_protocol'; +import { createFirstDisplayApsRenderStrategy } from '../../../src/first_display/render_bridge'; +import { + createFirstDisplayRenderJournal, + type FirstDisplayRenderOwnerOptionsV1, +} from '../../../src/first_display/render_journal'; + +type TestRenderBridgeOptions = FirstDisplayRenderOwnerOptionsV1 & + Readonly<{ getAps: () => FirstDisplayApsProtocolV1 | undefined }>; + +/** Compose the production owner and APS capabilities without creating a production graph seam. */ +export function createTestFirstDisplayRenderBridge(options: TestRenderBridgeOptions) { + const { getAps, ...ownerOptions } = options; + const aps = getAps(); + const strategy = aps ? createFirstDisplayApsRenderStrategy(ownerOptions, aps) : undefined; + return createFirstDisplayRenderJournal(ownerOptions, strategy); +} diff --git a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts index 408ff3298..af9513d2b 100644 --- a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; import type { FirstDisplayGptBoundCycleV1 } from '../../src/first_display/adapters/googletag'; -import { createFirstDisplayRenderBridge } from '../../src/first_display/render_bridge'; import { PUC_DYNAMIC_OWNER } from '../../src/kernel/contracts/puc_dynamic_owner'; +import { createTestFirstDisplayRenderBridge } from './helpers/render_owner_composition'; + const RESERVATION_ID = `r1_${'a'.repeat(22)}`; class FakePort { @@ -95,22 +96,25 @@ function fixture(kind: 'adm' | 'aps' = 'adm') { function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) { const value = fixture(kind); - let listener: ((event: Record) => void) | undefined; + const listeners: Array<(event: Record) => void> = []; const target = { addEventListener: vi.fn( (name: string, next: (event: Record) => void, capture: boolean) => { expect(name).toBe('message'); expect(capture).toBe(true); - listener = next; + listeners.push(next); } ), - removeEventListener: vi.fn(), + removeEventListener: vi.fn((_name: string, next: (event: Record) => void) => { + const index = listeners.indexOf(next); + if (index >= 0) listeners.splice(index, 1); + }), }; const channels: Array<{ port1: FakePort; port2: FakePort }> = []; const timers = new Map void; delayMs: number }>>(); let randomByte = 1; let now = 0; - const bridge = createFirstDisplayRenderBridge({ + const bridge = createTestFirstDisplayRenderBridge({ getAps: () => kind === 'aps' ? Object.freeze({ @@ -123,15 +127,9 @@ function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) permanentSandbox: 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation', deadlines: Object.freeze({ - insertionMs: 1_000 as const, documentAcceptanceMs: 3_000 as const, completionMs: 10_000 as const, - ownerSettlementMs: 20_000 as const, }), - isReservationId: (candidate: unknown): candidate is string => - typeof candidate === 'string' && /^r1_[A-Za-z0-9_-]{22}$/.test(candidate), - isLifecycleTicket: (candidate: unknown): candidate is string => - typeof candidate === 'string' && /^t1_[A-Za-z0-9_-]{22}$/.test(candidate), isBootstrapNonce: (candidate: unknown): candidate is string => typeof candidate === 'string' && /^b1_[A-Za-z0-9_-]{22}$/.test(candidate), isRendererNonce: (candidate: unknown): candidate is string => @@ -141,8 +139,26 @@ function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) creativeOrigin: 'https://creative.example', tagType: 'iframe' as const, }), - createRenderBridge: () => { - throw new Error('the full bridge fixture already owns construction'); + createRenderStrategy: () => { + throw new Error('the legacy bridge fixture already owns construction'); + }, + parseWindowMessage: (candidate: unknown) => { + if (typeof candidate !== 'string') return undefined; + const message = JSON.parse(candidate) as Record; + if (message.message === 'TS APS Bootstrap Ready') { + return Object.freeze({ + kind: 'bootstrap_ready' as const, + bootstrap: message.bootstrapNonce as string, + }); + } + if (message.message === 'TS APS Container Ready') { + return Object.freeze({ + kind: 'container_ready' as const, + bootstrap: message.bootstrapNonce as string, + renderer: message.rendererNonce as string, + }); + } + return undefined; }, parseDocumentMessage: (candidate: unknown, nonce: string) => { const message = candidate as Record; @@ -200,8 +216,8 @@ function harness(kind: 'adm' | 'aps' = 'adm', onNativeMutation?: () => boolean) }) ).toBe(true); const dispatch = (event: Record): void => { - if (!listener) throw new Error('expected capture listener'); - listener(event); + if (listeners.length === 0) throw new Error('expected capture listener'); + for (const listener of [...listeners]) listener(event); }; const fire = (delayMs: number): void => { const entry = [...timers.entries()].find(([, timer]) => timer.delayMs === delayMs); @@ -278,8 +294,8 @@ function collapsedShell(h: ReturnType) { return { frame, wrapper }; } -function registerOwner(h: ReturnType) { - const source = {}; +function registerOwner(h: ReturnType, ownerSource?: object) { + const source = ownerSource ?? {}; const responsePort = new FakePort(); h.dispatch(requestEvent(responsePort, { source })); expect(h.bridge.recordGam(h.cycle, 'nonempty_gam')).toBe(true); @@ -307,6 +323,7 @@ function startApsDocument(h: ReturnType) { ports: [], source: frame.contentWindow, }); + expect(h.terminalFacts).toEqual([]); const navigation = JSON.parse(postMessage.mock.calls[0]?.[0] as string) as Record< string, unknown @@ -345,12 +362,89 @@ function acceptPucAps(h: ReturnType): HTMLIFrameElement { return frame; } +function bindMixedAdm(h: ReturnType) { + const element = h.dom.window.document.createElement('div'); + element.id = 'slot-2'; + h.dom.window.document.body.appendChild(element); + const reservationId = `r1_${'b'.repeat(22)}`; + const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze({ + bid: Object.freeze({ + candidateId: 'candidate002', + slot: 'slot-2', + provider: 'example', + upstreamBidId: 'upstream-2', + cpm: 1.5, + currency: 'USD' as const, + targeting: Object.freeze({}), + rendererReservationId: reservationId, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
mixed ADM creative
', + width: 300, + height: 250, + }), + }), + element, + ownership: 'trusted_server', + physicalSlot: {}, + isCurrent: () => true, + placement: Object.freeze({ + slot: 'slot-2', + gamUnitPath: '/123/example-2', + divId: 'slot-2', + formats: Object.freeze([Object.freeze([300, 250] as const)]), + targeting: Object.freeze({}), + }), + slotId: 'slot-2', + traceToken: 'gt1_2', + }); + const terminal = vi.fn(); + expect(h.bridge.bind(cycle, terminal)).toBe(true); + return { cycle, element, reservationId, terminal }; +} + describe('bounded first-display render bridge', () => { + it('isolates interleaved APS and ADM ownership in one shared journal', () => { + const h = harness('aps'); + const adm = bindMixedAdm(h); + registerOwner(h); + + expect(h.bridge.recordGam(adm.cycle, 'gam_empty')).toBe(true); + const admFrame = adm.element.querySelector('iframe'); + expect(admFrame?.srcdoc).toContain('mixed ADM creative'); + admFrame?.dispatchEvent(new h.dom.window.Event('load')); + expect(adm.terminal).toHaveBeenCalledWith('accepted', null); + expect(h.terminals).toEqual([]); + + const { documentPort, frame: apsFrame, nonce } = startApsDocument(h); + documentPort.dispatch({ message: 'TS APS Document Accepted', version: 1, nonce }); + documentPort.dispatch({ message: 'TS APS Render Completed', version: 1, nonce }); + expect(h.terminals).toEqual(['accepted']); + + h.bridge.sealTsAdmission(); + expect(h.bridge.closeIngress()).toBe(true); + const handoff = h.bridge.captureHandoff(); + expect(handoff?.artifacts.map(({ kind, slotId, token }) => ({ kind, slotId, token }))).toEqual([ + { kind: 'gpt_adm', slotId: 'slot-2', token: adm.reservationId }, + { kind: 'aps', slotId: 'slot-1', token: RESERVATION_ID }, + ]); + expect( + new Set( + handoff?.tombstones.filter(({ kind }) => kind === 'reservation').map(({ value }) => value) + ) + ).toEqual(new Set([adm.reservationId, RESERVATION_ID])); + expect(h.bridge.detachCommittedArtifacts()).toBe(true); + h.bridge.dispose(); + expect(admFrame?.isConnected).toBe(true); + expect(apsFrame.isConnected).toBe(true); + }); + it('observes admitted bridge activity and terminal tombstone expiry', () => { const mutations = vi.fn(() => true); const h = harness('adm', mutations); const shell = collapsedShell(h); - const owner = registerOwner(h); + const owner = registerOwner(h, shell.frame.contentWindow!); h.channels[0]?.port1.dispatch({ message: 'TS Owner Inserted', version: 1, diff --git a/crates/trusted-server-js/lib/test/first_display/slices.test.ts b/crates/trusted-server-js/lib/test/first_display/slices.test.ts index 9ee5571a1..30362b5bd 100644 --- a/crates/trusted-server-js/lib/test/first_display/slices.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/slices.test.ts @@ -53,6 +53,10 @@ import { createDidomiRuntime } from '../../src/integrations/didomi/module'; import { shouldProxyExternalUrl } from '../../src/integrations/creative/proxy_sign'; import { getPermutiveSegments } from '../../src/integrations/permutive/segments'; import { mirrorSourcepointConsent } from '../../src/integrations/sourcepoint/consent_mirror'; +import { + installRenderOwnerInitial, + type FirstDisplayRenderOwnerProtocolV1, +} from '../../src/first_display/render_journal'; const RELEASE_ID = 'a'.repeat(64); @@ -187,6 +191,61 @@ describe('first-display initial slice definitions', () => { } }); + it('installs one source-neutral render journal and gives its slice disposer final ownership', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const removeEventListener = vi.spyOn(dom.window, 'removeEventListener'); + const release = vi.fn(); + const owned: Array<() => void> = []; + let protocol: FirstDisplayRenderOwnerProtocolV1 | undefined; + + expect( + installRenderOwnerInitial( + Object.freeze({ + observe: vi.fn(), + register: (candidate: FirstDisplayRenderOwnerProtocolV1) => { + protocol = candidate; + return release; + }, + }), + (dispose) => owned.push(dispose) + ) + ).toEqual({ version: 1, id: 'render_owner' }); + const bridge = protocol?.createRenderBridge({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + createChannel: () => { + throw new Error('unused'); + }, + document: dom.window.document, + fillRandom: () => undefined, + now: () => 0, + setTimer: () => ({}), + }); + + expect(bridge).toBeDefined(); + expect(() => + protocol?.createRenderBridge({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + createChannel: () => { + throw new Error('unused'); + }, + document: dom.window.document, + fillRandom: () => undefined, + now: () => 0, + setTimer: () => ({}), + }) + ).toThrow(TypeError); + + expect(owned).toHaveLength(1); + owned[0]?.(); + expect(release).toHaveBeenCalledOnce(); + expect(removeEventListener).toHaveBeenCalledWith('message', expect.any(Function), true); + dom.window.close(); + }); + it.each([false, true])( 'projects the typed GAM attribution flag into the first-display GPT owner (%s)', (gamAttributionEnabled) => { @@ -233,8 +292,9 @@ describe('first-display initial slice definitions', () => { } ); - it('pins the exact twelve optional slices in build order', () => { + it('pins the exact thirteen optional slices in build order', () => { expect(INITIAL_SLICE_DEFINITIONS.map(({ id }) => id)).toEqual([ + 'render_owner_initial', 'aps_initial', 'creative_initial', 'datadome_initial', @@ -283,6 +343,12 @@ describe('first-display initial slice definitions', () => { expect( selectInitialSliceDefinitions(['first_display', 'prebid_initial', 'gpt_initial']) ).toBeUndefined(); + expect( + selectInitialSliceDefinitions(['first_display', 'aps_initial', 'gpt_initial']) + ).toBeUndefined(); + expect( + selectInitialSliceDefinitions(['first_display', 'render_owner_initial']) + ).toBeUndefined(); expect( selectInitialSliceDefinitions(['first_display', 'unknown_initial' as 'gpt_initial']) ).toBeUndefined(); @@ -1119,7 +1185,7 @@ describe('first-display initial slice definitions', () => { } }); - it('registers the closed APS reservation and document-channel protocol', () => { + it('registers the closed APS nonce, policy, and document-channel protocol', () => { const release = vi.fn(); const disposers: Array<() => void> = []; let protocol: FirstDisplayApsProtocolV1 | undefined; @@ -1157,17 +1223,12 @@ describe('first-display initial slice definitions', () => { 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation' ); expect(protocol?.deadlines).toEqual({ - insertionMs: 1_000, documentAcceptanceMs: 3_000, completionMs: 10_000, - ownerSettlementMs: 20_000, }); - expect(protocol?.isReservationId(`r1_${'a'.repeat(22)}`)).toBe(true); - expect(protocol?.isLifecycleTicket(`t1_${'a'.repeat(22)}`)).toBe(true); expect(protocol?.isBootstrapNonce(`b1_${'a'.repeat(22)}`)).toBe(true); expect(protocol?.isRendererNonce(`n1_${'a'.repeat(22)}`)).toBe(true); expect(protocol?.isBootstrapNonce(`n1_${'a'.repeat(22)}`)).toBe(false); - expect(protocol?.isReservationId(`r1_${'a'.repeat(21)}`)).toBe(false); const nonce = `n1_${'b'.repeat(22)}`; expect( protocol?.parseDocumentMessage( diff --git a/crates/trusted-server-js/lib/test/first_display/transaction.test.ts b/crates/trusted-server-js/lib/test/first_display/transaction.test.ts index 3f50f14dc..f9b2cb62b 100644 --- a/crates/trusted-server-js/lib/test/first_display/transaction.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/transaction.test.ts @@ -58,7 +58,7 @@ describe('release-private first-display transaction', () => { ).toBe(true); expect( transaction.register( - registration('gpt_initial', 7, (own) => { + registration('gpt_initial', 8, (own) => { own(() => events.push('dispose-gpt')); events.push('activate-gpt'); }) @@ -91,7 +91,7 @@ describe('release-private first-display transaction', () => { }), }); transaction.register( - registration('gpt_initial', 7, () => { + registration('gpt_initial', 8, () => { events.push('activate-gpt'); }) ); @@ -120,7 +120,7 @@ describe('release-private first-display transaction', () => { const omitted = make(); expect(omitted.register(registration('first_display', 1))).toBe(true); expect(omitted.activate()).toBe(false); - expect(make().register(registration('gpt_initial', 7))).toBe(false); + expect(make().register(registration('gpt_initial', 8))).toBe(false); expect( make().register({ ...registration('first_display', 1), releaseId: 'b'.repeat(64) }) ).toBe(false); @@ -129,9 +129,9 @@ describe('release-private first-display transaction', () => { expect(make().register(accessor)).toBe(false); const late = make(); expect(late.register(registration('first_display', 1))).toBe(true); - expect(late.register(registration('gpt_initial', 7))).toBe(true); + expect(late.register(registration('gpt_initial', 8))).toBe(true); expect(late.activate()).toBe(true); - expect(late.register(registration('gpt_initial', 7))).toBe(false); + expect(late.register(registration('gpt_initial', 8))).toBe(false); }); it('authenticates the exact parser-inserted current script and current generation', () => { @@ -177,7 +177,7 @@ describe('release-private first-display transaction', () => { }) ); transaction.register( - registration('gpt_initial', 7, (own) => { + registration('gpt_initial', 8, (own) => { own(() => events.push('dispose-c')); throw new Error('boom'); }) @@ -187,4 +187,64 @@ describe('release-private first-display transaction', () => { expect(events).toEqual(['dispose-c', 'dispose-b', 'dispose-a']); expect(transaction.state).toBe('failed'); }); + + it('cannot continue activation or resurrect after a slice disposes reentrantly', () => { + const { document, script } = documentWithScript(); + const events: string[] = []; + const transaction = createFirstDisplayTransaction({ + document, + script, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display', 'gpt_initial'], + isCurrentGeneration: () => true, + }); + transaction.register( + registration('first_display', 1, (own) => { + own(() => events.push('dispose-base')); + events.push('activate-base'); + transaction.dispose(); + }) + ); + transaction.register( + registration('gpt_initial', 8, () => { + events.push('activate-gpt'); + }) + ); + + expect(transaction.activate()).toBe(false); + expect(transaction.state).toBe('disposed'); + expect(events).toEqual(['activate-base', 'dispose-base']); + }); + + it('removes each disposer before invoking it during recursive rollback', () => { + const { document, script } = documentWithScript(); + const events: string[] = []; + const transaction = createFirstDisplayTransaction({ + document, + script, + releaseId: RELEASE_ID, + generation: 7, + expectedSliceIds: ['first_display', 'gpt_initial'], + isCurrentGeneration: () => true, + }); + transaction.register( + registration('first_display', 1, (own) => { + own(() => events.push('dispose-a')); + own(() => { + events.push('dispose-b'); + transaction.dispose(); + }); + }) + ); + transaction.register( + registration('gpt_initial', 8, () => { + throw new Error('boom'); + }) + ); + + expect(transaction.activate()).toBe(false); + expect(transaction.state).toBe('disposed'); + expect(events).toEqual(['dispose-b', 'dispose-a']); + }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts index 36c7404dc..8991f11a4 100644 --- a/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts @@ -204,6 +204,65 @@ describe('render_runtime provider', () => { expect(batch.dispose).toHaveBeenCalledTimes(2); }); + it('adopts a publisher-owned PUC shell without claiming its DOM lifetime', () => { + const host = document.createElement('div'); + host.id = 'div-1'; + const publisherWrapper = document.createElement('div'); + const frame = document.createElement('iframe'); + frame.src = 'https://publisher.example/universal-creative'; + publisherWrapper.append(frame); + document.body.append(host, publisherWrapper); + const navigationGeneration = {}; + const batch = Object.freeze({ + createRenderAttempt: vi.fn(() => + Object.freeze({ + ok: true as const, + value: Object.freeze({ id: `a1_${'A'.repeat(22)}`, navigationGeneration }), + }) + ), + dispose: vi.fn(), + }); + const navigation = Object.freeze({ + generation: navigationGeneration, + createAuctionBatch: vi.fn(() => batch), + isCurrent: () => true, + }) as unknown as NavigationSession; + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slots: Object.freeze([Object.freeze({ id: 'slot-1', domId: 'div-1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1' })]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + kind: 'gpt_adm' as const, + owner: 'publisher' as const, + slotId: 'slot-1', + token: `r1_${'a'.repeat(22)}`, + }), + ]), + }), + identities: Object.freeze([Object.freeze({}), frame]), + }); + const store = createCommittedArtifactStore(); + const committed = adoptInitialRenderArtifactsFromHandoff( + adoption, + navigation, + store, + createArtifactHostPositionLeaseRegistry(), + document + ); + + expect(committed).toBeDefined(); + committed?.arm(); + frame.style.setProperty('visibility', 'hidden'); + expect(store.sweep()).toBe(1); + expect(frame.isConnected).toBe(true); + expect(batch.dispose).toHaveBeenCalledOnce(); + }); + it.each(['reparented', 'frame_style'] as const)( 'retires an adopted APS mount on %s loss and compare-restores only owned style', (mutation) => { diff --git a/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts b/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts index 2718f4874..e86d35516 100644 --- a/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts @@ -75,32 +75,61 @@ const EXPECTED = [ ] as const; describe('canonical release catalog', () => { - it('pins the exact thirteen first-display rows and closed server-owned selection', () => { + it('pins the exact fourteen first-display rows and closed server-owned selection', () => { expect(FIRST_DISPLAY_CONTRACT_IDS).toEqual(FIRST_DISPLAY_CATALOG.map(({ id }) => id)); expect(FIRST_DISPLAY_CATALOG.map(({ order, id }) => [order, id])).toEqual([ [1, 'first_display'], - [2, 'aps_initial'], - [3, 'creative_initial'], - [4, 'datadome_initial'], - [5, 'didomi_initial'], - [6, 'google_tag_manager_initial'], - [7, 'gpt_initial'], - [8, 'lockr_initial'], - [9, 'osano_initial'], - [10, 'permutive_initial'], - [11, 'sourcepoint_initial'], - [12, 'prebid_initial'], - [13, 'testlight_initial'], + [2, 'render_owner_initial'], + [3, 'aps_initial'], + [4, 'creative_initial'], + [5, 'datadome_initial'], + [6, 'didomi_initial'], + [7, 'google_tag_manager_initial'], + [8, 'gpt_initial'], + [9, 'lockr_initial'], + [10, 'osano_initial'], + [11, 'permutive_initial'], + [12, 'sourcepoint_initial'], + [13, 'prebid_initial'], + [14, 'testlight_initial'], ]); - expect(MAX_FIRST_DISPLAY_SLICES).toBe(13); + expect(MAX_FIRST_DISPLAY_SLICES).toBe(14); expect( selectFirstDisplayCatalog({ eligibleBatch: true, integrations: ['aps', 'gpt', 'prebid'], apsParticipates: true, + renderOwnerParticipates: true, prebidParticipates: true, }).map(({ id }) => id) - ).toEqual(['first_display', 'aps_initial', 'gpt_initial', 'prebid_initial']); + ).toEqual([ + 'first_display', + 'render_owner_initial', + 'aps_initial', + 'gpt_initial', + 'prebid_initial', + ]); + expect( + selectFirstDisplayCatalog({ + eligibleBatch: true, + integrations: ['gpt'], + renderOwnerParticipates: true, + }).map(({ id }) => id) + ).toEqual(['first_display', 'render_owner_initial', 'gpt_initial']); + expect( + selectFirstDisplayCatalog({ + eligibleBatch: true, + integrations: ['gpt'], + renderOwnerParticipates: false, + }).map(({ id }) => id) + ).toEqual(['first_display', 'gpt_initial']); + expect( + selectFirstDisplayCatalog({ + eligibleBatch: true, + integrations: ['gpt'], + apsParticipates: true, + }).map(({ id }) => id) + ).toEqual(['first_display', 'gpt_initial']); expect(selectFirstDisplayCatalog({ eligibleBatch: false, integrations: [] })).toEqual([]); expect(() => selectFirstDisplayCatalog({ eligibleBatch: true, integrations: ['unknown'] }) 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 78fefad64..d13c544ac 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 @@ -374,6 +374,12 @@ describe('Universal Creative bridge dispatcher', () => { expect(PUC_DYNAMIC_OWNER).not.toContain('rendererUrl'); expect(PUC_DYNAMIC_OWNER).not.toContain('aaxResponse'); expect(PUC_DYNAMIC_OWNER).toContain('TS APS Top Mount Started'); + expect(PUC_DYNAMIC_OWNER).not.toMatch( + /\b(?:fetch|XMLHttpRequest|WebSocket|EventSource|Worker|SharedWorker|Blob)\b/ + ); + expect(PUC_DYNAMIC_OWNER).not.toContain('import('); + expect(PUC_DYNAMIC_OWNER).not.toContain('createObjectURL'); + expect(PUC_DYNAMIC_OWNER).not.toMatch(/createElement\(["'](?:script|link)["']\)/); }); it('binds ADM load and final acceptance to the exact inserted navigation', () => { @@ -745,6 +751,92 @@ describe('Universal Creative bridge dispatcher', () => { } }); + it('contains synchronous helper registration and control-port settlement reentrancy', 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 controlListener: ((event: unknown) => void) | undefined; + const controlPort = { + close: vi.fn(), + postMessage: vi.fn(), + set onmessage(listener: ((event: unknown) => void) | null) { + controlListener = listener ?? undefined; + }, + set onmessageerror(_listener: ((event: unknown) => void) | null) {}, + start: vi.fn(() => { + controlListener?.({ + data: { + message: 'TS APS Top Mount Started', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }, + ports: [], + }); + controlListener?.({ + data: { + message: 'TS Owner Settled', + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + outcome: 'accepted', + }, + ports: [], + }); + }), + }; + const sendMessage = vi.fn( + ( + _type: string, + _payload: Readonly>, + callback: (event: unknown) => void + ) => { + callback({ + data: JSON.stringify({ + message: 'TS Render Owner Registered', + adId: RESERVATION_ID, + version: 1, + lifecycleTicket: LIFECYCLE_TICKET, + }), + ports: [controlPort], + }); + return stopListening; + } + ); + + try { + const rendered = dynamicWindow.render!( + window.JSON.parse( + JSON.stringify({ + adId: RESERVATION_ID, + message: 'Prebid Response', + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + tsOwner: { + version: 1, + status: 'ready', + kind: 'aps', + lifecycleTicket: LIFECYCLE_TICKET, + }, + }) + ) as Readonly>, + { sendMessage }, + window + ); + + await expect(rendered).resolves.toBeUndefined(); + expect(controlPort.start).toHaveBeenCalledOnce(); + expect(controlPort.close).toHaveBeenCalledOnce(); + expect(stopListening).toHaveBeenCalledOnce(); + } finally { + delete dynamicWindow.render; + } + }); + it('rejects accepted ADM settlement after the owner iframe navigation changes', async () => { const dynamicWindow = window as unknown as { render?: ( @@ -1576,6 +1668,14 @@ describe('Universal Creative bridge dispatcher', () => { expect(order).toEqual([`lookup:${RESERVATION_ID}`, 'stop']); expect(port.postMessage).toHaveBeenCalledOnce(); + expect(String(port.postMessage.mock.calls[0]?.[0])).toBe( + JSON.stringify({ + message: 'Prebid Response', + adId: RESERVATION_ID, + rendererVersion: '4', + tsOwner: { version: 1, status: 'refused' }, + }) + ); expect(JSON.parse(String(port.postMessage.mock.calls[0]?.[0]))).toEqual({ message: 'Prebid Response', adId: RESERVATION_ID, @@ -1858,7 +1958,8 @@ describe('Universal Creative bridge dispatcher', () => { 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])); + const rawResponse = String(port.postMessage.mock.calls[0]?.[0]); + const response = JSON.parse(rawResponse); expect( new TextEncoder().encode(String(port.postMessage.mock.calls[0]?.[0])).byteLength ).toBeLessThanOrEqual(72 * 1_024); @@ -1874,6 +1975,20 @@ describe('Universal Creative bridge dispatcher', () => { lifecycleTicket: LIFECYCLE_TICKET, }, }); + expect(rawResponse).toBe( + JSON.stringify({ + message: 'Prebid Response', + adId: RESERVATION_ID, + renderer: PUC_DYNAMIC_OWNER, + rendererVersion: '4', + 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'); diff --git a/crates/trusted-server-js/src/bundle.rs b/crates/trusted-server-js/src/bundle.rs index 0ec802645..bcd3d240f 100644 --- a/crates/trusted-server-js/src/bundle.rs +++ b/crates/trusted-server-js/src/bundle.rs @@ -135,7 +135,7 @@ pub fn all_integration_metadata() -> Vec { .collect() } -/// Return generated metadata for the base and twelve first-display components. +/// Return generated metadata for the base and thirteen first-display slices. #[must_use] pub fn all_first_display_metadata() -> Vec { TSJS_ARTIFACTS @@ -388,25 +388,25 @@ mod tests { fn generated_artifact_inventory_includes_bootstrap_core_and_catalog_once() { let artifacts = all_artifact_metadata(); - assert_eq!(artifacts.len(), 35); + assert_eq!(artifacts.len(), 36); assert_eq!(artifacts[0].id, "bootstrap"); assert_eq!(artifacts[0].role, TsjsArtifactRole::Bootstrap); assert_eq!(artifacts[1].id, "first_display"); assert_eq!(artifacts[1].role, TsjsArtifactRole::FirstDisplayBase); assert!( - artifacts[2..14] + artifacts[2..15] .iter() .all(|artifact| artifact.role == TsjsArtifactRole::FirstDisplaySlice) ); - assert_eq!(artifacts[14].id, "core"); - assert_eq!(artifacts[14].role, TsjsArtifactRole::Core); + assert_eq!(artifacts[15].id, "core"); + assert_eq!(artifacts[15].role, TsjsArtifactRole::Core); assert!( - artifacts[15..] + artifacts[16..] .iter() .all(|artifact| artifact.role == TsjsArtifactRole::Integration) ); assert_eq!(all_module_ids().len(), 21); - assert_eq!(all_first_display_ids().len(), 13); + assert_eq!(all_first_display_ids().len(), 14); } #[test] 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 adbabbb19..00a32f78c 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 @@ -1304,27 +1304,45 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - Create: `crates/trusted-server-js/lib/src/first_display/render_journal.ts` - Create: `crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts` +- Create: `crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts` +- Delete: `crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts` +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/integrations/registry.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` - Modify: `crates/trusted-server-core/src/tsjs.rs` +- Modify: `crates/trusted-server-js/build.rs` +- Modify: `crates/trusted-server-js/src/bundle.rs` - Modify: `crates/trusted-server-js/lib/src/first_display/agent.ts` -- Modify: `crates/trusted-server-js/lib/src/first_display/adm_render_bridge.ts` +- Modify: `crates/trusted-server-js/lib/src/first_display/composition.ts` - Modify: `crates/trusted-server-js/lib/src/first_display/render_bridge.ts` - Modify: `crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts` -- Modify: `crates/trusted-server-js/lib/src/first_display/slices/aps.ts` +- Modify: `crates/trusted-server-js/lib/src/first_display/slices/*.ts` +- Modify: `crates/trusted-server-js/lib/src/core/bootstrap.ts` +- Modify: `crates/trusted-server-js/lib/src/core/contracts/boot.ts` - Modify: `crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts` +- Modify: `crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts` - Modify: `crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/integration_registry.ts` - Modify: `crates/trusted-server-js/lib/src/kernel/release_catalog.ts` - Modify: `crates/trusted-server-js/lib/src/shared/first_display_transaction.ts` - Modify: `crates/trusted-server-js/lib/src/shared/first_display_registration.ts` - Modify: `crates/trusted-server-js/lib/src/shared/first_display_contracts.ts` - Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/scripts/bundle-metrics.mjs` - Modify: `crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs` +- Modify: `crates/trusted-server-js/lib/scripts/print-release-id.mjs` +- Modify: `scripts/validate-tsjs-performance-evidence.mjs` +- Modify: `crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts` - Test: `crates/trusted-server-js/lib/test/first_display/{agent,adm_render_bridge,render_bridge,slices}.test.ts` - Test: `crates/trusted-server-js/lib/test/first_display/transaction.test.ts` - Test: `crates/trusted-server-js/lib/test/first_display/contracts.test.ts` +- Test: `crates/trusted-server-js/lib/test/core/bootstrap.test.ts` +- Test: `crates/trusted-server-js/lib/test/integrations/render_runtime/module.test.ts` - Test: `crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts` - Test: `crates/trusted-server-js/lib/test/services/puc_bridge.test.ts` +- Test: `crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs` - Test: `crates/trusted-server-js/lib/test/build/release-v1.test.mjs` -- Test: `crates/trusted-server-integration-tests/browser/tests/shared/{aps-renderer,aps-puc-lifecycle,tsjs-runtime}.spec.ts` +- Test: `crates/trusted-server-integration-tests/browser/tests/shared/{aps-renderer,aps-puc-lifecycle,tsjs-performance,tsjs-runtime}.spec.ts` - [ ] **Step 1: Add RED catalog, selection, and source-ownership tests.** Add `render_owner_initial` as catalog order 2 and shift the remaining optional @@ -1341,14 +1359,24 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled renderer-document parsing, mount code, or top-mount policy. ADM-only masks still receive the self-contained v4 owner. Assert exact catalog order 1–14 and the implications APS ⇒ render owner ⇒ GPT, ADM ⇒ render owner without APS, and - no-bid/nonrendering ⇒ no render owner. + no-bid/nonrendering ⇒ no render owner. Pin the server masks `0001` no-bid, + `0081` attribution-only, `0083` ADM, `008b` creative ADM, `0087` APS, `008f` + creative APS, and `1083` Prebid ADM. Reject an APS participation fact when APS + is not enabled. Update the Rust build consumer, generated Rust inventory, + release-id printer, persistent manifest capacity, and all slice registration + orders from 13/35 to 14/36 rather than leaving a dead build scalar or a stale + hard-coded index. - [ ] **Step 2: Add RED parity tests before extracting code.** Run the same journal corpus against ADM-only, APS-only, and mixed APS/ADM batches. Cover accepted, failed, cancelled, empty-GAM fallback, replacement success/failure, moved or mutated nodes, navigation, handoff, post-detach reentrancy, overlay-lease - transfer, targeting restoration, and final retirement. The new tests must fail - only because a shared base journal is not yet exposed. + transfer, targeting restoration, and final retirement. Include the first-display + APS adoption → persistent APS replacement → final-retirement sequence and the + publisher-owned PUC handoff. Keep the old harness shape only in a test helper + that composes the neutral journal with the APS strategy; production has no + compatibility wrapper. The new tests must fail only because a shared base + journal is not yet exposed. - [ ] **Step 3: Add RED compact-owner tests.** Keep the exact successful/refused v4 outer shapes and the complete current adversarial corpus, while requiring the @@ -1360,7 +1388,9 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled route test must also prove `render_owner_initial` is concatenated into the one served mask body, has no independently routable/requested production asset, and can create no script, preload, fetch, dynamic-import, worker, or blob request - before paint. + before paint. Measure the browser-observed call-time boundary, not a potentially + backdated `PerformanceResourceTiming.startTime`, and assert the exact request + sequence document → selected first-display artifact → persistent runtime. - [ ] **Step 4: Run RED.** @@ -1374,7 +1404,11 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled detach state, timers, and retirement bookkeeping. Expose only the exact frozen operations the APS protocol needs through the release-private slice host. Keep APS descriptor parsing, nonce registries, document parser, mount policy, and - overlay behavior in `aps_initial`. + overlay behavior in `aps_initial`. Instantiate exactly one journal during + render-owner activation, pass its one-use private capability through the base + host to APS, and let the render-owner slice disposer own it. The APS protocol + may import the neutral interface as a type only; the owner must have no runtime + dependency on APS. - [ ] **Step 6: Compact the dynamic owner in place.** Deduplicate its exact record, event/port, timer, and terminal helpers without removing checks or changing a @@ -1399,7 +1433,11 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled Expected: every focused behavior is unchanged, the production graph has one render journal, and the exact mandatory APS mask remains admitted. If the combined inline-plus-mask semantic bytes still exceed the rc × 1.10 allowance, stop and - revisit the approved split rather than weakening a gate. + revisit the approved split rather than weakening a gate. The release proof must + show 36 artifacts, 14 first-display rows, 3,584 reachable masks, the exact + generated admitted subset, a largest admitted first-display body no greater than + 90,000/30,000/26,000 raw/gzip/Brotli bytes, and a maximal total that excludes the + bootstrap role. - [ ] **Step 8: Run the three-browser focused lifecycle proof.** In `tsjs-runtime.spec.ts`, assert exactly one parser-blocking first-display TSJS @@ -1413,7 +1451,7 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled - [ ] **Step 9: Commit.** ```bash - git add crates/trusted-server-core/src/tsjs.rs crates/trusted-server-js/lib/src/first_display crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts crates/trusted-server-js/lib/src/kernel/release_catalog.ts crates/trusted-server-js/lib/src/shared/first_display_transaction.ts crates/trusted-server-js/lib/src/shared/first_display_registration.ts crates/trusted-server-js/lib/src/shared/first_display_contracts.ts crates/trusted-server-js/lib/build-all.mjs crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs crates/trusted-server-js/lib/test/first_display crates/trusted-server-js/lib/test/kernel/release_catalog.test.ts crates/trusted-server-js/lib/test/services/puc_bridge.test.ts crates/trusted-server-js/lib/test/build/release-v1.test.mjs crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts + git add -A git commit -m "Thin the APS first-display owner" ``` 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 c1d8392d6..91c6ed76e 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 @@ -2568,6 +2568,15 @@ that slice, and neither slice imports or instantiates a second complete render b | `prebid_initial` | Prebid participates in the initial batch | artifact admission, publisher queue, bidder/user-ID/EID setup, initial TS bid/PUC path | | `testlight_initial` | Testlight is enabled | capture preexisting callbacks before publisher replacement/drain | +The canonical low-order examples are part of the server/build contract: no-bid is +`0001`, GPT attribution-only is `0081`, ADM is `0083`, creative-guarded ADM is +`008b`, APS is `0087`, creative-guarded APS is `008f`, and Prebid-plus-ADM is +`1083`. These examples do not create aliases: the route accepts only the exact +generated mask/hash pair admitted for the current configuration. APS participation +without enabled APS configuration is invalid rather than a reason to select the +render owner, and `render_owner_initial` has no independently routable production +asset; its bytes exist only inside the selected first-display composition. + Every slice registers into the bootstrap's release-private first-display sink from the expected parser-inserted artifact and `document.currentScript`. The build fixes their order, interfaces, and allowed imports; there is no public service locator or diff --git a/scripts/validate-tsjs-performance-evidence.mjs b/scripts/validate-tsjs-performance-evidence.mjs index 12e8df4f8..a8db699d4 100644 --- a/scripts/validate-tsjs-performance-evidence.mjs +++ b/scripts/validate-tsjs-performance-evidence.mjs @@ -100,7 +100,7 @@ function validateReferenceTransfer( expectedSha, expectedModel, path, - firstDisplayMask = "0045", + firstDisplayMask = "008b", ) { exactKeys( value, @@ -707,14 +707,14 @@ export function validateEvidence(evidence, expected) { expected.baseSha, apsFirstAction.baseline.artifactModel, "aps.transfer.baselineReferenceTransfer", - "0047", + "008f", ); const apsCandidateTransfer = validateReferenceTransfer( evidence.aps.transfer.candidateReferenceTransfer, expected.headSha, apsFirstAction.candidate.artifactModel, "aps.transfer.candidateReferenceTransfer", - "0047", + "008f", ); if ( apsFirstAction.baseline.selectedTransferBytes !== @@ -964,7 +964,7 @@ function validFixture() { sha256: "4".repeat(64), }, { - semanticEndpoint: `external:/static/tsjs=tsjs-first-display.min.js?m=0045&v=${"5".repeat(64)}`, + semanticEndpoint: `external:/static/tsjs=tsjs-first-display.min.js?m=008b&v=${"5".repeat(64)}`, delivery: "external", rawBytes: 79_000, gzipBytes: 19_000, @@ -1097,7 +1097,7 @@ function validFixture() { sha256: "9".repeat(64), }, { - semanticEndpoint: `external:/static/tsjs=tsjs-first-display.min.js?m=0047&v=${"a".repeat(64)}`, + semanticEndpoint: `external:/static/tsjs=tsjs-first-display.min.js?m=008f&v=${"a".repeat(64)}`, delivery: "external", rawBytes: 79_500, gzipBytes: 19_500, @@ -1162,7 +1162,7 @@ function runSelfTest() { )[0]; releaseBaselineFixture.transfer.baselineReferenceTransfer.sources[0].semanticEndpoint = "inline:boot-controller"; - releaseBaselineFixture.transfer.baselineReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=0045&v=${"3".repeat(64)}`; + releaseBaselineFixture.transfer.baselineReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=008b&v=${"3".repeat(64)}`; for (const metric of ["rawBytes", "gzipBytes", "brotliBytes"]) { releaseBaselineFixture.transfer.baselineReferenceTransfer[metric] -= removedLegacyInit[metric]; @@ -1247,7 +1247,7 @@ function runSelfTest() { [ "mismatched first-display mask", (value) => { - value.transfer.candidateReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=0047&v=${"5".repeat(64)}`; + value.transfer.candidateReferenceTransfer.sources[1].semanticEndpoint = `external:/static/tsjs=tsjs-first-display.min.js?m=008f&v=${"5".repeat(64)}`; }, ], [ @@ -1535,7 +1535,7 @@ function runSelfTest() { ); assert.match( performanceTest, - /performanceCase === "aps" \? "0047" : "0045"/u, + /performanceCase === "aps" \? "008f" : "008b"/u, "the release-v1 semantic transfer must measure the exact admitted GPT and APS agents", ); assert.match( From d200e700e7424b70e5c1ceebb767f5f87724e881 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:44:36 -0700 Subject: [PATCH 842/844] Consolidate TSJS first-display ownership --- .../tests/shared/tsjs-performance.spec.ts | 25 +- crates/trusted-server-js/lib/build-all.mjs | 30 +- .../lib/scripts/bundle-metrics.mjs | 8 +- .../lib/scripts/check-bundle-budgets.mjs | 16 +- .../lib/src/core/adapters/gam_attribution.ts | 47 - .../lib/src/core/bootstrap.ts | 345 ++--- .../core/contracts/server_boot_transport.ts | 10 +- .../trusted-server-js/lib/src/core/index.ts | 36 +- .../src/first_display/adapters/googletag.ts | 385 +++--- .../lib/src/first_display/agent.ts | 1112 ++++++----------- .../lib/src/first_display/base_marker.ts | 1 + .../lib/src/first_display/composition.ts | 80 +- .../lib/src/first_display/driver.ts | 216 +++- .../src/first_display/leaf/aps_protocol.ts | 20 +- .../first_display/leaf/browser_route_owner.ts | 112 ++ .../src/first_display/leaf/creative_guard.ts | 63 +- .../src/first_display/leaf/gpt_protocol.ts | 111 +- .../src/first_display/leaf/prebid_protocol.ts | 15 +- .../lib/src/first_display/leaf/projection.ts | 52 +- .../src/first_display/registration_client.ts | 11 +- .../lib/src/first_display/render_bridge.ts | 62 +- .../lib/src/first_display/render_journal.ts | 320 +++-- .../lib/src/first_display/slices/aps.ts | 7 +- .../lib/src/first_display/slices/creative.ts | 10 +- .../lib/src/first_display/slices/datadome.ts | 19 +- .../src/first_display/slices/definition.ts | 36 +- .../lib/src/first_display/slices/didomi.ts | 15 +- .../slices/google_tag_manager.ts | 22 +- .../lib/src/first_display/slices/gpt.ts | 22 +- .../lib/src/first_display/slices/lockr.ts | 21 +- .../lib/src/first_display/slices/osano.ts | 17 +- .../lib/src/first_display/slices/permutive.ts | 26 +- .../lib/src/first_display/slices/prebid.ts | 7 +- .../src/first_display/slices/render_owner.ts | 10 +- .../src/first_display/slices/sourcepoint.ts | 22 +- .../lib/src/first_display/slices/testlight.ts | 10 +- .../src/kernel/contracts/puc_dynamic_owner.ts | 270 ++-- .../lib/src/kernel/identity.ts | 59 + .../lib/src/kernel/integration_registry.ts | 17 +- .../lib/src/kernel/lifecycle_module.ts | 4 +- .../lib/src/kernel/release_catalog.ts | 18 +- .../lib/src/shared/first_display_contracts.ts | 317 +++++ .../lib/src/shared/first_display_handoff.ts | 242 +++- .../src/shared/first_display_registration.ts | 16 +- .../lib/src/shared/takeover.ts | 36 +- .../test/build/generated-fallback.test.mjs | 267 ++-- .../lib/test/build/release-v1.test.mjs | 277 +--- .../lib/test/core/bootstrap.test.ts | 23 + .../first_display/adm_render_bridge.test.ts | 32 +- .../lib/test/first_display/agent.test.ts | 463 ++++--- .../first_display/browser_route_owner.test.ts | 85 ++ .../lib/test/first_display/contracts.test.ts | 92 ++ .../lib/test/first_display/driver.test.ts | 215 ++-- .../test/first_display/gpt_adapter.test.ts | 178 +-- .../lib/test/first_display/handoff.test.ts | 55 +- .../first_display/helpers/compact_takeover.ts | 191 +++ .../helpers/render_owner_composition.ts | 29 +- .../lib/test/first_display/projection.test.ts | 9 +- .../test/first_display/render_bridge.test.ts | 83 +- .../lib/test/first_display/slices.test.ts | 298 +++-- .../lib/test/first_display/takeover.test.ts | 28 +- .../lib/test/integrations/gpt/module.test.ts | 221 ++-- .../lib/test/kernel/identity.test.ts | 21 + .../test/kernel/integration_registry.test.ts | 7 +- .../lib/test/kernel/lifecycle_module.test.ts | 79 +- .../lib/test/kernel/runtime.test.ts | 16 +- .../lib/test/services/puc_bridge.test.ts | 1 - crates/trusted-server-js/src/bundle.rs | 19 +- ...8-04-aps-tsjs-resilience-implementation.md | 69 +- ...s-render-fix-and-tsjs-resilience-design.md | 82 +- 70 files changed, 4117 insertions(+), 3023 deletions(-) delete mode 100644 crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts create mode 100644 crates/trusted-server-js/lib/src/first_display/base_marker.ts create mode 100644 crates/trusted-server-js/lib/src/first_display/leaf/browser_route_owner.ts create mode 100644 crates/trusted-server-js/lib/test/first_display/browser_route_owner.test.ts create mode 100644 crates/trusted-server-js/lib/test/first_display/helpers/compact_takeover.ts 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 0a7b212d4..e3f9befcf 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 @@ -379,6 +379,7 @@ function loadReleaseFixtureResources( for (const artifact of firstDisplayArtifacts) expect(artifact.phase).toBe("first_display"); const selectedBody = firstDisplayArtifacts + .filter(({ id }) => id !== "first_display") .map((artifact) => readFileSync(resolve(dist, artifact.file), "utf8")) .join(";\n"); const selectedHash = createHash("sha256").update(selectedBody).digest("hex"); @@ -435,7 +436,9 @@ function loadReleaseFixtureResources( const selectedTag = ``; // The single generated bootstrap carries mutually exclusive direct-runtime and // first-display branches; each branch owns one mark callsite. - expect(controllerDocument.match(/tsjs:bids-script/gu)).toHaveLength(2); + expect( + controllerDocument.match(/performance\.mark\("tsjs:bids-script"\)/gu), + ).toHaveLength(2); expect(controllerDocument.match(/id="trustedserver-js"/gu)).toHaveLength(1); expect(controllerDocument).toContain(selectedTag); expect(controllerDocument).toContain( @@ -1924,12 +1927,20 @@ test("gates semantic reference transfer against the exact rc base build", () => const baselineResources = loadBaselineFixtureResources(baselineRoot); const candidateResources = loadReleaseFixtureResources(REPO_ROOT); - for (const metric of ["rawBytes", "gzipBytes", "brotliBytes"] as const) { - expect( - candidateResources.referenceTransfer[metric], - `candidate ${metric} semantic transfer`, - ).toBeLessThanOrEqual(baselineResources.referenceTransfer[metric]); - } + const regressions = ( + ["rawBytes", "gzipBytes", "brotliBytes"] as const + ).filter( + (metric) => + candidateResources.referenceTransfer[metric] > + baselineResources.referenceTransfer[metric], + ); + expect( + regressions, + JSON.stringify({ + baseline: baselineResources.referenceTransfer, + candidate: candidateResources.referenceTransfer, + }), + ).toEqual([]); }); test("boots the generated first-display artifact through persistent takeover", async ({ diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index f5cab24ec..92188524c 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -33,21 +33,25 @@ const bootstrapPrivateProperties = // Mangle only that artifact: protocol, registration, handoff, and public agent keys // intentionally keep their authored names across independently built components. const firstDisplayBasePrivateProperties = - /^(?:options|stateValue|agentBatch|slotResults|handoffOwner|mutationObserver|observedMutationRevision|displayWasCommitted|sealed|failed|pending|reasons|actionStarted|disposedDriver|handoffFinalized|committedArtifactsDetached|lastTimingMs|firstActionAtMs|terminalAtMs|paintAtMs|nextTraceSequence|acceptedTrace|recordTerminal|recordFirstAction|scheduleProtectedPaint|readTiming|captureHandoffData|disposeDriver|installNativeMutationIngress|observeDomMutations|isOwnedRuntimeInsertion|closeNativeMutationIngress|disposeNativeMutationIngress|claimTimer|completionTimer|controlRelease|directFrame|documentAccepted|documentAcceptancePending|documentRelease|documentTimer|documentTransferred|insertionTimer|pendingDocumentTerminal|ownerSource|ownerTicket|bootstrapNonce|rendererNonce|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|recordFailure)$/; + /^(?:options|driver|production|renderer|startedAtMs|performance|paint|onProtectedPaint|onSettled|onFailure|onPrebidAdmissionFailure|mutationDocument|initialMutationRevision|identityIssuer|gptInput|onAgentReady|sliceBindings|stateValue|agentBatch|handoffOwner|handoffCapsule|mutationObserver|observedMutationRevision|displayWasCommitted|failed|pending|actionStarted|disposedDriver|handoffFinalized|committedArtifactsDetached|lastTimingMs|firstActionAtMs|terminalAtMs|paintAtMs|bound|productionBatch|recordTerminal|recordFirstAction|scheduleProtectedPaint|readTiming|startProduction|acceptedSlotIds|closeDriverIngress|captureDriverHandoff|detachDriverArtifacts|captureHandoffData|disposeDriver|installNativeMutationIngress|observeDomMutations|isOwnedRuntimeInsertion|closeNativeMutationIngress|disposeNativeMutationIngress|observeNativeMutation|finalizeHandoff|detachCommittedArtifacts|mutationRevision|start|settle|fail|dispose|activate|sliceHost|install|parserState|observations|own|afterActivate|claimTimer|completionTimer|controlRelease|directFrame|documentAccepted|documentAcceptancePending|documentRelease|documentTimer|documentTransferred|insertionTimer|pendingDocumentTerminal|ownerSource|ownerTicket|bootstrapNonce|rendererNonce|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|inserted|ticket|cycle|onTerminal|reservationId|timer|attempt|batch|slotResults|reasons|acceptedTrace|nextTraceSequence)$/; + +const combinedBootstrapPrivateProperties = new RegExp( + `${bootstrapPrivateProperties.source}|${firstDisplayBasePrivateProperties.source}` +); // The source-neutral owner keeps these fields inside one independently built IIFE. const firstDisplayRenderOwnerPrivateProperties = - /^(?:claimTimer|controlRelease|insertionTimer|ownerSource|ownerTicket|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|execution|originalCount|exact|exactShape|ports|port|source)$/; + /^(?:claimTimer|controlRelease|insertionTimer|ownerSource|ownerTicket|phaseValue|registryState|expiresAtInternal|ordinalInternal|controlPort|claim|gam|inserted|ticket|active|cycle|onTerminal|reservationId|timer|attempt|execution|originalCount|exact|exactShape|ports|port|source|bind|recordGam|recordFailure|sweepCommittedArtifacts|sealTsAdmission|closeIngress|captureHandoff|detachCommittedArtifacts|artifacts|tombstones|clockEpochMs|nextReservationOrdinal|nextTicketOrdinal|expiresAtMs|ordinal)$/; // APS keeps these renderer-specific fields inside its independently built IIFE. // Cross-artifact strategy, protocol, callback, and artifact keys remain authored. const firstDisplayApsPrivateProperties = - /^(?:callbacks|overlay|active|accepted|bootstrapNavigated|bootstrapNonceInternal|bootstrapSource|completionTimer|cycle|documentAccepted|documentPort|documentRelease|documentTimer|frame|hostPositionOwned|pendingTerminal|previousHostPosition|previousHostPositionPriority|rendererNonceInternal|originalCount|exact|ports|publisherOrigin|rendererUrl|sandbox|permanentSandbox|deadlines|documentAcceptanceMs|completionMs|isBootstrapNonce|isRendererNonce|bootstrapPolicy|parseDocumentMessage|parseWindowMessage)$/; + /^(?:callbacks|overlay|active|accepted|bootstrapNavigated|bootstrapNonceInternal|bootstrapSource|completionTimer|cycle|documentAccepted|documentPort|documentRelease|documentTimer|frame|hostPositionOwned|pendingTerminal|previousHostPosition|previousHostPositionPriority|rendererNonceInternal|originalCount|exact|ports|rendererUrl|sandbox|permanentSandbox|deadlines|documentAcceptanceMs|completionMs|isBootstrapNonce|isRendererNonce|bootstrapPolicy|parseDocumentMessage|parseWindowMessage)$/; // These names are private to the GPT initial IIFE and never cross its protocol // receipt or handoff boundary. Keep the cross-artifact protocol keys authored. const firstDisplayGptPrivateProperties = - /^(?:options|binding|command|cycleMap|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restoreTargetingObservers|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting|sealedTargetingOwnership|targetingWrites|ingressClosing|diagnosticRecords|nextDiagnosticCycleOrdinal|requestTimer|completionTimer|requestOperation|requestInvoked|bindingState|operations|requested|settled|encodedBytes|readPhysicalElementId|retireCycle|snapshotDestroyedCycles|invalidateCyclesForElement|invalidateCyclesForPublisherCall|consumeTargetingWrite|invalidateStaleTargetingObservers|captureRetainedTargeting|timer)$/; + /^(?:options|clearTimer|setTimer|projection|diagnosticsActive|onNativeMutation|binding|command|cycleMap|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|activate|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restoreTargetingObservers|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting|sealedTargetingOwnership|targetingWrites|ingressClosing|diagnosticRecords|nextDiagnosticCycleOrdinal|publicCycle|requestTimer|completionTimer|requestOperation|requestInvoked|bindingState|operations|requested|settled|elementId|runtimeSlotNumber|current|valid|consumed|operation|protocol|restore|handle|fired|deadlines|externalReadyMs|requestStartMs|completionMs|requestPlan|classifyRenderEnded|encodedBytes|readPhysicalElementId|retireCycle|snapshotDestroyedCycles|invalidateCyclesForElement|invalidateCyclesForPublisherCall|consumeTargetingWrite|invalidateStaleTargetingObservers|captureRetainedTargeting|targetingOwnership|cycles|facts|nextTraceTokenOrdinal|overflowCount|dropCount|nextCycleOrdinal|quarantines|records|responseIdentifier|seen|state|unknownPriorCycle|ordinal|timer|start|closeIngress|captureHandoff|captureDiagnosticsHandoff|detachCommittedSlots|dispose)$/; fs.rmSync(distributionDirectory, { recursive: true, force: true }); fs.mkdirSync(distributionDirectory, { recursive: true }); @@ -109,7 +113,7 @@ const sourceById = Object.freeze({ }); const firstDisplaySourceById = Object.freeze({ - first_display: 'first_display/agent.ts', + first_display: 'first_display/base_marker.ts', render_owner_initial: 'first_display/slices/render_owner.ts', aps_initial: 'first_display/slices/aps.ts', creative_initial: 'first_display/slices/creative.ts', @@ -208,15 +212,20 @@ async function buildArtifact(artifact) { configFile: false, root: libDirectory, ...(artifact.role === 'bootstrap' - ? { esbuild: { mangleProps: bootstrapPrivateProperties } } + ? { esbuild: { mangleProps: combinedBootstrapPrivateProperties, mangleQuoted: true } } : artifact.id === 'first_display' - ? { esbuild: { mangleProps: firstDisplayBasePrivateProperties } } + ? { esbuild: { mangleProps: firstDisplayBasePrivateProperties, mangleQuoted: true } } : artifact.id === 'render_owner_initial' - ? { esbuild: { mangleProps: firstDisplayRenderOwnerPrivateProperties } } + ? { + esbuild: { + mangleProps: firstDisplayRenderOwnerPrivateProperties, + mangleQuoted: true, + }, + } : artifact.id === 'aps_initial' - ? { esbuild: { mangleProps: firstDisplayApsPrivateProperties } } + ? { esbuild: { mangleProps: firstDisplayApsPrivateProperties, mangleQuoted: true } } : artifact.id === 'gpt_initial' - ? { esbuild: { mangleProps: firstDisplayGptPrivateProperties } } + ? { esbuild: { mangleProps: firstDisplayGptPrivateProperties, mangleQuoted: true } } : {}), define: { __TSJS_EMBEDDED_RELEASE_ID_V1__: JSON.stringify(RELEASE_SENTINEL), @@ -228,6 +237,7 @@ async function buildArtifact(artifact) { emptyOutDir: false, outDir: distributionDirectory, assetsDir: '.', + target: 'es2022', sourcemap: false, minify: 'esbuild', rollupOptions: { diff --git a/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs b/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs index d68cf8c7d..ea4236efc 100644 --- a/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs +++ b/crates/trusted-server-js/lib/scripts/bundle-metrics.mjs @@ -23,7 +23,7 @@ export function firstDisplayMaskIsPermitted(measurement) { return BUNDLE_SIZE_NAMES.every( (sizeName) => Number.isSafeInteger(measurement?.[sizeName]) && - measurement[sizeName] > 0 && + measurement[sizeName] >= 0 && measurement[sizeName] <= FIRST_DISPLAY_AGENT_SIZE_CEILING[sizeName] ); } @@ -236,7 +236,7 @@ export function enumerateReachableFirstDisplayMasks(catalog) { result.push({ mask: mask.toString(16).padStart(4, '0'), ids: selected.map(({ id }) => id), - files: selected.map(({ file }) => file), + files: selected.filter(({ id }) => id !== 'first_display').map(({ file }) => file), }); } return result; @@ -257,7 +257,9 @@ export async function measureReachableFirstDisplayMasks(catalog, contents, concu const mask = masks[index]; measured[index] = { ...mask, - ...(await measureBytesAsync(concatenateBundleSet(mask.files, contents))), + ...(await measureBytesAsync( + mask.files.length === 0 ? Buffer.alloc(0) : concatenateBundleSet(mask.files, contents) + )), }; measured[index].permitted = firstDisplayMaskIsPermitted(measured[index]); } diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs index 7fe56293e..742c0390c 100644 --- a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -105,9 +105,11 @@ const CURRENT_PROVIDER_SOURCE_OWNERS = Object.freeze({ 'src/integrations/sourcepoint/consent.ts': 'sourcepoint_consent', }); const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ + 'src/first_display/agent.ts': 'bootstrap', + 'src/first_display/base_marker.ts': 'first_display', + 'src/first_display/leaf/projection.ts': 'bootstrap', 'src/first_display/render_journal.ts': 'render_owner_initial', 'src/first_display/render_bridge.ts': 'aps_initial', - 'src/core/adapters/gam_attribution.ts': 'bootstrap', 'src/core/bootstrap.ts': 'bootstrap', 'src/core/contracts/server_boot_transport.ts': 'bootstrap', 'src/core/contracts/boot.ts': 'core', @@ -190,6 +192,7 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'prebid', 'prebid_later', ]), + 'src/core/contracts/fallback_ad_units.ts': Object.freeze(['bootstrap']), 'src/core/contracts/auction_projection.ts': Object.freeze(['core', 'prebid', 'prebid_later']), 'src/core/contracts/generated/renderer_validator_v1.ts': Object.freeze([ 'bootstrap', @@ -296,7 +299,7 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/kernel/sessions.ts': Object.freeze(['core']), 'src/shared/async.ts': Object.freeze(['creative', 'datadome']), 'src/shared/first_display_contracts.ts': Object.freeze(['bootstrap', 'core']), - 'src/shared/first_display_handoff.ts': Object.freeze(['bootstrap', 'first_display']), + 'src/shared/first_display_handoff.ts': Object.freeze(['bootstrap', 'first_display', 'core']), 'src/shared/first_display_registration.ts': Object.freeze(['bootstrap', 'first_display']), 'src/shared/first_display_transaction.ts': Object.freeze(['bootstrap']), 'src/shared/integration_config_validators.ts': Object.freeze([ @@ -872,12 +875,12 @@ function validateCurrentSourceOwnership(currentGraph, release) { ['first_display_base', 'first_display_slice'].includes(artifactsById.get(owner)?.role) ); if (firstDisplaySource) { + const policy = currentExactSourceOwner(source); for (const owner of owners) { - if (!firstDisplayOwners.includes(owner)) { + if (!firstDisplayOwners.includes(owner) && owner !== policy?.owner) { violations.push(`${owner} reaches first-display source ${source}`); } } - const policy = currentExactSourceOwner(source); if (policy) { if (!artifactIds.has(policy.owner)) { violations.push(`${source} requires missing current owner ${policy.owner}`); @@ -1017,7 +1020,10 @@ function validateFirstDisplayMaskMeasurements(metrics, contents) { ) { fail(`build metrics first-display mask ${index} is not canonical`); } - for (const sizeName of SIZE_NAMES) { + if (!Number.isSafeInteger(measured.rawBytes) || measured.rawBytes < 0) { + fail(`firstDisplay.masks.${index}.rawBytes must be a non-negative integer`); + } + for (const sizeName of ['gzipBytes', 'brotliBytes']) { assertPositiveInteger(measured[sizeName], `firstDisplay.masks.${index}.${sizeName}`); } const bytes = firstDisplayCompositionBytes(canonical.files, contents); diff --git a/crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts b/crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts deleted file mode 100644 index 5ee6f99a1..000000000 --- a/crates/trusted-server-js/lib/src/core/adapters/gam_attribution.ts +++ /dev/null @@ -1,47 +0,0 @@ -type GoogletagTarget = Window & { googletag?: unknown }; - -function object(value: unknown): Record | undefined { - return (typeof value === 'object' && value !== null) || typeof value === 'function' - ? (value as Record) - : undefined; -} - -/** Enqueue the document-local GAM attribution command at the parser-time boundary. */ -export function enqueueFirstDisplayGamAttribution(target: GoogletagTarget): boolean { - try { - let binding = object(target.googletag); - if (!binding) { - binding = { cmd: [] }; - if ( - !Reflect.defineProperty(target, 'googletag', { - configurable: true, - enumerable: true, - value: binding, - writable: true, - }) || - target.googletag !== binding - ) { - return false; - } - } - const queue = object(Reflect.get(binding, 'cmd')); - const push = queue && Reflect.get(queue, 'push'); - if (!queue || typeof push !== 'function') return false; - Reflect.apply(push, queue, [ - () => { - try { - const root = object(target.googletag) ?? binding; - const setConfig = Reflect.get(root, 'setConfig'); - if (typeof setConfig === 'function') { - Reflect.apply(setConfig, root, [{ targeting: { ts: 'true' } }]); - } - } catch { - // Missing or hostile GPT targeting cannot block later queue work. - } - }, - ]); - return true; - } catch { - return false; - } -} diff --git a/crates/trusted-server-js/lib/src/core/bootstrap.ts b/crates/trusted-server-js/lib/src/core/bootstrap.ts index 34d091572..9fcd023fb 100644 --- a/crates/trusted-server-js/lib/src/core/bootstrap.ts +++ b/crates/trusted-server-js/lib/src/core/bootstrap.ts @@ -1,28 +1,27 @@ declare const __TSJS_SERVER_BOOT_TRANSPORT_V1__: unknown; -import type { - FirstDisplayAgent, - FirstDisplayAgentRegistrationHostV1, - FirstDisplayBootstrapController, +import { + prepareFirstDisplayBase, + type FirstDisplayAgent, + type FirstDisplayAgentRegistrationHostV1, } from '../first_display/agent'; -import type { PreparedKernelTakeover } from '../kernel/integration_registry'; +import type { InitialSliceInstaller } from '../first_display/slices/definition'; import type { FirstDisplaySliceId } from '../kernel/release_catalog'; import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; +import type { ClaimedFirstDisplayTakeoverV1, FirstDisplayTakeoverClaim } from '../shared/takeover'; -import { enqueueFirstDisplayGamAttribution } from './adapters/gam_attribution'; -import { validateRequestAdsOptions } from './contracts/request_ads'; import { + deepFreezeTransportV1, snapshotServerBootTransportV1, type ServerBootTransportSnapshotV1, } from './contracts/server_boot_transport'; -import { validateProgrammaticAdUnits } from './registry'; import { EMBEDDED_RELEASE_ID } from './release_id'; const RELEASE = EMBEDDED_RELEASE_ID; interface ComponentRegistration { - readonly id: string; - readonly prepare: (host: unknown) => unknown; + readonly id: Exclude; + readonly install: InitialSliceInstaller; } type BootstrapTarget = object & { boot?: unknown; que?: unknown }; @@ -46,15 +45,6 @@ class RuntimeUnavailableError extends Error { } } -function deepFreeze(value: unknown): void { - if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return; - for (const key of Reflect.ownKeys(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor && 'value' in descriptor) deepFreeze(descriptor.value); - } - Object.freeze(value); -} - function prepareIngress(target: BootstrapTarget): unknown[] { const descriptor = Object.getOwnPropertyDescriptor(target, 'que'); const previous = @@ -79,6 +69,9 @@ function fallbackFields( reason: BootFailureReason, initialDisplayCommitted: boolean ): Readonly> { + const unavailable = (): never => { + throw new RuntimeUnavailableError(RELEASE, reason); + }; const safeBoot = { abi: 1, releaseId: RELEASE, @@ -99,8 +92,7 @@ function fallbackFields( creative: { version: 1, enabled: false, clickGuard: false, renderGuard: false }, diagnostics: { version: 1, renderTraceOverlay: false, gpt: { active: false } }, }; - deepFreeze(safeBoot); - const knownSlots = new Set(); + deepFreezeTransportV1(safeBoot); let level = 'warn'; const levels = ['silent', 'error', 'warn', 'info', 'debug']; const observe = (..._values: readonly unknown[]): void => undefined; @@ -120,23 +112,8 @@ function fallbackFields( debug: observe, }), _registerIntegration: () => false, - addAdUnits: (units: unknown): never => { - validateProgrammaticAdUnits(units, knownSlots); - throw new RuntimeUnavailableError(RELEASE, reason); - }, - requestAds: async (options?: unknown): Promise => { - const validated = validateRequestAdsOptions(options); - const selected = validated.slots ?? []; - const result = { - slots: selected.map((slot) => - validated.aborted - ? { slot, path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' } - : { slot, path: 'primary', outcome: 'failed', reason: 'slot_unresolved' } - ), - }; - deepFreeze(result); - return result; - }, + addAdUnits: unavailable, + requestAds: async (): Promise => unavailable(), }; Object.defineProperty(fields, '_internal', { value: Object.freeze({ @@ -395,8 +372,6 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn let bootstrapTimer: number | undefined; let takeoverTimer: number | undefined; let takeoverStartedAt = 0; - let controllerState: 'installing' | 'agent_registered' | 'action_started' | 'settled' | 'failed' = - 'installing'; const clear = (handle: number | undefined): void => { if (handle !== undefined) window.clearTimeout(handle); @@ -424,10 +399,9 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn }; const commitFallback = (reason: BootFailureReason): void => { if (terminal) return; - const initialDisplayCommitted = agent?.snapshot().initialDisplayCommitted ?? false; + const initialDisplayCommitted = agent?.initialDisplayCommitted ?? false; terminal = true; current = false; - controllerState = 'failed'; clear(bootstrapTimer); clear(takeoverTimer); removePrivate('_firstDisplayTakeover'); @@ -445,82 +419,44 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn }; const now = (): number => performance.now(); const startedAtMs = now(); - const controller: FirstDisplayBootstrapController = Object.freeze({ - get state() { - return controllerState; - }, - startedAtMs, - registerAgent: (): boolean => { - if (controllerState !== 'installing' || now() - startedAtMs >= 10_000) return false; - controllerState = 'agent_registered'; - return true; - }, - startAction: (): boolean => { - if (controllerState !== 'agent_registered' || now() - startedAtMs >= 10_000) return false; - controllerState = 'action_started'; - return true; - }, - settle: (): boolean => { - if (controllerState !== 'agent_registered' && controllerState !== 'action_started') { - return false; - } - controllerState = 'settled'; - clear(bootstrapTimer); - return true; - }, - fail: (reason: BootFailureReason): boolean => { - if (controllerState === 'settled' || controllerState === 'failed') return false; - commitFallback(reason); - return true; - }, - }); - const bridge = (selected: FirstDisplayAgent): void => { agent = selected; - const coordinate = (prepared: PreparedKernelTakeover): void => { - let activated = false; + let live = true; + const claim: FirstDisplayTakeoverClaim = ( + finalize + ): ClaimedFirstDisplayTakeoverV1 | undefined => { + if (!live) return undefined; + live = false; try { - if ( - terminal || - !current || - !runtimeScript || - now() - takeoverStartedAt >= 10_000 || - !authentic(runtimeScript, boot.manifest.runtimeSrc, 'trustedserver-js-runtime') || - agent?.state !== 'painted' - ) { - throw new TypeError('tsjs'); - } - const finalized = agent.finalizeHandoff(); - const handoff = finalized && prepared.validateHandoff(finalized.handoff, outline); - if (!handoff || agent.snapshot().mutationRevision !== handoff.mutationRevision) { + if (terminal || !current || selected.state !== 'painted') { throw new TypeError('tsjs'); } - const identities = finalized.capsule.consume(handoff.releaseId, handoff.generation); - if (!identities || !agent.detachCommittedArtifacts()) throw new TypeError('tsjs'); - disposeAgent(); - prepared.activate( - Object.freeze({ version: 1, adoptInitialDisplay: true, handoff, identities }) - ); - activated = true; - if ( - !current || - now() - takeoverStartedAt >= 10_000 || - !authentic(runtimeScript, boot.manifest.runtimeSrc, 'trustedserver-js-runtime') - ) { - throw new TypeError('tsjs'); - } - prepared.commit(); - terminal = true; - clear(takeoverTimer); - removePrivate('_firstDisplayTakeover'); + const finalized = selected.finalizeHandoff(finalize); + if (!finalized) throw new TypeError('tsjs'); + return Object.freeze([ + finalized, + outline, + () => current && !terminal && now() - takeoverStartedAt < 10_000, + () => + Boolean( + runtimeScript && + authentic(runtimeScript, boot.manifest.runtimeSrc, 'trustedserver-js-runtime') + ), + () => selected.mutationRevision, + () => selected.detachCommittedArtifacts(), + disposeAgent, + (reason) => commitFallback(reason), + () => { + terminal = true; + try { + clear(takeoverTimer); + } catch { + // The committed persistent owner remains terminal if timer cleanup fails. + } + removePrivate('_firstDisplayTakeover'); + }, + ]); } catch (error) { - if (activated) { - try { - prepared.rollback(); - } catch { - // Fallback remains terminal after a persistent rollback error. - } - } commitFallback('bundle_partial'); throw error; } @@ -528,7 +464,7 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn Object.defineProperty(target, '_firstDisplayTakeover', { configurable: true, enumerable: false, - value: coordinate, + value: claim, writable: false, }); }; @@ -557,40 +493,39 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn commitFallback('bundle_partial'); } }; - const protocols = new Map(); - const rawBinding = (id: string): unknown => { - const observe = (): void => undefined; - const register = (value: unknown): (() => void) => { - if (protocols.has(id)) throw new TypeError('tsjs'); - protocols.set(id, value); - return () => { - if (protocols.get(id) === value) protocols.delete(id); - }; - }; + const binding = ( + id: string, + observe: (key: unknown, value: unknown) => void, + register: ((protocol: unknown) => () => void) | undefined + ): readonly [bindings: unknown, config: unknown] => { + let bindings: unknown; if (id === 'gpt_initial') { - return Object.freeze({ - gam: () => enqueueFirstDisplayGamAttribution(window), + bindings = Object.freeze({ browser: window, observe, register }); + } else if (id === 'render_owner_initial') { + bindings = Object.freeze({ observe, register }); + } else if (id === 'aps_initial') { + bindings = Object.freeze({ observe, + publisherOrigin: location.origin, register, }); - } - if (id === 'render_owner_initial') { - return Object.freeze({ observe, register }); - } - if (id === 'aps_initial') { - return Object.freeze({ observe, publisherOrigin: location.origin, register }); - } - if (id === 'creative_initial') { - return Object.freeze({ - config: boot.creative, + } else if (id === 'creative_initial') { + bindings = Object.freeze({ location: Object.freeze({ href: location.href, origin: location.origin }), observe, - register, + register: () => () => undefined, + }); + } else if (id === 'testlight_initial') { + bindings = Object.freeze({ + enqueue: (callback: () => void) => { + ingress.push(callback); + }, + observe, + target: window, }); + } else { + bindings = register ? Object.freeze({ observe, register }) : Object.freeze({ observe }); } - return undefined; - }; - const binding = (id: string): unknown => { const product = id.endsWith('_initial') ? id.slice(0, -'_initial'.length) : ''; let config: unknown = id === 'creative_initial' @@ -606,7 +541,7 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn } } } - return Object.freeze({ bindings: rawBinding(id), config }); + return Object.freeze([bindings, config]); }; const host: FirstDisplayAgentRegistrationHostV1 = Object.freeze({ options: Object.freeze({ @@ -615,7 +550,7 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn projectionDigest: outline.projectionDigest, projection: boot.auctionProjection, }), - bootstrap: controller, + startedAtMs, performance, paint: Object.freeze({ hidden: () => document.visibilityState === 'hidden', @@ -623,14 +558,15 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn scheduleHidden: (callback: () => void) => window.setTimeout(callback, 0), }), onProtectedPaint: protectedPaint, + onSettled: () => clear(bootstrapTimer), onFailure: commitFallback, - gptInput: Object.freeze({ - browser: window, - clearTimer: (handle: unknown) => window.clearTimeout(handle as number), - diagnosticsActive: boot.diagnostics.gpt.active, + gptInput: Object.freeze([ + window, + (handle: unknown) => window.clearTimeout(handle as number), document, - setTimer: (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), - }), + (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), + boot.diagnostics.gpt.active, + ] as const), handoff: Object.freeze({ releaseId: RELEASE, generation: outline.generation, @@ -647,9 +583,41 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn commitFallback('abi_mismatch'); return false; }; + const activateComponents = (): boolean => { + removePrivate('_registerFirstDisplay'); + const base = prepareFirstDisplayBase(host); + let afterActivate: (() => void) | undefined; + let ownershipOpen = true; + try { + const context: FirstDisplaySliceActivationContext = Object.freeze({ + own: (dispose: () => void) => { + if (!ownershipOpen || terminal || typeof dispose !== 'function') { + throw new TypeError('tsjs'); + } + disposers.push(dispose); + }, + afterActivate: (callback: () => void) => { + if (!ownershipOpen || terminal || afterActivate || typeof callback !== 'function') { + throw new TypeError('tsjs'); + } + afterActivate = callback; + }, + }); + base.activate(context); + for (const component of registrations) { + base.sliceHost.activate(component.id, context.own, component.install); + if (terminal || !current) return false; + } + } finally { + ownershipOpen = false; + } + afterActivate?.(); + if (terminal || !current) return false; + return Boolean(agent && !terminal); + }; const register = function (this: unknown, candidate: unknown, source: unknown): boolean { try { - const expectedId = firstDisplay.slices[registrations.length]; + const expectedId = firstDisplay.slices[registrations.length + 1]; if ( terminal || this !== target || @@ -657,85 +625,29 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn document.currentScript !== source || (agentScript && source !== agentScript) || !authentic(source, firstDisplay.src, 'trustedserver-js') || - typeof candidate !== 'object' || - candidate === null + !Array.isArray(candidate) || + !Object.isFrozen(candidate) || + candidate.length !== 4 ) { return fail(); } if (terminal || !current) return false; - const fields = candidate as Readonly<{ - abi: unknown; - id: unknown; - releaseId: unknown; - prepare: unknown; - }>; + const fields = candidate as readonly unknown[]; if ( - fields.abi !== 1 || - fields.id !== expectedId || - fields.releaseId !== RELEASE || - typeof fields.prepare !== 'function' + fields[0] !== 1 || + fields[1] !== expectedId || + fields[2] !== RELEASE || + typeof fields[3] !== 'function' ) { return fail(); } agentScript = source; registrations.push({ - id: fields.id as string, - prepare: fields.prepare as (host: unknown) => unknown, + id: fields[1] as Exclude, + install: fields[3] as InitialSliceInstaller, }); - if (registrations.length !== firstDisplay.slices.length) return true; - removePrivate('_registerFirstDisplay'); - const prepared: Array<(context: FirstDisplaySliceActivationContext) => void> = []; - let sliceHost: unknown; - for (const component of registrations) { - const value = component.prepare(component.id === 'first_display' ? host : sliceHost); - if (terminal || !current) return false; - if (typeof value !== 'object' || value === null || !Object.isFrozen(value)) { - return fail(); - } - const activate = Object.getOwnPropertyDescriptor(value, 'activate')?.value; - if (typeof activate !== 'function') return fail(); - if (component.id === 'first_display') { - sliceHost = Object.getOwnPropertyDescriptor(value, 'sliceHost')?.value; - if (typeof sliceHost !== 'object' || sliceHost === null || !Object.isFrozen(sliceHost)) { - return fail(); - } - } - prepared.push(activate as (context: FirstDisplaySliceActivationContext) => void); - } - let afterActivate: (() => void) | undefined; - let ownershipOpen = true; - try { - for (let index = 0; index < prepared.length; index += 1) { - prepared[index]!( - Object.freeze({ - own: (dispose: () => void) => { - if (!ownershipOpen || terminal || typeof dispose !== 'function') { - throw new TypeError('tsjs'); - } - disposers.push(dispose); - }, - afterActivate: (callback: () => void) => { - if ( - !ownershipOpen || - terminal || - index !== 0 || - afterActivate || - typeof callback !== 'function' - ) { - throw new TypeError('tsjs'); - } - afterActivate = callback; - }, - }) - ); - if (terminal || !current) return false; - } - } finally { - ownershipOpen = false; - } - afterActivate?.(); - if (terminal || !current) return false; - return Boolean(agent && !terminal); + if (registrations.length !== firstDisplay.slices.length - 1) return true; + return activateComponents(); } catch { return fail(); } @@ -757,6 +669,7 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn }); performance.mark('tsjs:bids-script'); bootstrapTimer = window.setTimeout(() => commitFallback('bundle_partial'), 10_000); + if (firstDisplay.slices.length === 1) activateComponents(); } catch { commitFallback('abi_mismatch'); } diff --git a/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts index eb449e7f1..0537eda42 100644 --- a/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts +++ b/crates/trusted-server-js/lib/src/core/contracts/server_boot_transport.ts @@ -39,7 +39,6 @@ const CONFIG_IDS = Object.freeze([ 'sourcepoint', 'testlight', ] as const); - export interface ServerBootIntegrityV1 { readonly version: 1; readonly projectionDigest: string; @@ -68,9 +67,11 @@ function exact(value: unknown, keys: readonly string[]): Record : undefined; } -function deepFreeze(value: unknown): void { +export function deepFreezeTransportV1(value: unknown): void { if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return; - for (const key of Object.keys(value)) deepFreeze((value as Record)[key]); + for (const key of Object.keys(value)) { + deepFreezeTransportV1((value as Record)[key]); + } Object.freeze(value); } @@ -229,7 +230,6 @@ function validBoot(candidate: unknown, releaseId: string): Readonly } return boot as unknown as Readonly; } - function validIntegrity(candidate: unknown): Readonly | undefined { const integrity = exact(candidate, ['version', 'projectionDigest', 'integrationConfigDigest']); return integrity && @@ -320,7 +320,7 @@ export function snapshotServerBootTransportV1( if (!boot || !integrity) return undefined; const outline = validOutline(root.outline, releaseId, integrity, boot); if (outline === undefined) return undefined; - deepFreeze(root); + deepFreezeTransportV1(root); return root as unknown as ServerBootTransportSnapshotV1; } catch { 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 1d38e5250..525f74538 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -15,6 +15,10 @@ export type { Runtime, RuntimeOptions, RuntimeState } from '../kernel/runtime'; import type { Runtime, RuntimeOptions } from '../kernel/runtime'; import { validateRuntimeManifestV1 } from '../kernel/integration_registry'; +import { + coordinatePreparedFirstDisplayTakeoverV1, + finalizeFirstDisplayAgentCaptureV1, +} from '../shared/first_display_handoff'; import { consumeFirstDisplayTakeoverTransport } from '../shared/takeover'; import { ownDataObject } from './contracts/auction_projection'; @@ -181,6 +185,7 @@ export function startProductionRuntime(createComposition: BrowserRuntimeComposit const { boot } = claimed; const takeover = consumeFirstDisplayTakeoverTransport(target); if (takeover.status === 'invalid') return; + let takeoverClaim = takeover.status === 'accepted' ? takeover.claim : undefined; const claimDirect = takeover.status === 'absent' ? directRuntimeClaim(target) : undefined; if (claimDirect === null) return; const composition = createComposition( @@ -191,7 +196,36 @@ export function startProductionRuntime(createComposition: BrowserRuntimeComposit knownIntegrationIds: EMBEDDED_INTEGRATION_IDS, catalog: EMBEDDED_RUNTIME_CATALOG, boot, - ...(takeover.status === 'accepted' ? { coordinateTakeover: takeover.coordinate } : {}), + ...(takeoverClaim + ? { + coordinateTakeover: (prepared) => { + const claim = takeoverClaim; + takeoverClaim = undefined; + const leased = claim?.(finalizeFirstDisplayAgentCaptureV1); + if (!leased) throw new TypeError('tsjs'); + if ( + !coordinatePreparedFirstDisplayTakeoverV1({ + prepared, + finalized: leased[0], + outline: leased[1], + boot, + isCurrentGeneration: leased[2], + authenticateRuntimeScript: leased[3], + currentMutationRevision: leased[4], + quiesceAgent: () => undefined, + detachCommittedArtifacts: () => { + if (!leased[5]()) throw new TypeError('tsjs'); + }, + disposeAgent: leased[6], + onFailure: leased[7], + }) + ) { + throw new TypeError('tsjs'); + } + leased[8](); + }, + } + : {}), autoInstall: true, onInstallComplete: (result) => { try { diff --git a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts index ce61c3e91..a2d2002a0 100644 --- a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts @@ -3,7 +3,7 @@ import type { FirstDisplayProjectionSlotV1, FirstDisplayProjectionV1, } from '../leaf/projection'; -import type { FirstDisplayGptProtocolV1 } from '../leaf/gpt_protocol'; +import type { FirstDisplayGptBatchPolicyV1 } from '../leaf/gpt_protocol'; import type { FirstDisplayGptDiagnosticEventV1, FirstDisplayGptDiagnosticsV1, @@ -49,17 +49,18 @@ export type FirstDisplayGptFailureReason = | 'gpt_request_timeout' | typeof SLOT_UNRESOLVED; -export interface FirstDisplayGptBoundCycleV1 { - readonly bid: FirstDisplayProjectionBidV1; - readonly element: HTMLElement; +/** Compact live physical-cycle authority crossing GPT, bootstrap, and render owner. */ +export type FirstDisplayGptBoundCycleV1 = readonly [ + bid: FirstDisplayProjectionBidV1, + element: HTMLElement, /** Closure-private physical-cycle validity; false after a later GPT request takes the slot. */ - readonly isCurrent: () => boolean; - readonly ownership: 'publisher' | 'trusted_server'; - readonly physicalSlot: object; - readonly placement: FirstDisplayProjectionSlotV1; - readonly slotId: string; - readonly traceToken: string; -} + isCurrent: () => boolean, + ownership: 'publisher' | 'trusted_server', + physicalSlot: object, + placement: FirstDisplayProjectionSlotV1, + slotId: string, + traceToken: string, +]; export interface FirstDisplayTargetingOwnershipV1 { readonly installed: string; @@ -67,40 +68,52 @@ export interface FirstDisplayTargetingOwnershipV1 { readonly prior: readonly string[]; } -export interface FirstDisplayGptHandoffCycleV1 extends FirstDisplayGptBoundCycleV1 { - readonly targetingOwnership: readonly FirstDisplayTargetingOwnershipV1[]; -} - -export interface FirstDisplayGptDiagnosticCycleV1 { - readonly nextCycleOrdinal: number; - readonly quarantines: readonly string[]; - readonly records: readonly Readonly<{ - readonly ordinal: number; - readonly responseIdentifier: string | null; - readonly seen: readonly FirstDisplayGptDiagnosticEventV1[]; - readonly state: 'open' | 'completed' | 'retired'; - }>[]; - readonly slotId: string; - readonly token: string; - readonly unknownPriorCycle: boolean; -} - -export interface FirstDisplayGptDiagnosticsHandoffV1 extends FirstDisplayGptDiagnosticsV1 { - readonly cycles: readonly Readonly[]; - readonly nextTraceTokenOrdinal: number; -} - -export interface FirstDisplayGoogletagBatchCallbacks { - readonly onBound: (cycle: FirstDisplayGptBoundCycleV1) => void; - readonly onFailure: (slotId: string, reason: FirstDisplayGptFailureReason) => void; - readonly onFirstAction: () => boolean; - readonly onRenderEnded: ( - cycle: FirstDisplayGptBoundCycleV1, - result: FirstDisplayGptRenderResult - ) => void; +export type FirstDisplayGptHandoffCycleV1 = readonly [ + ...FirstDisplayGptBoundCycleV1, + targetingOwnership: readonly FirstDisplayTargetingOwnershipV1[], +]; + +/** Compact physical GPT state crossing from the selected slice to the inline owner. */ +export type FirstDisplayGptCaptureCycleV1 = readonly [ + slotId: string, + elementId: string, + ownership: 'trusted_server' | 'publisher', + targetingOwnership: readonly FirstDisplayTargetingOwnershipV1[], + traceToken: string, + physicalSlot: object, +]; + +export type FirstDisplayGptDiagnosticCycleV1 = readonly [ + slotId: string, + token: string, + nextCycleOrdinal: number, + unknownPriorCycle: boolean, + quarantines: readonly string[], + records: readonly (readonly [ + ordinal: number, + responseIdentifier: string | null, + seen: readonly FirstDisplayGptDiagnosticEventV1[], + state: 'open' | 'completed' | 'retired', + ])[], +]; + +export type FirstDisplayGptDiagnosticsHandoffV1 = readonly [ + cycles: readonly FirstDisplayGptDiagnosticCycleV1[], + facts: FirstDisplayGptDiagnosticsV1['facts'], + nextTraceTokenOrdinal: number, + overflowCount: number, + dropCount: number, +]; + +/** Compact authenticated callback capability crossing from the inline owner. */ +export type FirstDisplayGoogletagBatchCallbacks = readonly [ + onBound: (cycle: FirstDisplayGptBoundCycleV1) => void, + onFailure: (slotId: string, reason: FirstDisplayGptFailureReason) => void, + onFirstAction: () => boolean, + onRenderEnded: (cycle: FirstDisplayGptBoundCycleV1, result: FirstDisplayGptRenderResult) => void, /** Retire an accepted TS artifact when its exact parser-time physical binding is lost. */ - readonly onRetire?: (cycle: FirstDisplayGptBoundCycleV1) => void; -} + onRetire: ((cycle: FirstDisplayGptBoundCycleV1) => void) | undefined, +]; export interface FirstDisplayGoogletagBatch { readonly start: (callbacks: FirstDisplayGoogletagBatchCallbacks) => boolean; @@ -108,13 +121,23 @@ export interface FirstDisplayGoogletagBatch { /** Destroy noncommitted TS slots before closing publisher-mutation observation. */ readonly closeIngress: (committedSlotIds: readonly string[]) => boolean; /** Capture exact terminal physical identities without changing disposal ownership. */ - readonly captureHandoff: () => readonly FirstDisplayGptHandoffCycleV1[] | undefined; + readonly captureHandoff: () => readonly FirstDisplayGptCaptureCycleV1[] | undefined; readonly captureDiagnosticsHandoff: () => FirstDisplayGptDiagnosticsHandoffV1 | undefined; /** Exempt exactly the accepted slot identities from provisional destruction/restoration. */ readonly detachCommittedSlots: (slotIds: readonly string[]) => boolean; readonly dispose: () => void; } +/** Compact authenticated capability passed from the GPT slice to the inline owner. */ +export type FirstDisplayGoogletagBatchCapabilityV1 = readonly [ + start: FirstDisplayGoogletagBatch['start'], + closeIngress: FirstDisplayGoogletagBatch['closeIngress'], + captureHandoff: FirstDisplayGoogletagBatch['captureHandoff'], + captureDiagnosticsHandoff: FirstDisplayGoogletagBatch['captureDiagnosticsHandoff'], + detachCommittedSlots: FirstDisplayGoogletagBatch['detachCommittedSlots'], + dispose: FirstDisplayGoogletagBatch['dispose'], +]; + export interface FirstDisplayGoogletagBatchOptions { readonly browser: Window & { googletag?: unknown }; readonly clearTimer: (handle: unknown) => void; @@ -122,14 +145,20 @@ export interface FirstDisplayGoogletagBatchOptions { readonly diagnosticsActive?: boolean; readonly onNativeMutation?: () => boolean; readonly projection: FirstDisplayProjectionV1; - readonly protocol: Pick< - FirstDisplayGptProtocolV1, - 'classifyRenderEnded' | 'deadlines' | 'requestPlan' - >; + readonly protocol: FirstDisplayGptBatchPolicyV1; readonly setTimer: (callback: () => void, delayMs: number) => unknown; } -export type FirstDisplayGoogletagBatchInput = Omit; +/** Compact authenticated input crossing from the inline owner to the GPT slice. */ +export type FirstDisplayGoogletagBatchInput = readonly [ + browser: FirstDisplayGoogletagBatchOptions['browser'], + clearTimer: FirstDisplayGoogletagBatchOptions['clearTimer'], + document: FirstDisplayGoogletagBatchOptions['document'], + setTimer: FirstDisplayGoogletagBatchOptions['setTimer'], + projection: FirstDisplayGoogletagBatchOptions['projection'], + diagnosticsActive?: FirstDisplayGoogletagBatchOptions['diagnosticsActive'], + onNativeMutation?: FirstDisplayGoogletagBatchOptions['onNativeMutation'], +]; type ExternalObject = Record; @@ -140,11 +169,12 @@ interface FirstDisplayDiagnosticCycleRecord { state: 'open' | 'completed' | 'retired'; } -interface ActiveCycle extends FirstDisplayGptBoundCycleV1 { +interface ActiveCycle { readonly bindingState: { current: boolean }; readonly diagnosticRecords: FirstDisplayDiagnosticCycleRecord[]; readonly elementId: string; readonly operations: readonly ('display' | 'refresh')[]; + readonly publicCycle: FirstDisplayGptBoundCycleV1; readonly requestOperation: 0 | 1; readonly runtimeSlotNumber: number; completionTimer?: unknown; @@ -186,6 +216,48 @@ function externalObject(value: unknown): ExternalObject | undefined { : undefined; } +/** Enqueue the document-local GAM attribution command at the parser-time boundary. */ +export function enqueueFirstDisplayGamAttribution( + target: Window & { googletag?: unknown } +): boolean { + try { + let binding = externalObject(target.googletag); + if (!binding) { + binding = { cmd: [] }; + if ( + !Reflect.defineProperty(target, 'googletag', { + configurable: true, + enumerable: true, + value: binding, + writable: true, + }) || + target.googletag !== binding + ) { + return false; + } + } + const queue = externalObject(Reflect.get(binding, 'cmd')); + const push = queue && Reflect.get(queue, 'push'); + if (!queue || typeof push !== 'function') return false; + Reflect.apply(push, queue, [ + () => { + try { + const root = externalObject(target.googletag) ?? binding; + const setConfig = Reflect.get(root, 'setConfig'); + if (typeof setConfig === 'function') { + Reflect.apply(setConfig, root, [{ targeting: { ts: 'true' } }]); + } + } catch { + // Missing or hostile GPT targeting cannot block later queue work. + } + }, + ]); + return true; + } catch { + return false; + } +} + function member(value: unknown, key: PropertyKey): unknown { const object = externalObject(value); if (!object) return undefined; @@ -250,13 +322,10 @@ function targetingEntries( ); } -function winnerRows(projection: FirstDisplayProjectionV1): readonly Readonly<{ - bid: FirstDisplayProjectionBidV1; - placement: FirstDisplayProjectionSlotV1; -}>[] { - const rows: Array< - Readonly<{ bid: FirstDisplayProjectionBidV1; placement: FirstDisplayProjectionSlotV1 }> - > = []; +function winnerRows( + projection: FirstDisplayProjectionV1 +): readonly (readonly [FirstDisplayProjectionBidV1, FirstDisplayProjectionSlotV1])[] { + const rows: Array = []; let winner = 0; for (let index = 0; index < projection.auction.results.length; index += 1) { const decision = projection.auction.results[index]; @@ -264,7 +333,7 @@ function winnerRows(projection: FirstDisplayProjectionV1): readonly Readonly<{ if (!decision || !placement || decision.outcome !== 'winner') continue; const bid = projection.bids[winner]; winner += 1; - if (bid) rows.push(Object.freeze({ bid, placement })); + if (bid) rows.push(Object.freeze([bid, placement])); } return Object.freeze(rows); } @@ -363,9 +432,11 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (committed.size !== committedSlotIds.length) return false; const retainedSlots = new Set(); for (const slotId of committed) { - const matches = [...this.cycleMap.values()].filter((cycle) => cycle.slotId === slotId); + const matches = [...this.cycleMap.values()].filter( + (cycle) => cycle.publicCycle[6] === slotId + ); if (matches.length !== 1 || !matches[0]?.settled) return false; - retainedSlots.add(matches[0].physicalSlot); + retainedSlots.add(matches[0].publicCycle[4]); } const destroyableSlots = [...this.createdSlots].filter((slot) => !retainedSlots.has(slot)); this.ingressClosing = true; @@ -419,58 +490,55 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { return true; } - public captureHandoff(): readonly FirstDisplayGptHandoffCycleV1[] | undefined { + public captureHandoff(): readonly FirstDisplayGptCaptureCycleV1[] | undefined { if (this.disposed || !this.ingressClosed) return undefined; return Object.freeze( - [...this.cycleMap.values()].map((cycle) => { - const targetingOwnership = - this.sealedTargetingOwnership.get(cycle.physicalSlot) ?? Object.freeze([]); - return Object.freeze({ - bid: cycle.bid, - element: cycle.element, - isCurrent: cycle.isCurrent, - ownership: cycle.ownership, - physicalSlot: cycle.physicalSlot, - placement: cycle.placement, - slotId: cycle.slotId, - targetingOwnership: Object.freeze(targetingOwnership), - traceToken: cycle.traceToken, - }); - }) + [...this.cycleMap.values()] + .filter((cycle) => this.sealedTargetingOwnership.has(cycle.publicCycle[4])) + .map((cycle) => + Object.freeze([ + cycle.publicCycle[6], + cycle.elementId, + cycle.publicCycle[3], + Object.freeze(this.sealedTargetingOwnership.get(cycle.publicCycle[4]) ?? []), + cycle.publicCycle[7], + cycle.publicCycle[4], + ] as const) + ) ); } public captureDiagnosticsHandoff(): FirstDisplayGptDiagnosticsHandoffV1 | undefined { if (this.disposed || !this.ingressClosed) return undefined; - return Object.freeze({ - cycles: Object.freeze( - [...this.cycleMap.values()].map((cycle) => - Object.freeze({ - nextCycleOrdinal: cycle.nextDiagnosticCycleOrdinal, - quarantines: Object.freeze([]), - records: Object.freeze( - cycle.diagnosticRecords.map((record) => - Object.freeze({ - ordinal: record.ordinal, - responseIdentifier: record.responseIdentifier ?? null, - seen: Object.freeze( - DIAGNOSTIC_EVENT_ORDER.filter((event) => record.seen.has(event)) - ), - state: record.state, - }) - ) - ), - slotId: cycle.slotId, - token: cycle.traceToken, - unknownPriorCycle: cycle.unknownPriorCycle, - }) - ) + return Object.freeze([ + Object.freeze( + [...this.cycleMap.values()] + .filter((cycle) => this.sealedTargetingOwnership.has(cycle.publicCycle[4])) + .map((cycle) => + Object.freeze([ + cycle.publicCycle[6], + cycle.publicCycle[7], + cycle.nextDiagnosticCycleOrdinal, + cycle.unknownPriorCycle, + Object.freeze([]), + Object.freeze( + cycle.diagnosticRecords.map((record) => + Object.freeze([ + record.ordinal, + record.responseIdentifier ?? null, + Object.freeze(DIAGNOSTIC_EVENT_ORDER.filter((event) => record.seen.has(event))), + record.state, + ] as const) + ) + ), + ] as const) + ) ), - facts: Object.freeze([...this.diagnosticFacts]), - dropCount: this.diagnosticFactDrops, - nextTraceTokenOrdinal: this.nextTraceTokenOrdinal, - overflowCount: this.diagnosticFactOverflow, - }); + Object.freeze([...this.diagnosticFacts]), + this.nextTraceTokenOrdinal, + this.diagnosticFactOverflow, + this.diagnosticFactDrops, + ] as const); } public detachCommittedSlots(slotIds: readonly string[]): boolean { @@ -479,9 +547,11 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (requested.size !== slotIds.length) return false; const selected: object[] = []; for (const slotId of requested) { - const matches = [...this.cycleMap.values()].filter((cycle) => cycle.slotId === slotId); + const matches = [...this.cycleMap.values()].filter( + (cycle) => cycle.publicCycle[6] === slotId + ); if (matches.length !== 1 || !matches[0]?.settled) return false; - selected.push(matches[0].physicalSlot); + selected.push(matches[0].publicCycle[4]); } this.committedSlotsDetached = true; for (const slot of selected) this.detachedSlots.add(slot); @@ -562,10 +632,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private activate( binding: ExternalObject, - rows: readonly Readonly<{ - bid: FirstDisplayProjectionBidV1; - placement: FirstDisplayProjectionSlotV1; - }>[], + rows: readonly (readonly [FirstDisplayProjectionBidV1, FirstDisplayProjectionSlotV1])[], callbacks: FirstDisplayGoogletagBatchCallbacks ): void { if (this.disposed || this.binding !== binding) return; @@ -580,9 +647,10 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { for (const row of rows) { if (this.disposed) return; - const element = resolveElement(this.options.document, row.placement); + const [bid, placement] = row; + const element = resolveElement(this.options.document, placement); if (!element) { - callbacks.onFailure(row.placement.slot, SLOT_UNRESOLVED); + callbacks[1](placement.slot, SLOT_UNRESOLVED); continue; } const matches = existing.filter((candidate) => @@ -593,21 +661,17 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { : false ); if (matches.length > 1) { - callbacks.onFailure(row.placement.slot, SLOT_UNRESOLVED); + callbacks[1](placement.slot, SLOT_UNRESOLVED); continue; } const publisherSlot = physicalSlot(matches[0]); const slot = publisherSlot ?? physicalSlot( - call(binding, 'defineSlot', [ - row.placement.gamUnitPath, - row.placement.formats, - element.id, - ]) + call(binding, 'defineSlot', [placement.gamUnitPath, placement.formats, element.id]) ); if (!slot) { - callbacks.onFailure(row.placement.slot, SLOT_UNRESOLVED); + callbacks[1](placement.slot, SLOT_UNRESOLVED); continue; } const ownership = publisherSlot ? 'publisher' : 'trusted_server'; @@ -621,52 +685,44 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze({ initialLoadDisabled: disabled, ownership }) ); if (!plan) { - callbacks.onFailure(row.placement.slot, GPT_REQUEST_FAILED); + callbacks[1](placement.slot, GPT_REQUEST_FAILED); continue; } const traceTokenOrdinal = this.nextTraceTokenOrdinal; if (traceTokenOrdinal > 4_294_967_295) { - callbacks.onFailure(row.placement.slot, GPT_REQUEST_FAILED); + callbacks[1](placement.slot, GPT_REQUEST_FAILED); continue; } const traceToken = `gt1_${traceTokenOrdinal.toString(36)}`; this.nextTraceTokenOrdinal += 1; const bindingState = { current: true }; + const publicCycle: FirstDisplayGptBoundCycleV1 = Object.freeze([ + bid, + element, + () => bindingState.current, + ownership, + slot, + placement, + placement.slot, + traceToken, + ]); const cycle: ActiveCycle = { - bid: row.bid, bindingState, diagnosticRecords: [], - element, elementId: element.id, - isCurrent: () => bindingState.current, operations: plan.operations, - ownership, - physicalSlot: slot, - placement: row.placement, + publicCycle, requestOperation: plan.requestOperation, runtimeSlotNumber: traceTokenOrdinal, nextDiagnosticCycleOrdinal: 1, requestInvoked: false, requested: false, settled: false, - slotId: row.placement.slot, - traceToken, unknownPriorCycle: ownership === 'publisher', }; this.cycleMap.set(slot, cycle); - callbacks.onBound( - Object.freeze({ - bid: cycle.bid, - element: cycle.element, - isCurrent: cycle.isCurrent, - ownership: cycle.ownership, - physicalSlot: cycle.physicalSlot, - placement: cycle.placement, - slotId: cycle.slotId, - traceToken: cycle.traceToken, - }) - ); - for (const [key, value] of targetingEntries(row.bid, row.placement)) { + callbacks[0](publicCycle); + for (const [key, value] of targetingEntries(bid, placement)) { if (publisherSlot) this.journalPublisherTargeting(slot, key, value); this.writeTargeting(slot, 'setTargeting', [key, value]); } @@ -689,11 +745,11 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { ); if (!this.firstAction) { this.firstAction = true; - if (!callbacks.onFirstAction()) throw new TypeError('tsjs'); + if (!callbacks[2]()) throw new TypeError('tsjs'); } } if (operation === 'display') call(binding, 'display', [cycle.elementId]); - else call(service, 'refresh', [[cycle.physicalSlot], { changeCorrelator: false }]); + else call(service, 'refresh', [[cycle.publicCycle[4]], { changeCorrelator: false }]); } } catch { if (cycle.settled) continue; @@ -753,19 +809,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { cycle.settled = true; if (cycle.requestTimer !== undefined) this.clearOwnedTimer(cycle.requestTimer); if (cycle.completionTimer !== undefined) this.clearOwnedTimer(cycle.completionTimer); - callbacks.onRenderEnded( - Object.freeze({ - bid: cycle.bid, - element: cycle.element, - isCurrent: cycle.isCurrent, - ownership: cycle.ownership, - physicalSlot: cycle.physicalSlot, - placement: cycle.placement, - slotId: cycle.slotId, - traceToken: cycle.traceToken, - }), - result - ); + callbacks[3](cycle.publicCycle, result); }; this.requestedListener = requestedListener; this.renderListener = renderListener; @@ -815,7 +859,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } if (cycle.requestTimer !== undefined) this.clearOwnedTimer(cycle.requestTimer); if (cycle.completionTimer !== undefined) this.clearOwnedTimer(cycle.completionTimer); - callbacks.onFailure(cycle.slotId, reason); + callbacks[1](cycle.publicCycle[6], reason); } private journalPublisherTargeting(slot: object, key: string, installed: string): void { @@ -976,18 +1020,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { if (!cycle.bindingState.current) return false; cycle.bindingState.current = false; try { - callbacks.onRetire?.( - Object.freeze({ - bid: cycle.bid, - element: cycle.element, - isCurrent: cycle.isCurrent, - ownership: cycle.ownership, - physicalSlot: cycle.physicalSlot, - placement: cycle.placement, - slotId: cycle.slotId, - traceToken: cycle.traceToken, - }) - ); + callbacks[4]?.(cycle.publicCycle); } catch { // Binding invalidation remains authoritative when retirement observation fails. } @@ -1018,7 +1051,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { ): void { if (!elementId) return; for (const cycle of this.cycleMap.values()) { - if (cycle.physicalSlot !== replacement && cycle.elementId === elementId) { + if (cycle.publicCycle[4] !== replacement && cycle.elementId === elementId) { this.retireCycle(cycle, callbacks); } } @@ -1201,14 +1234,14 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { const fact: Readonly = Object.freeze({ version: 1, event: eventType, - token: cycle.traceToken, + token: cycle.publicCycle[7], runtimeSlotNumber: cycle.runtimeSlotNumber, cycleOrdinal: disposition === 'matched' ? (diagnosticCycle?.ordinal ?? null) : null, disposition, issueReason, capturedAtMs: observedAtMs, elementId: cycle.elementId, - adUnitPath: cycle.placement.gamUnitPath, + adUnitPath: cycle.publicCycle[5].gamUnitPath, isEmpty: eventType === SLOT_RENDER_ENDED ? optionalBoolean('isEmpty') : null, renderedSize, isBackfill: eventType === SLOT_RENDER_ENDED ? optionalBoolean('isBackfill') : null, @@ -1372,13 +1405,13 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } private failRows( - rows: readonly Readonly<{ placement: FirstDisplayProjectionSlotV1 }>[], + rows: readonly (readonly [FirstDisplayProjectionBidV1, FirstDisplayProjectionSlotV1])[], callbacks: FirstDisplayGoogletagBatchCallbacks, reason: FirstDisplayGptFailureReason ): void { for (const row of rows) { try { - callbacks.onFailure(row.placement.slot, reason); + callbacks[1](row[1].slot, reason); } catch { // One consumer cannot prevent independent slot settlement. } diff --git a/crates/trusted-server-js/lib/src/first_display/agent.ts b/crates/trusted-server-js/lib/src/first_display/agent.ts index e9407d0fd..02c782b5c 100644 --- a/crates/trusted-server-js/lib/src/first_display/agent.ts +++ b/crates/trusted-server-js/lib/src/first_display/agent.ts @@ -1,42 +1,28 @@ import type { BootFailureReason } from '../kernel/fallback'; -import { - createNavigationIdentityIssuerFromSource, - type NavigationIdentityIssuer, -} from '../kernel/identity'; +import type { FirstDisplayNavigationIdentityIssuer } from '../kernel/identity'; import type { FirstDisplaySliceId } from '../kernel/release_catalog'; -import type { FirstDisplayGptDiagnosticsV1 } from '../shared/takeover'; -import { - captureMutationObservedBindings, - createFirstDisplayParserStateCollector, -} from '../shared/first_display_registration'; +import type { + FirstDisplayGptDiagnosticEventV1, + FirstDisplayGptDiagnosticsV1, +} from '../shared/takeover'; import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; -import { - isCreativeBootConfigV1, - isDidomiIntegrationConfigV1, - isEmptyIntegrationConfigV1, - isGptIntegrationConfigV1, - isPrebidIntegrationConfigV1, - isSourcepointIntegrationConfigV1, -} from '../shared/integration_config_validators'; -import { - createFirstDisplayHandoffOwner, - type FinalizedFirstDisplayHandoffV1, - type FirstDisplayHandoffOwner, +import type { + FinalizedFirstDisplayHandoffV1, + FirstDisplayAgentCaptureFinalizerV1, } from '../shared/first_display_handoff'; import type { FirstDisplayGoogletagBatchInput, - FirstDisplayGptDiagnosticCycleV1, + FirstDisplayGptBoundCycleV1, FirstDisplayGptHandoffCycleV1, } from './adapters/googletag'; -import { createFirstDisplayProjectedDriver, type FirstDisplayRenderBridgeV1 } from './driver'; +import type { FirstDisplayRenderBridgeCapabilityV1 } from './driver'; import type { FirstDisplayApsProtocolV1 } from './leaf/aps_protocol'; -import type { FirstDisplayGptProtocolV1 } from './leaf/gpt_protocol'; +import type { FirstDisplayGptCapabilityV1, FirstDisplayGptProtocolV1 } from './leaf/gpt_protocol'; import type { FirstDisplayRenderOwnerOptionsV1, FirstDisplayRenderOwnerProtocolV1, } from './render_journal'; -import { registerCurrentFirstDisplayComponent } from './registration_client'; import type { FirstDisplaySliceHost, InitialSliceInstaller, @@ -44,9 +30,9 @@ import type { } from './slices/definition'; import { acceptServerFirstDisplayBatchV1, + type FirstDisplayAgentBatchV1, type FirstDisplayAuctionProtocolId, type FirstDisplayBatchOutcomeV1, - type FirstDisplayBatchV1, } from './leaf/projection'; const MAX_U32 = 4_294_967_295; @@ -66,15 +52,6 @@ export type FirstDisplayTerminalResult = 'accepted' | 'failed' | 'cancelled'; export type FirstDisplayAgentState = 'ready' | 'active' | 'terminal' | 'painted' | 'failed' | 'disposed'; -export interface FirstDisplayBootstrapController { - readonly state: 'installing' | 'agent_registered' | 'action_started' | 'settled' | 'failed'; - readonly startedAtMs: number; - readonly registerAgent: () => boolean; - readonly startAction: () => boolean; - readonly settle: () => boolean; - readonly fail: (reason: BootFailureReason) => boolean; -} - export interface FirstDisplayDriver { readonly start: ( outcomes: readonly FirstDisplayBatchOutcomeV1[], @@ -100,7 +77,19 @@ export interface FirstDisplayDriverHandoffV1 { token: string; }>[]; readonly cycles: readonly FirstDisplayGptHandoffCycleV1[]; - readonly diagnosticCycles: readonly Readonly[]; + readonly diagnosticCycles: readonly Readonly<{ + nextCycleOrdinal: number; + quarantines: readonly string[]; + records: readonly Readonly<{ + ordinal: number; + responseIdentifier: string | null; + seen: readonly FirstDisplayGptDiagnosticEventV1[]; + state: 'open' | 'completed' | 'retired'; + }>[]; + slotId: string; + token: string; + unknownPriorCycle: boolean; + }>[]; readonly clockEpochMs: number; readonly gptDiagnostics: Readonly; readonly identities: readonly object[]; @@ -123,25 +112,35 @@ export interface FirstDisplayPaintScheduler { export interface FirstDisplayAgentOptions { readonly batch: unknown; - readonly bootstrap: FirstDisplayBootstrapController; - readonly driver: FirstDisplayDriver; + readonly production?: Readonly<{ + gpt?: FirstDisplayGptProtocolV1; + gptInput: readonly [ + browser: FirstDisplayGoogletagBatchInput[0], + clearTimer: FirstDisplayGoogletagBatchInput[1], + document: FirstDisplayGoogletagBatchInput[2], + setTimer: FirstDisplayGoogletagBatchInput[3], + diagnosticsActive?: FirstDisplayGoogletagBatchInput[5], + onNativeMutation?: FirstDisplayGoogletagBatchInput[6], + ]; + renderer?: FirstDisplayRenderBridgeCapabilityV1; + }>; + readonly startedAtMs: number; readonly performance: Readonly<{ mark: (name: string) => void; measure?: (name: string, startMark: string, endMark: string) => void; }>; readonly paint: FirstDisplayPaintScheduler; readonly onProtectedPaint: () => void; + readonly onSettled: () => void; readonly onFailure: (reason: BootFailureReason) => void; - readonly onPrebidAdmissionFailure?: (reason: 'prebid_admission_failed') => void; readonly mutationDocument?: Document; readonly initialMutationRevision?: number; readonly now?: () => number; - readonly identityIssuer?: NavigationIdentityIssuer; - readonly parserState?: () => readonly Readonly<{ - sliceId: string; - observations: readonly string[]; - values: readonly (readonly [string, string | number | boolean | null])[]; - }>[]; + readonly identityIssuer?: FirstDisplayNavigationIdentityIssuer; + readonly parserState?: () => readonly (readonly [ + string, + readonly (readonly [string, string | number | boolean | null])[], + ])[]; readonly handoff?: Readonly<{ releaseId: string; generation: number; @@ -152,17 +151,27 @@ export interface FirstDisplayAgentOptions { /** Bootstrap-owned dependencies supplied only to the release-bound base component. */ export interface FirstDisplayAgentRegistrationHostV1 { - readonly options: Omit & + readonly options: FirstDisplayAgentOptions & Readonly<{ - gptInput: Omit; + gptInput: readonly [ + browser: FirstDisplayGoogletagBatchInput[0], + clearTimer: FirstDisplayGoogletagBatchInput[1], + document: FirstDisplayGoogletagBatchInput[2], + setTimer: FirstDisplayGoogletagBatchInput[3], + diagnosticsActive?: FirstDisplayGoogletagBatchInput[5], + ]; onAgentReady?: (agent: FirstDisplayAgent) => void; }>; - readonly sliceBindings: (id: string) => unknown; + readonly sliceBindings: ( + id: string, + observe: (key: unknown, value: unknown) => void, + register: ((protocol: unknown) => () => void) | undefined + ) => readonly [bindings: unknown, config: unknown]; } function createBrowserMessageChannel( browser: Window -): ReturnType { +): ReturnType { const constructor = Reflect.get(browser, 'MessageChannel'); if (typeof constructor !== 'function') { throw new TypeError('tsjs'); @@ -173,7 +182,7 @@ function createBrowserMessageChannel( if (typeof port1 !== 'object' || port1 === null || typeof port2 !== 'object' || port2 === null) { throw new TypeError('tsjs'); } - return { port1, port2 } as ReturnType; + return { port1, port2 } as ReturnType; } function fillBrowserRandom(browser: Window, bytes: Uint8Array): void { @@ -196,160 +205,79 @@ function readBrowserNow(browser: Window): number { return Reflect.apply(now, performance, []) as number; } -interface PreparedFirstDisplayBaseV1 { +export interface PreparedFirstDisplayBaseV1 { readonly activate: (context: FirstDisplaySliceActivationContext) => void; readonly sliceHost: FirstDisplaySliceHost; } -export interface FirstDisplayAgentSnapshotV1 { +export interface FirstDisplayAgent { readonly state: FirstDisplayAgentState; readonly mutationRevision: number; readonly initialDisplayCommitted: boolean; - readonly outcomes: readonly Readonly<{ - slotId: string; - result: FirstDisplayTerminalResult | 'no_bid' | 'failed' | 'cancelled'; - }>[]; -} - -export interface FirstDisplayAgent { - readonly state: FirstDisplayAgentState; - readonly coversProtocols: ( - protocols: ReadonlyMap - ) => boolean; readonly start: () => boolean; - readonly admitTsBid: (complete: () => void) => boolean; readonly observeNativeMutation: () => boolean; - readonly snapshot: () => FirstDisplayAgentSnapshotV1; - readonly finalizeHandoff: () => FinalizedFirstDisplayHandoffV1 | undefined; + readonly finalizeHandoff: ( + finalize: FirstDisplayAgentCaptureFinalizerV1 + ) => FinalizedFirstDisplayHandoffV1 | undefined; readonly detachCommittedArtifacts: () => boolean; readonly dispose: () => void; } -function protocolIdentity(candidate: unknown, expected: FirstDisplayAuctionProtocolId): boolean { +type FirstDisplayRegisteredProtocolId = FirstDisplayAuctionProtocolId | 'render_owner'; + +function protocolIdentity( + candidate: unknown, + expected: FirstDisplayRegisteredProtocolId, + exact = true +): boolean { try { + const length = exact ? 2 : 3; if ( - typeof candidate !== 'object' || - candidate === null || - Array.isArray(candidate) || - Object.getPrototypeOf(candidate) !== Object.prototype || + !Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Array.prototype || !Object.isFrozen(candidate) || - Reflect.ownKeys(candidate).length !== 2 + candidate.length !== length || + Reflect.ownKeys(candidate).length !== length + 1 ) { return false; } - const version = Object.getOwnPropertyDescriptor(candidate, 'version'); - const id = Object.getOwnPropertyDescriptor(candidate, 'id'); - return Boolean( - version?.enumerable && - 'value' in version && - version.value === 1 && - id?.enumerable && - 'value' in id && - id.value === expected - ); + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) return false; + } + return candidate[0] === 1 && candidate[1] === expected && (exact || candidate[2] !== undefined); } catch { return false; } } -type FirstDisplayRegisteredProtocolId = - FirstDisplayAuctionProtocolId | FirstDisplayRenderOwnerProtocolV1['id']; - function fullProtocolIdentity( candidate: unknown, expected: FirstDisplayRegisteredProtocolId ): boolean { - try { - if ( - typeof candidate !== 'object' || - candidate === null || - Array.isArray(candidate) || - Object.getPrototypeOf(candidate) !== Object.prototype || - !Object.isFrozen(candidate) - ) { - return false; - } - const version = Object.getOwnPropertyDescriptor(candidate, 'version'); - const id = Object.getOwnPropertyDescriptor(candidate, 'id'); - return Boolean( - version?.enumerable && - 'value' in version && - version.value === 1 && - id?.enumerable && - 'value' in id && - id.value === expected - ); - } catch { - return false; - } + return protocolIdentity(candidate, expected, false); } -function captureProtocolRegistration( - candidate: unknown, - protocolId: FirstDisplayRegisteredProtocolId, - protocols: Map -): unknown { - try { - if ( - typeof candidate !== 'object' || - candidate === null || - Array.isArray(candidate) || - Object.getPrototypeOf(candidate) !== Object.prototype || - !Object.isFrozen(candidate) - ) { - return candidate; - } - const descriptors = Object.getOwnPropertyDescriptors(candidate); - const register = descriptors.register; - if (!register?.enumerable || !('value' in register) || typeof register.value !== 'function') { - return candidate; - } - const original = register.value as (protocol: unknown) => unknown; - const captured = Object.create(Object.prototype) as Record; - const wrappedRegister = (protocol: unknown): (() => void) => { - if (!fullProtocolIdentity(protocol, protocolId) || protocols.has(protocolId)) { - throw new TypeError('tsjs'); - } - const release = Reflect.apply(original, candidate, [protocol]); - if (typeof release !== 'function') { - throw new TypeError('tsjs'); - } - protocols.set(protocolId, protocol); - let live = true; - return () => { - if (!live) return; - live = false; - if (protocols.get(protocolId) === protocol) protocols.delete(protocolId); - Reflect.apply(release, undefined, []); - }; - }; - Object.defineProperties(captured, { - ...descriptors, - register: { - ...register, - value: wrappedRegister, - }, - }); - return Object.freeze(captured); - } catch { - return candidate; - } +function sameCycle( + expected: FirstDisplayGptBoundCycleV1, + candidate: FirstDisplayGptBoundCycleV1 +): boolean { + return candidate === expected; } class FirstDisplayAgentOwner implements FirstDisplayAgent { - private readonly agentBatch: FirstDisplayBatchV1 | undefined; + private readonly agentBatch: FirstDisplayAgentBatchV1 | undefined; private readonly slotResults = new Map< string, FirstDisplayTerminalResult | 'no_bid' | 'failed' | 'cancelled' >(); private readonly pending = new Set(); private readonly reasons = new Map(); - private readonly handoffOwner: FirstDisplayHandoffOwner | undefined; + private handoffCapsule: FinalizedFirstDisplayHandoffV1['capsule'] | undefined; private stateValue: FirstDisplayAgentState = 'ready'; private mutationObserver: MutationObserver | undefined; private observedMutationRevision: number; private displayWasCommitted = false; - private sealed = false; private disposedDriver = false; private failed = false; private actionStarted = false; @@ -364,30 +292,13 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { string, Readonly<{ atMs: number; historySequence: number }> >(); + private readonly bound = new Map(); + private productionBatch: FirstDisplayGptCapabilityV1 | undefined; public constructor(private readonly options: FirstDisplayAgentOptions) { this.agentBatch = acceptServerFirstDisplayBatchV1(options.batch); this.observedMutationRevision = options.initialMutationRevision ?? 0; - this.lastTimingMs = options.bootstrap.startedAtMs; - this.handoffOwner = options.handoff - ? createFirstDisplayHandoffOwner({ - releaseId: options.handoff.releaseId, - generation: options.handoff.generation, - initialMutationRevision: this.observedMutationRevision, - isCurrentGeneration: () => !this.failed && this.stateValue !== 'disposed', - isTerminal: () => this.stateValue === 'painted', - isPainted: () => this.stateValue === 'painted', - closeIngress: () => { - if (!this.options.driver.closeIngress()) { - throw new TypeError('tsjs'); - } - this.closeNativeMutationIngress(); - }, - onFailure: (reason) => { - this.fail(reason); - }, - }) - : undefined; + this.lastTimingMs = options.startedAtMs; this.installNativeMutationIngress(); } @@ -395,113 +306,110 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { return this.stateValue; } - public coversProtocols(protocols: ReadonlyMap): boolean { - return Boolean( - this.agentBatch && - protocols.size === this.agentBatch.requiredProtocols.length && - this.agentBatch.requiredProtocols.every((id) => protocolIdentity(protocols.get(id), id)) - ); + public get mutationRevision(): number { + return this.observedMutationRevision; + } + + public get initialDisplayCommitted(): boolean { + return this.displayWasCommitted; } public start(): boolean { if (this.stateValue !== 'ready') return false; if (!this.agentBatch) return this.fail('abi_mismatch'); - if (!this.options.bootstrap.registerAgent()) { - this.stateValue = 'failed'; - return false; + const started = this.readTiming(); + if (started === undefined || started - this.options.startedAtMs >= 10_000) { + return this.fail(BUNDLE_PARTIAL); } - const actions: FirstDisplayBatchOutcomeV1[] = []; - for (let index = 0; index < this.agentBatch.outcomes.length; index += 1) { - const outcome = this.agentBatch.outcomes[index]!; - if (ACTION_KINDS.has(outcome.kind)) { - this.pending.add(outcome.slotId); - actions.push(outcome); + let actionCount = 0; + const outcomes = this.agentBatch[2]; + const projection = this.agentBatch[3]; + for (let index = 0; index < outcomes.length; index += 1) { + const outcome = outcomes[index]!; + if (ACTION_KINDS.has(outcome[1])) { + this.pending.add(outcome[0]); + actionCount += 1; } else { - this.slotResults.set(outcome.slotId, outcome.kind as 'no_bid' | 'failed' | 'cancelled'); - const decision = this.agentBatch.projection.auction.results[index]; - this.reasons.set(outcome.slotId, decision?.outcome === 'failed' ? decision.reason : null); + this.slotResults.set(outcome[0], outcome[1] as 'no_bid' | 'failed' | 'cancelled'); + const decision = projection.auction.results[index]; + this.reasons.set(outcome[0], decision?.outcome === 'failed' ? decision.reason : null); } } this.stateValue = 'active'; - if (actions.length === 0) { + if (actionCount === 0) { this.recordTerminal(); return true; } try { - this.options.driver.start( - Object.freeze(actions), - () => this.recordFirstAction(), - (slotId, result, reason) => { - this.settle(slotId, result, reason); - } - ); + if (!this.startProduction()) { + throw new TypeError('tsjs'); + } return true; } catch { return this.fail(BUNDLE_PARTIAL); } } - public admitTsBid(complete: () => void): boolean { - if (!this.sealed || typeof complete !== 'function') return false; - try { - complete(); - } catch { - // Bidder completion isolation cannot reopen sealed TS admission. - } - try { - this.options.onPrebidAdmissionFailure?.('prebid_admission_failed'); - } catch { - // Diagnostics cannot alter the terminal no-bid completion. - } - return false; - } - public observeNativeMutation(): boolean { if (this.stateValue !== 'painted' || this.failed) return false; - if (this.handoffOwner) { - const observed = this.handoffOwner.observeMutation(); - this.observedMutationRevision = this.handoffOwner.mutationRevision; - return observed; - } if (this.observedMutationRevision >= MAX_U32) return this.fail(BUNDLE_PARTIAL); this.observedMutationRevision += 1; return true; } - public snapshot(): FirstDisplayAgentSnapshotV1 { - const outcomes = this.agentBatch?.outcomes.map(({ slotId }) => - Object.freeze({ - slotId, - result: this.slotResults.get(slotId) ?? 'failed', - }) - ); - return Object.freeze({ - state: this.stateValue, - mutationRevision: this.observedMutationRevision, - initialDisplayCommitted: this.displayWasCommitted, - outcomes: Object.freeze(outcomes ?? []), - }); - } - - public finalizeHandoff(): FinalizedFirstDisplayHandoffV1 | undefined { + public finalizeHandoff( + finalize: FirstDisplayAgentCaptureFinalizerV1 + ): FinalizedFirstDisplayHandoffV1 | undefined { if ( this.stateValue !== 'painted' || this.failed || - !this.handoffOwner || - this.handoffFinalized + !this.options.handoff || + this.handoffFinalized || + typeof finalize !== 'function' ) { return undefined; } - const finalized = this.handoffOwner.finalize(() => { - const captured = this.options.driver.captureHandoff(); - if (!captured) return undefined; - const candidate = this.captureHandoffData(captured); - return candidate ? Object.freeze({ candidate, identities: captured.identities }) : undefined; - }); - if (!finalized) return undefined; - this.handoffFinalized = true; - return finalized; + try { + if (!this.closeDriverIngress()) throw new TypeError('tsjs'); + this.closeNativeMutationIngress(); + const currentTimeMs = this.readTiming(); + if ( + currentTimeMs === undefined || + !this.agentBatch || + this.terminalAtMs === undefined || + this.paintAtMs === undefined + ) { + throw new TypeError('tsjs'); + } + const finalized = finalize([ + this.options.handoff, + Object.freeze([this.agentBatch[0], this.agentBatch[2]]), + this.slotResults, + this.reasons, + this.acceptedTrace, + this.options.parserState?.() ?? [], + this.productionBatch?.[2](), + this.productionBatch?.[3](), + this.options.production?.renderer?.[7](), + [ + this.options.startedAtMs, + this.firstActionAtMs, + this.terminalAtMs, + this.paintAtMs, + currentTimeMs, + ], + this.nextTraceSequence, + this.observedMutationRevision, + ]); + if (!finalized) throw new TypeError('tsjs'); + this.handoffCapsule = finalized.capsule; + this.handoffFinalized = true; + return finalized; + } catch { + this.fail(BUNDLE_PARTIAL); + return undefined; + } } public detachCommittedArtifacts(): boolean { @@ -512,7 +420,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { ) { return false; } - if (!this.options.driver.detachCommittedArtifacts()) return false; + if (!this.detachDriverArtifacts()) return false; this.committedArtifactsDetached = true; return true; } @@ -520,7 +428,8 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { public dispose(): void { if (this.stateValue === 'disposed') return; this.stateValue = 'disposed'; - this.handoffOwner?.dispose(); + this.handoffCapsule?.clear(); + this.handoffCapsule = undefined; this.disposeDriver(); this.pending.clear(); } @@ -572,24 +481,23 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { this.fail(BUNDLE_PARTIAL); return; } - this.options.bootstrap.settle(); + try { + this.options.onSettled(); + } catch { + this.fail(BUNDLE_PARTIAL); + return; + } this.mark('tsjs:first-display-terminal'); this.scheduleProtectedPaint(2); } private recordFirstAction(): boolean { if (this.stateValue !== 'active' || this.actionStarted) return this.fail(BUNDLE_PARTIAL); - if (!this.options.bootstrap.startAction()) { - if (this.options.bootstrap.state !== 'failed') return this.fail(BUNDLE_PARTIAL); - this.failed = true; - this.stateValue = 'failed'; - this.disposeDriver(); - this.pending.clear(); - return false; + const firstDisplayMs = this.readTiming(); + if (firstDisplayMs === undefined || firstDisplayMs - this.options.startedAtMs >= 10_000) { + return this.fail(BUNDLE_PARTIAL); } this.actionStarted = true; - const firstDisplayMs = this.readTiming(); - if (firstDisplayMs === undefined) return this.fail(BUNDLE_PARTIAL); this.firstActionAtMs = firstDisplayMs; this.mark('tsjs:first-display'); try { @@ -612,12 +520,11 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { return; } try { - this.options.driver.sealTsAdmission(); + this.options.production?.renderer?.[5](); } catch { this.fail(BUNDLE_PARTIAL); return; } - this.sealed = true; this.stateValue = 'painted'; this.paintAtMs = this.readTiming(); if (this.paintAtMs === undefined) { @@ -649,7 +556,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { private readTiming(): number | undefined { try { - const value = this.options.now?.() ?? this.options.bootstrap.startedAtMs; + const value = this.options.now?.() ?? this.options.startedAtMs; if (!Number.isFinite(value) || value < 0 || value < this.lastTimingMs) return undefined; this.lastTimingMs = value; return value; @@ -658,209 +565,98 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { } } - private captureHandoffData(captured: FirstDisplayDriverHandoffV1): unknown | undefined { - const handoff = this.options.handoff; + private startProduction(): boolean { + const production = this.options.production; const batch = this.agentBatch; - if (!handoff || !batch || this.terminalAtMs === undefined || this.paintAtMs === undefined) { - return undefined; - } - const cycleBySlot = new Map(captured.cycles.map((cycle) => [cycle.slotId, cycle])); - const diagnosticCycleBySlot = new Map( - captured.diagnosticCycles.map((cycle) => [cycle.slotId, cycle]) - ); - const cycleTokenBySlot = new Map( - captured.cycles.map((cycle) => [cycle.slotId, cycle.traceToken]) - ); - const artifactBySlot = new Map( - captured.artifacts.map((artifact) => [artifact.slotId, artifact]) + const renderer = production?.renderer; + if (!production || !batch || !renderer) return false; + const gptBatch = production.gpt?.[2]( + Object.freeze([ + production.gptInput[0], + production.gptInput[1], + production.gptInput[2], + production.gptInput[3], + batch[3], + production.gptInput[4], + production.gptInput[5], + ]) ); - if ( - cycleBySlot.size !== captured.cycles.length || - diagnosticCycleBySlot.size !== captured.diagnosticCycles.length || - captured.diagnosticCycles.length !== captured.cycles.length || - artifactBySlot.size !== captured.artifacts.length - ) { - return undefined; - } - const bidBySlot = new Map(batch.projection.bids.map((bid) => [bid.slot, bid])); - const slots = batch.projection.slots.map((placement, index) => { - const outcome = this.slotResults.get(placement.slot); - const projected = batch.outcomes[index]; - if (!outcome || !projected || projected.slotId !== placement.slot) { - throw new TypeError('tsjs'); - } - const cycle = cycleBySlot.get(placement.slot); - const bid = bidBySlot.get(placement.slot); - const targeting: Record = { ...placement.targeting }; - if (bid) { - Object.assign(targeting, bid.targeting); - targeting.hb_adid = bid.rendererReservationId; - } - const committedArtifact = - outcome === 'accepted' && (projected.kind === 'gpt_adm' || projected.kind === 'aps') - ? projected.kind - : 'none'; - return { - id: placement.slot, - aliases: [], - domId: cycle?.element.id ?? placement.divId, - gamPath: placement.gamUnitPath, - formats: placement.formats.map((format) => [...format]), - owner: cycle?.ownership ?? 'trusted_server', - outcome, - targeting: Object.keys(targeting) - .sort() - .map((key) => [key, targeting[key]]), - targetingOwnership: - cycle?.targetingOwnership.map((ownership) => ({ - installed: ownership.installed, - key: ownership.key, - prior: [...ownership.prior], - })) ?? [], - committedArtifact, - gptToken: cycleTokenBySlot.get(placement.slot) ?? null, - }; - }); - const acceptedIds = new Set( - slots.filter((slot) => slot.outcome === 'accepted').map((slot) => slot.id) - ); - if ( - captured.cycles.some(({ slotId }) => !acceptedIds.has(slotId)) || - captured.artifacts.some((artifact) => { - const projected = batch.outcomes.find(({ slotId }) => slotId === artifact.slotId); - const bid = bidBySlot.get(artifact.slotId); - return ( - !acceptedIds.has(artifact.slotId) || - !projected || - projected.kind !== artifact.kind || - bid?.rendererReservationId !== artifact.token - ); - }) - ) { - return undefined; - } - const issuer = this.options.identityIssuer; - if (!issuer) return undefined; - const attempts = []; - for (let index = 0; index < slots.length; index += 1) { - const slot = slots[index]!; - const identity = issuer.mintAttemptId(); - if (!identity.ok) return undefined; - attempts.push({ - id: identity.value, - slotId: slot.id, - ordinal: index + 1, - state: slot.outcome, - reason: this.reasons.get(slot.id) ?? null, - }); - } - const artifacts = slots - .filter((slot) => slot.committedArtifact !== 'none') - .map((slot) => { - const bid = bidBySlot.get(slot.id); - const capturedArtifact = artifactBySlot.get(slot.id); - if (!bid || !capturedArtifact) { - throw new TypeError('tsjs'); + if (!gptBatch) return false; + this.productionBatch = gptBatch; + return gptBatch[0]([ + (cycle): void => { + const action = batch[2].find(([slotId]) => slotId === cycle[6]); + const bidIndex = batch[3].bids.indexOf(cycle[0]); + const placement = batch[3].slots.find(({ slot }) => slot === cycle[6]); + if ( + !action || + !ACTION_KINDS.has(action[1]) || + this.bound.has(cycle[6]) || + bidIndex < 0 || + batch[3].bids[bidIndex]?.slot !== cycle[6] || + cycle[5] !== placement + ) { + this.settle(cycle[6], 'failed', 'gpt_request_failed'); + return; + } + this.bound.set(cycle[6], cycle); + if (!renderer[0](cycle, (result, reason) => this.settle(cycle[6], result, reason))) { + this.settle(cycle[6], 'failed', 'internal_error'); } - return { - hostPosition: capturedArtifact.hostPosition, - hostPositionPriority: capturedArtifact.hostPositionPriority, - slotId: slot.id, - kind: slot.committedArtifact, - owner: capturedArtifact.owner, - token: bid.rendererReservationId, - }; - }); - const cycles = captured.cycles.map((cycle) => { - const diagnostic = diagnosticCycleBySlot.get(cycle.slotId); - if (!diagnostic || diagnostic.token !== cycle.traceToken) { - throw new TypeError('tsjs'); - } - return { - slotId: diagnostic.slotId, - token: diagnostic.token, - nextCycleOrdinal: diagnostic.nextCycleOrdinal, - unknownPriorCycle: diagnostic.unknownPriorCycle, - records: diagnostic.records.map((record) => ({ - ordinal: record.ordinal, - responseIdentifier: record.responseIdentifier, - seen: [...record.seen], - state: record.state, - })), - quarantines: [...diagnostic.quarantines], - }; - }); - const traceSlots = slots.map((slot) => { - const accepted = this.acceptedTrace.get(slot.id); - return { - slotId: slot.id, - impressions: accepted ? 1 : 0, - bindings: - !accepted || slot.gptToken === null - ? [] - : [ - { - atMs: accepted.atMs, - cycleOrdinal: 1, - historySequence: accepted.historySequence, - state: 'completed', - token: slot.gptToken, - }, - ], - }; - }); - return { - version: 1, - releaseId: handoff.releaseId, - generation: handoff.generation, - projectionDigest: batch.projectionDigest, - integrationConfigDigest: handoff.integrationConfigDigest, - slices: [...handoff.slices], - slots, - attempts, - tombstones: captured.tombstones.map((entry) => ({ ...entry })), - artifacts, - parserState: this.options.parserState?.() ?? [], - gptDiagnostics: Object.freeze({ - facts: Object.freeze([...captured.gptDiagnostics.facts]), - overflowCount: captured.gptDiagnostics.overflowCount, - dropCount: captured.gptDiagnostics.dropCount, - }), - timing: { - bidsScriptMs: this.options.bootstrap.startedAtMs, - firstDisplayMs: this.firstActionAtMs, - terminalMs: this.terminalAtMs, - paintMs: this.paintAtMs, }, - highWater: { - navigationAttemptPrefix: issuer.snapshotPrefix(), - nextNavigationAttemptOrdinal: attempts.length + 1, - nextAttemptOrdinal: attempts.length + 1, - nextSlotRegistrationOrdinal: slots.length + 1, - reservationClockEpochMs: captured.clockEpochMs, - nextReservationOrdinal: captured.nextReservationOrdinal, - nextTicketOrdinal: captured.nextTicketOrdinal, + (slotId, reason): void => { + const cycle = this.bound.get(slotId); + if (cycle) renderer[2](cycle); + this.settle(slotId, 'failed', reason); }, - cycles, - trace: { - nextSequence: this.nextTraceSequence, - nextGlobalSlotOrdinal: captured.nextTraceTokenOrdinal, - slots: traceSlots, + () => this.recordFirstAction(), + (cycle, result): void => { + const exact = this.bound.get(cycle[6]); + if (!exact || !sameCycle(exact, cycle) || !renderer[1](exact, result)) { + this.settle(cycle[6], 'failed', 'gpt_request_failed'); + } }, - mutationRevision: this.observedMutationRevision, - }; + (cycle): void => { + const exact = this.bound.get(cycle[6]); + if (exact && sameCycle(exact, cycle)) renderer[3](exact); + }, + ]); + } + + private acceptedSlotIds(): string[] { + return [...this.slotResults.entries()] + .filter(([, result]) => result === 'accepted') + .map(([slotId]) => slotId); + } + + private closeDriverIngress(): boolean { + const production = this.options.production; + if (!production) return false; + const accepted = this.acceptedSlotIds(); + return ( + (!this.productionBatch || this.productionBatch[1](accepted)) && + (!production.renderer || production.renderer[6]()) + ); + } + + private detachDriverArtifacts(): boolean { + const production = this.options.production; + if (!production) return false; + const accepted = this.acceptedSlotIds(); + return ( + (!this.productionBatch || this.productionBatch[4](accepted)) && + (!production.renderer || production.renderer[8]()) + ); } private fail(reason: BootFailureReason): false { if (this.failed || this.stateValue === 'disposed') return false; this.failed = true; this.stateValue = 'failed'; - if (!this.options.bootstrap.fail(reason)) { - try { - this.options.onFailure(reason); - } catch { - // Post-paint failure publication cannot restore provisional authority. - } + try { + this.options.onFailure(reason); + } catch { + // Failure publication cannot restore provisional authority. } this.disposeDriver(); this.pending.clear(); @@ -872,14 +668,16 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { this.disposedDriver = true; this.disposeNativeMutationIngress(); try { - this.options.driver.dispose(); + this.productionBatch?.[5](); + this.options.production?.renderer?.[9](); + this.bound.clear(); } catch { // Disposal is generation-latched; physical cleanup failure cannot restore authority. } } private installNativeMutationIngress(): void { - if (!this.handoffOwner || !this.options.mutationDocument) return; + if (!this.options.handoff || !this.options.mutationDocument) return; try { const document = this.options.mutationDocument; const Observer = document.defaultView?.MutationObserver; @@ -899,7 +697,7 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { private observeDomMutations(records: readonly MutationRecord[]): void { if (records.length > 0) { try { - this.options.driver.sweepCommittedArtifacts(); + this.options.production?.renderer?.[4](); } catch { this.fail(BUNDLE_PARTIAL); return; @@ -949,258 +747,178 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { /** Create the bounded provisional lifecycle owner; it publishes no runtime API. */ export function createFirstDisplayAgent(options: FirstDisplayAgentOptions): FirstDisplayAgent { - const owner = new FirstDisplayAgentOwner(options); - return Object.freeze({ - get state() { - return owner.state; - }, - coversProtocols: (protocols: ReadonlyMap) => - owner.coversProtocols(protocols), - start: () => owner.start(), - admitTsBid: (complete: () => void) => owner.admitTsBid(complete), - observeNativeMutation: () => owner.observeNativeMutation(), - snapshot: () => owner.snapshot(), - finalizeHandoff: () => owner.finalizeHandoff(), - detachCommittedArtifacts: () => owner.detachCommittedArtifacts(), - dispose: () => owner.dispose(), - }); + return new FirstDisplayAgentOwner(options); } -function createNonRenderingBridge(now: () => number): FirstDisplayRenderBridgeV1 { - let phase = 0; - return Object.freeze({ - bind: () => false, - recordGam: () => false, - recordFailure: () => false, - retire: () => false, - sweepCommittedArtifacts: () => 0, - sealTsAdmission: (): void => { - if (phase !== 0) throw new TypeError('tsjs'); - phase = 1; - }, - closeIngress: (): boolean => { - if (phase !== 1) return false; - phase = 2; - return true; - }, - captureHandoff: () => { - if (phase !== 2) return undefined; - let observedAt: number; - try { - observedAt = now(); - } catch { - return undefined; - } - if (!Number.isFinite(observedAt)) return undefined; - phase = 3; - return Object.freeze({ - artifacts: Object.freeze([]), - clockEpochMs: observedAt, - nextReservationOrdinal: 1, - nextTicketOrdinal: 1, - tombstones: Object.freeze([]), - }); - }, - detachCommittedArtifacts: (): boolean => { - if (phase !== 3) return false; - phase = 4; - return true; - }, - dispose: (): void => { - phase = 5; - }, - }); -} - -function prepareRegisteredAgent(host: unknown): PreparedFirstDisplayBaseV1 { - try { - if ( - typeof host !== 'object' || - host === null || - Array.isArray(host) || - Object.getPrototypeOf(host) !== Object.prototype || - !Object.isFrozen(host) || - Reflect.ownKeys(host).length !== 2 - ) { - throw new TypeError('tsjs'); +/** Prepare the bootstrap-owned first-display coordinator for its optional slices. */ +export function prepareFirstDisplayBase( + host: FirstDisplayAgentRegistrationHostV1 +): PreparedFirstDisplayBaseV1 { + const { options, sliceBindings } = host; + const fullProtocols = new Map(); + const parserState = new Map< + string, + { observations: string[]; values: Map } + >(); + const registerParserSlice = (id: string): boolean => { + if (parserState.has(id) || !options.handoff?.slices.includes(id as FirstDisplaySliceId)) { + return false; } - const descriptor = Object.getOwnPropertyDescriptor(host, 'options'); - const bindingsDescriptor = Object.getOwnPropertyDescriptor(host, 'sliceBindings'); + parserState.set(id, { observations: [], values: new Map() }); + return true; + }; + const observeParserValue = (id: string, key: unknown, value: unknown): boolean => { + const state = parserState.get(id); if ( - !descriptor?.enumerable || - !('value' in descriptor) || - typeof descriptor.value !== 'object' || - descriptor.value === null || - !Object.isFrozen(descriptor.value) || - !bindingsDescriptor?.enumerable || - !('value' in bindingsDescriptor) || - typeof bindingsDescriptor.value !== 'function' + !state || + typeof key !== 'string' || + key.length === 0 || + key.length > 128 || + (value !== null && + typeof value !== 'string' && + typeof value !== 'boolean' && + !(typeof value === 'number' && Number.isFinite(value))) || + (typeof value === 'string' && value.length > 4_096) ) { - throw new TypeError('tsjs'); + return false; } - const options = descriptor.value as FirstDisplayAgentRegistrationHostV1['options']; - const sliceBindings = bindingsDescriptor.value as (id: string) => unknown; - const auctionProtocols = new Map(); - const fullProtocols = new Map(); - const parserState = createFirstDisplayParserStateCollector(); - let agent: FirstDisplayAgent | undefined; - const sliceConfigValid = (id: OptionalFirstDisplaySliceId, candidate: unknown): boolean => { - if (id === 'creative_initial') return isCreativeBootConfigV1(candidate); - if (id === 'didomi_initial') return isDidomiIntegrationConfigV1(candidate); - if (id === 'gpt_initial') return isGptIntegrationConfigV1(candidate); - if (id === 'prebid_initial') return isPrebidIntegrationConfigV1(candidate); - if (id === 'sourcepoint_initial') return isSourcepointIntegrationConfigV1(candidate); - return isEmptyIntegrationConfigV1(candidate); - }; - const sliceHost: FirstDisplaySliceHost = Object.freeze({ - activate: ( - id: OptionalFirstDisplaySliceId, - own: FirstDisplaySliceActivationContext['own'], - install?: InitialSliceInstaller - ): void => { - if (typeof install !== 'function') { + if (!state.values.has(key)) { + if (state.values.size >= 256) return false; + state.observations.push(key); + } + state.values.set(key, value as string | number | boolean | null); + return true; + }; + const snapshotParserState = () => + Object.freeze( + [...parserState].map(([sliceId, state]) => + Object.freeze([ + sliceId, + Object.freeze( + state.observations.map((key) => Object.freeze([key, state.values.get(key)!] as const)) + ), + ] as const) + ) + ); + let agent: FirstDisplayAgent | undefined; + const sliceHost: FirstDisplaySliceHost = Object.freeze({ + activate: ( + id: OptionalFirstDisplaySliceId, + own: FirstDisplaySliceActivationContext['own'], + install?: InitialSliceInstaller + ): void => { + if (typeof install !== 'function') { + throw new TypeError('tsjs'); + } + if (!registerParserSlice(id)) { + throw new TypeError('tsjs'); + } + const protocolId = id.endsWith('_initial') + ? (id.slice(0, -'_initial'.length) as FirstDisplayRegisteredProtocolId) + : undefined; + const observe = (key: unknown, value: unknown): void => { + if (!observeParserValue(id, key, value)) throw new TypeError('tsjs'); + agent?.observeNativeMutation(); + }; + const register = + protocolId && SLICE_PROTOCOLS.includes(protocolId) + ? (protocol: unknown): (() => void) => { + if (!fullProtocolIdentity(protocol, protocolId) || fullProtocols.has(protocolId)) { + throw new TypeError('tsjs'); + } + fullProtocols.set(protocolId, protocol); + return () => { + if (fullProtocols.get(protocolId) === protocol) { + fullProtocols.delete(protocolId); + } + }; + } + : undefined; + // The authenticated bootstrap builds the exact carrier around these + // base-owned observation and protocol capabilities. + const transported = sliceBindings(id, observe, register); + if (!Array.isArray(transported) || transported.length !== 2) { + throw new TypeError('tsjs'); + } + const installed = install(transported[0], own, transported[1]); + if (protocolId && SLICE_PROTOCOLS.includes(protocolId)) { + if (!fullProtocolIdentity(installed, protocolId)) { throw new TypeError('tsjs'); } - if (!parserState.register(id)) { - throw new TypeError('tsjs'); + if (AUCTION_PROTOCOLS.includes(protocolId as FirstDisplayAuctionProtocolId)) { + if (!protocolIdentity(installed, protocolId)) throw new TypeError('tsjs'); } - const protocolId = id.endsWith('_initial') - ? (id.slice(0, -'_initial'.length) as FirstDisplayRegisteredProtocolId) - : undefined; - const transported = sliceBindings(id); + } + }, + }); + return Object.freeze({ + activate: (context: FirstDisplaySliceActivationContext): void => { + const rendererOwner: { value: FirstDisplayRenderBridgeCapabilityV1 | undefined } = { + value: undefined, + }; + context.own(() => { + if (agent) agent.dispose(); + else rendererOwner.value?.[9](); + }); + context.afterActivate(() => { + const batch = acceptServerFirstDisplayBatchV1(options.batch); + if (!batch) throw new TypeError('tsjs'); + const gpt = fullProtocols.get('gpt'); + const aps = fullProtocols.get('aps'); + const renderOwner = fullProtocols.get('render_owner'); + const requiresRenderOwner = batch[2].some(([, kind]) => ACTION_KINDS.has(kind)); + const activatedAuctionProtocols = AUCTION_PROTOCOLS.filter((id) => fullProtocols.has(id)); if ( - typeof transported !== 'object' || - transported === null || - Array.isArray(transported) || - Object.getPrototypeOf(transported) !== Object.prototype || - !Object.isFrozen(transported) || - Reflect.ownKeys(transported).length !== 2 + activatedAuctionProtocols.length !== batch[1].length || + !batch[1].every((id) => fullProtocolIdentity(fullProtocols.get(id), id)) ) { throw new TypeError('tsjs'); } - const bindings = Object.getOwnPropertyDescriptor(transported, 'bindings'); - const config = Object.getOwnPropertyDescriptor(transported, 'config'); if ( - !bindings?.enumerable || - !('value' in bindings) || - !config?.enumerable || - !('value' in config) || - !sliceConfigValid(id, config.value) + requiresRenderOwner !== fullProtocolIdentity(renderOwner, 'render_owner') || + (aps !== undefined && renderOwner === undefined) ) { throw new TypeError('tsjs'); } - const candidate = captureMutationObservedBindings( - bindings.value, + const renderOptions: FirstDisplayRenderOwnerOptionsV1 = Object.freeze([ + options.gptInput[0], + options.gptInput[1], + () => createBrowserMessageChannel(options.gptInput[0]), + options.gptInput[2], + (bytes) => fillBrowserRandom(options.gptInput[0], bytes), + () => readBrowserNow(options.gptInput[0]), () => agent?.observeNativeMutation() === true, - (key, value) => { - if (!parserState.observe(id, key, value)) { - throw new TypeError('tsjs'); - } - } - ); - const installed = install( - protocolId && SLICE_PROTOCOLS.includes(protocolId) - ? captureProtocolRegistration(candidate, protocolId, fullProtocols) - : candidate, - own, - config.value - ); - if (protocolId && SLICE_PROTOCOLS.includes(protocolId)) { - if (!fullProtocolIdentity(installed, protocolId)) { - throw new TypeError('tsjs'); - } - if (AUCTION_PROTOCOLS.includes(protocolId as FirstDisplayAuctionProtocolId)) { - const auctionProtocolId = protocolId as FirstDisplayAuctionProtocolId; - if (auctionProtocols.has(auctionProtocolId)) throw new TypeError('tsjs'); - auctionProtocols.set(auctionProtocolId, installed); - } - } - }, - }); - return Object.freeze({ - activate: (context: FirstDisplaySliceActivationContext): void => { - const rendererOwner: { value: FirstDisplayRenderBridgeV1 | undefined } = { - value: undefined, - }; - context.own(() => { - if (agent) agent.dispose(); - else rendererOwner.value?.dispose(); - }); - context.afterActivate(() => { - const batch = acceptServerFirstDisplayBatchV1(options.batch); - if (!batch) throw new TypeError('tsjs'); - const gpt = fullProtocols.get('gpt'); - const aps = fullProtocols.get('aps'); - const renderOwner = fullProtocols.get('render_owner'); - const requiresRenderOwner = batch.outcomes.some(({ kind }) => ACTION_KINDS.has(kind)); - if (batch.requiredProtocols.includes('gpt') && !fullProtocolIdentity(gpt, 'gpt')) { - throw new TypeError('tsjs'); - } - if (batch.requiredProtocols.includes('aps') && !fullProtocolIdentity(aps, 'aps')) { - throw new TypeError('tsjs'); - } - if ( - requiresRenderOwner !== fullProtocolIdentity(renderOwner, 'render_owner') || - (aps !== undefined && renderOwner === undefined) - ) { - throw new TypeError('tsjs'); - } - const renderOptions: FirstDisplayRenderOwnerOptionsV1 = { - browser: options.gptInput.browser, - clearTimer: options.gptInput.clearTimer, - createChannel: () => createBrowserMessageChannel(options.gptInput.browser), - document: options.gptInput.document, - fillRandom: (bytes) => fillBrowserRandom(options.gptInput.browser, bytes), - now: () => readBrowserNow(options.gptInput.browser), - onNativeMutation: () => agent?.observeNativeMutation() === true, - setTimer: options.gptInput.setTimer, - }; - const apsProtocol = fullProtocolIdentity(aps, 'aps') - ? (aps as FirstDisplayApsProtocolV1) - : undefined; - const renderStrategy = apsProtocol?.createRenderStrategy(renderOptions); - const renderer = renderOwner - ? (renderOwner as FirstDisplayRenderOwnerProtocolV1).createRenderBridge( - renderOptions, - renderStrategy - ) - : createNonRenderingBridge(renderOptions.now); - rendererOwner.value = renderer; - const driver = createFirstDisplayProjectedDriver({ - batch, + options.gptInput[3], + ]); + const apsProtocol = fullProtocolIdentity(aps, 'aps') + ? (aps as FirstDisplayApsProtocolV1) + : undefined; + const renderStrategy = apsProtocol?.[2].createRenderStrategy(renderOptions); + const renderer = renderOwner + ? (renderOwner as FirstDisplayRenderOwnerProtocolV1)[2](renderOptions, renderStrategy) + : undefined; + rendererOwner.value = renderer; + agent = createFirstDisplayAgent({ + ...options, + mutationDocument: options.gptInput[2], + parserState: snapshotParserState, + production: { ...(gpt ? { gpt: gpt as FirstDisplayGptProtocolV1 } : {}), - gptInput: { - ...options.gptInput, - onNativeMutation: () => agent?.observeNativeMutation() === true, - }, - renderer, - }); - const identityIssuer = createNavigationIdentityIssuerFromSource((target) => { - fillBrowserRandom(options.gptInput.browser, target); - return target; - }); - if (!identityIssuer.ok) { - throw new TypeError('tsjs'); - } - agent = createFirstDisplayAgent({ - ...options, - driver, - identityIssuer: identityIssuer.value, - mutationDocument: options.gptInput.document, - parserState: parserState.snapshot, - }); - if (!agent.coversProtocols(auctionProtocols)) { - throw new TypeError('tsjs'); - } - if (!agent.start()) throw new TypeError('tsjs'); - options.onAgentReady?.(agent); + gptInput: Object.freeze([ + options.gptInput[0], + options.gptInput[1], + options.gptInput[2], + options.gptInput[3], + options.gptInput[4], + () => agent?.observeNativeMutation() === true, + ]), + ...(renderer ? { renderer } : {}), + }, }); - }, - sliceHost, - }); - } catch { - throw new TypeError('tsjs'); - } + if (!agent.start()) throw new TypeError('tsjs'); + options.onAgentReady?.(agent); + }); + }, + sliceHost, + }); } - -registerCurrentFirstDisplayComponent('first_display', 1, prepareRegisteredAgent); diff --git a/crates/trusted-server-js/lib/src/first_display/base_marker.ts b/crates/trusted-server-js/lib/src/first_display/base_marker.ts new file mode 100644 index 000000000..df5dc6ddf --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/base_marker.ts @@ -0,0 +1 @@ +// The logical first-display base is physically co-bundled with the parser-inline bootstrap. diff --git a/crates/trusted-server-js/lib/src/first_display/composition.ts b/crates/trusted-server-js/lib/src/first_display/composition.ts index 53e0c71fc..830c80e36 100644 --- a/crates/trusted-server-js/lib/src/first_display/composition.ts +++ b/crates/trusted-server-js/lib/src/first_display/composition.ts @@ -1,19 +1,73 @@ import type { FirstDisplaySliceId } from '../kernel/release_catalog'; -import { APS_INITIAL_SLICE } from './slices/aps'; -import { CREATIVE_INITIAL_SLICE } from './slices/creative'; -import { DATADOME_INITIAL_SLICE } from './slices/datadome'; -import { DIDOMI_INITIAL_SLICE } from './slices/didomi'; +import { installApsInitial } from './leaf/aps_protocol'; +import { installTestlightInitial } from './leaf/callback_capture'; +import { installDidomiInitial } from './leaf/config_guard'; +import { installOsanoInitial, installSourcepointInitial } from './leaf/consent_snapshot'; +import { installPermutiveInitial } from './leaf/context_snapshot'; +import { installCreativeInitial } from './leaf/creative_guard'; +import { installPrebidInitial } from './leaf/prebid_protocol'; +import { + installDataDomeInitial, + installGoogleTagManagerInitial, + installLockrInitial, +} from './leaf/route_guard'; +import { installRenderOwnerInitial } from './render_journal'; import type { InitialSliceDefinition } from './slices/definition'; -import { GOOGLE_TAG_MANAGER_INITIAL_SLICE } from './slices/google_tag_manager'; -import { GPT_INITIAL_SLICE } from './slices/gpt'; -import { LOCKR_INITIAL_SLICE } from './slices/lockr'; -import { OSANO_INITIAL_SLICE } from './slices/osano'; -import { PERMUTIVE_INITIAL_SLICE } from './slices/permutive'; -import { PREBID_INITIAL_SLICE } from './slices/prebid'; -import { RENDER_OWNER_INITIAL_SLICE } from './slices/render_owner'; -import { SOURCEPOINT_INITIAL_SLICE } from './slices/sourcepoint'; -import { TESTLIGHT_INITIAL_SLICE } from './slices/testlight'; +import { installGptInitialSlice } from './slices/gpt'; + +export const RENDER_OWNER_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'render_owner_initial', + install: installRenderOwnerInitial, +}); +export const APS_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'aps_initial', + install: installApsInitial, +}); +export const CREATIVE_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'creative_initial', + install: installCreativeInitial, +}); +export const DATADOME_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'datadome_initial', + install: installDataDomeInitial, +}); +export const DIDOMI_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'didomi_initial', + install: installDidomiInitial, +}); +export const GOOGLE_TAG_MANAGER_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'google_tag_manager_initial', + install: installGoogleTagManagerInitial, +}); +export const GPT_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'gpt_initial', + install: installGptInitialSlice, +}); +export const LOCKR_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'lockr_initial', + install: installLockrInitial, +}); +export const OSANO_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'osano_initial', + install: installOsanoInitial, +}); +export const PERMUTIVE_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'permutive_initial', + install: installPermutiveInitial, +}); +export const SOURCEPOINT_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'sourcepoint_initial', + install: installSourcepointInitial, +}); +export const PREBID_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'prebid_initial', + install: installPrebidInitial, +}); +export const TESTLIGHT_INITIAL_SLICE: InitialSliceDefinition = Object.freeze({ + id: 'testlight_initial', + install: installTestlightInitial, +}); export const INITIAL_SLICE_DEFINITIONS: readonly InitialSliceDefinition[] = Object.freeze([ RENDER_OWNER_INITIAL_SLICE, diff --git a/crates/trusted-server-js/lib/src/first_display/driver.ts b/crates/trusted-server-js/lib/src/first_display/driver.ts index d1d0a57d6..c1e654074 100644 --- a/crates/trusted-server-js/lib/src/first_display/driver.ts +++ b/crates/trusted-server-js/lib/src/first_display/driver.ts @@ -24,11 +24,47 @@ export interface FirstDisplayRenderBridgeV1 { readonly sweepCommittedArtifacts: () => number; readonly sealTsAdmission: () => void; readonly closeIngress: () => boolean; - readonly captureHandoff: () => FirstDisplayRenderHandoffV1 | undefined; + readonly captureHandoff: () => FirstDisplayRenderCaptureV1 | undefined; readonly detachCommittedArtifacts: () => boolean; readonly dispose: () => void; } +/** Compact authenticated capability passed from the render slice to the inline owner. */ +export type FirstDisplayRenderBridgeCapabilityV1 = readonly [ + bind: FirstDisplayRenderBridgeV1['bind'], + recordGam: FirstDisplayRenderBridgeV1['recordGam'], + recordFailure: FirstDisplayRenderBridgeV1['recordFailure'], + retire: FirstDisplayRenderBridgeV1['retire'], + sweepCommittedArtifacts: FirstDisplayRenderBridgeV1['sweepCommittedArtifacts'], + sealTsAdmission: FirstDisplayRenderBridgeV1['sealTsAdmission'], + closeIngress: FirstDisplayRenderBridgeV1['closeIngress'], + captureHandoff: () => FirstDisplayRenderCaptureV1 | undefined, + detachCommittedArtifacts: FirstDisplayRenderBridgeV1['detachCommittedArtifacts'], + dispose: FirstDisplayRenderBridgeV1['dispose'], +]; + +/** Compact cross-artifact render state; live identity remains tuple-local until takeover. */ +export type FirstDisplayRenderCaptureV1 = readonly [ + artifacts: readonly (readonly [ + hostPosition: string | null, + hostPositionPriority: string | null, + identity: object, + kind: 'gpt_adm' | 'aps', + owner: 'trusted_server' | 'publisher', + slotId: string, + token: string, + ])[], + tombstones: readonly (readonly [ + kind: 'reservation' | 'ticket', + value: string, + expiresAtMs: number, + ordinal: number, + ])[], + clockEpochMs: number, + nextReservationOrdinal: number, + nextTicketOrdinal: number, +]; + export interface FirstDisplayRenderHandoffArtifactV1 { readonly hostPosition: string | null; readonly hostPositionPriority: string | null; @@ -55,7 +91,14 @@ export interface FirstDisplayRenderHandoffV1 { export interface FirstDisplayProjectedDriverOptionsV1 { readonly batch: FirstDisplayBatchV1; readonly gpt?: FirstDisplayGptProtocolV1; - readonly gptInput: Omit; + readonly gptInput: readonly [ + browser: FirstDisplayGoogletagBatchInput[0], + clearTimer: FirstDisplayGoogletagBatchInput[1], + document: FirstDisplayGoogletagBatchInput[2], + setTimer: FirstDisplayGoogletagBatchInput[3], + diagnosticsActive?: FirstDisplayGoogletagBatchInput[5], + onNativeMutation?: FirstDisplayGoogletagBatchInput[6], + ]; readonly renderer: FirstDisplayRenderBridgeV1; } @@ -64,14 +107,14 @@ function sameCycle( candidate: FirstDisplayGptBoundCycleV1 ): boolean { return ( - candidate.slotId === expected.slotId && - candidate.bid === expected.bid && - candidate.element === expected.element && - candidate.isCurrent === expected.isCurrent && - candidate.placement === expected.placement && - candidate.physicalSlot === expected.physicalSlot && - candidate.ownership === expected.ownership && - candidate.traceToken === expected.traceToken + candidate[6] === expected[6] && + candidate[0] === expected[0] && + candidate[1] === expected[1] && + candidate[2] === expected[2] && + candidate[5] === expected[5] && + candidate[4] === expected[4] && + candidate[3] === expected[3] && + candidate[7] === expected[7] ); } @@ -87,10 +130,17 @@ export function createFirstDisplayProjectedDriver( const gptBatch = expected.length === 0 ? undefined - : options.gpt?.createBatch({ - ...options.gptInput, - projection: options.batch.projection, - }); + : options.gpt?.[2]( + Object.freeze([ + options.gptInput[0], + options.gptInput[1], + options.gptInput[2], + options.gptInput[3], + options.batch.projection, + options.gptInput[4], + options.gptInput[5], + ]) + ); if (expected.length > 0 && !gptBatch) { throw new TypeError('tsjs'); } @@ -142,55 +192,53 @@ export function createFirstDisplayProjectedDriver( } started = true; onTerminal = terminal; - const accepted = gptBatch?.start({ - onBound: (cycle): void => { - const action = expectedBySlot.get(cycle.slotId); - const bidIndex = options.batch.projection.bids.indexOf(cycle.bid); + const accepted = gptBatch?.[0]([ + (cycle): void => { + const action = expectedBySlot.get(cycle[6]); + const bidIndex = options.batch.projection.bids.indexOf(cycle[0]); const expectedPlacement = options.batch.projection.slots.find( - ({ slot }) => slot === cycle.slotId + ({ slot }) => slot === cycle[6] ); if ( !action || - bound.has(cycle.slotId) || + bound.has(cycle[6]) || bidIndex < 0 || - options.batch.projection.bids[bidIndex]?.slot !== cycle.slotId || - cycle.placement !== expectedPlacement + options.batch.projection.bids[bidIndex]?.slot !== cycle[6] || + cycle[5] !== expectedPlacement ) { - settle(cycle.slotId, 'failed', 'gpt_request_failed'); + settle(cycle[6], 'failed', 'gpt_request_failed'); return; } - bound.set(cycle.slotId, cycle); - if ( - !options.renderer.bind(cycle, (result, reason) => settle(cycle.slotId, result, reason)) - ) { - settle(cycle.slotId, 'failed', 'internal_error'); + bound.set(cycle[6], cycle); + if (!options.renderer.bind(cycle, (result, reason) => settle(cycle[6], result, reason))) { + settle(cycle[6], 'failed', 'internal_error'); } }, - onFailure: (slotId, reason): void => { + (slotId, reason): void => { const cycle = bound.get(slotId); if (cycle) options.renderer.recordFailure(cycle); settle(slotId, 'failed', reason); }, - onFirstAction: (): boolean => { + (): boolean => { if (actionStarted || disposed) return false; actionStarted = true; return onFirstAction(); }, - onRenderEnded: (cycle, result): void => { - const exact = bound.get(cycle.slotId); + (cycle, result): void => { + const exact = bound.get(cycle[6]); if (!exact || !sameCycle(exact, cycle)) { - settle(cycle.slotId, 'failed', 'gpt_request_failed'); + settle(cycle[6], 'failed', 'gpt_request_failed'); return; } if (!options.renderer.recordGam(exact, result)) { - settle(cycle.slotId, 'failed', 'gpt_request_failed'); + settle(cycle[6], 'failed', 'gpt_request_failed'); } }, - onRetire: (cycle): void => { - const exact = bound.get(cycle.slotId); + (cycle): void => { + const exact = bound.get(cycle[6]); if (exact && sameCycle(exact, cycle)) options.renderer.retire(exact); }, - }); + ]); if (accepted !== true) throw new TypeError('tsjs'); }, sealTsAdmission: (): void => { @@ -207,23 +255,16 @@ export function createFirstDisplayProjectedDriver( const acceptedIds = [...settledResults.entries()] .filter(([, result]) => result === 'accepted') .map(([slotId]) => slotId); - if (gptBatch && !gptBatch.closeIngress(acceptedIds)) return false; + if (gptBatch && !gptBatch[1](acceptedIds)) return false; if (!options.renderer.closeIngress()) return false; ingressClosed = true; return true; }, captureHandoff: () => { if (disposed || !ingressClosed || handoffCaptured) return undefined; - const gptCycles = gptBatch?.captureHandoff() ?? Object.freeze([]); + const gptCycles = gptBatch?.[2]() ?? Object.freeze([]); const gptDiagnostics = - gptBatch?.captureDiagnosticsHandoff() ?? - Object.freeze({ - cycles: Object.freeze([]), - facts: Object.freeze([]), - nextTraceTokenOrdinal: 1, - overflowCount: 0, - dropCount: 0, - }); + gptBatch?.[3]() ?? Object.freeze([Object.freeze([]), Object.freeze([]), 1, 0, 0] as const); const render = options.renderer.captureHandoff(); if (!render) return undefined; const acceptedIds = new Set( @@ -231,42 +272,81 @@ export function createFirstDisplayProjectedDriver( .filter(([, result]) => result === 'accepted') .map(([slotId]) => slotId) ); - const cycles = gptCycles.filter(({ slotId }) => acceptedIds.has(slotId)); - const diagnosticCycles = gptDiagnostics.cycles.filter(({ slotId }) => - acceptedIds.has(slotId) - ); + const cycles = gptCycles.flatMap((captured) => { + const cycle = bound.get(captured[0]); + return cycle && + acceptedIds.has(captured[0]) && + cycle[1].id === captured[1] && + cycle[3] === captured[2] && + cycle[7] === captured[4] && + cycle[4] === captured[5] + ? [Object.freeze([...cycle, captured[3]] as const)] + : []; + }); + const diagnosticCycles = gptDiagnostics[0].filter((cycle) => acceptedIds.has(cycle[0])); if ( cycles.length !== acceptedIds.size || diagnosticCycles.length !== cycles.length || diagnosticCycles.some((cycle) => { - const physical = cycles.find(({ slotId }) => slotId === cycle.slotId); - return !physical || physical.traceToken !== cycle.token; + const physical = cycles.find((candidate) => candidate[6] === cycle[0]); + return !physical || physical[7] !== cycle[1]; }) || - render.artifacts.some(({ slotId }) => !acceptedIds.has(slotId)) + render[0].some((artifact) => !acceptedIds.has(artifact[5])) ) { return undefined; } const identities = [ - ...cycles.map(({ physicalSlot }) => physicalSlot), - ...render.artifacts.map(({ identity }) => identity), + ...cycles.map((cycle) => cycle[4]), + ...render[0].map((artifact) => artifact[2]), ]; if (new Set(identities).size !== identities.length) return undefined; handoffCaptured = true; return Object.freeze({ - artifacts: render.artifacts, - clockEpochMs: render.clockEpochMs, + artifacts: Object.freeze( + render[0].map((artifact) => ({ + hostPosition: artifact[0], + hostPositionPriority: artifact[1], + identity: artifact[2], + kind: artifact[3], + owner: artifact[4], + slotId: artifact[5], + token: artifact[6], + })) + ), + clockEpochMs: render[2], cycles: Object.freeze(cycles), - diagnosticCycles: Object.freeze(diagnosticCycles), + diagnosticCycles: Object.freeze( + diagnosticCycles.map((cycle) => ({ + slotId: cycle[0], + token: cycle[1], + nextCycleOrdinal: cycle[2], + unknownPriorCycle: cycle[3], + quarantines: cycle[4], + records: cycle[5].map((record) => ({ + ordinal: record[0], + responseIdentifier: record[1], + seen: record[2], + state: record[3], + })), + })) + ), gptDiagnostics: Object.freeze({ - facts: Object.freeze([...gptDiagnostics.facts]), - overflowCount: gptDiagnostics.overflowCount, - dropCount: gptDiagnostics.dropCount, + facts: Object.freeze([...gptDiagnostics[1]]), + overflowCount: gptDiagnostics[3], + dropCount: gptDiagnostics[4], }), identities: Object.freeze(identities), - nextTraceTokenOrdinal: gptDiagnostics.nextTraceTokenOrdinal, - nextReservationOrdinal: render.nextReservationOrdinal, - nextTicketOrdinal: render.nextTicketOrdinal, - tombstones: render.tombstones, + nextTraceTokenOrdinal: gptDiagnostics[2], + nextReservationOrdinal: render[3], + nextTicketOrdinal: render[4], + tombstones: Object.freeze( + render[1].map((entry) => ({ + kind: entry[0], + value: entry[1], + expiresAtMs: entry[2], + ordinal: entry[3], + })) + ), }); }, detachCommittedArtifacts: (): boolean => { @@ -276,7 +356,7 @@ export function createFirstDisplayProjectedDriver( const acceptedIds = [...settledResults.entries()] .filter(([, result]) => result === 'accepted') .map(([slotId]) => slotId); - if (gptBatch && !gptBatch.detachCommittedSlots(acceptedIds)) return false; + if (gptBatch && !gptBatch[4](acceptedIds)) return false; if (!options.renderer.detachCommittedArtifacts()) return false; committedArtifactsDetached = true; return true; @@ -284,7 +364,7 @@ export function createFirstDisplayProjectedDriver( dispose: (): void => { if (disposed) return; disposed = true; - gptBatch?.dispose(); + gptBatch?.[5](); options.renderer.dispose(); bound.clear(); settledResults.clear(); diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts index d7ef24bc3..ab2e060d2 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/aps_protocol.ts @@ -27,7 +27,7 @@ export interface FirstDisplayApsBootstrapPolicyV2 { readonly tagType: 'iframe' | 'script'; } -export interface FirstDisplayApsProtocolV1 { +export interface FirstDisplayApsPolicyV1 { readonly version: 1; readonly id: 'aps'; readonly publisherOrigin: string; @@ -53,6 +53,12 @@ export interface FirstDisplayApsProtocolV1 { ) => FirstDisplayRenderStrategyV1; } +export type FirstDisplayApsProtocolV1 = readonly [ + version: 1, + id: 'aps', + policy: FirstDisplayApsPolicyV1, +]; + interface ApsInitialBindings { readonly observe: (name: 'protocol_version', value: number) => void; readonly publisherOrigin: string; @@ -279,11 +285,12 @@ function bootstrapPolicy( export function installApsInitial( candidate: unknown, own: FirstDisplaySliceActivationContext['own'] -): Readonly<{ version: 1; id: 'aps' }> { +): readonly [version: 1, id: 'aps'] { const value = bindings(candidate); if (!value || typeof own !== 'function') throw new TypeError('tsjs'); - const rendererUrl = new URL('/integrations/aps/renderer/v2', value.publisherOrigin).href; - const protocol: FirstDisplayApsProtocolV1 = Object.freeze({ + const publisherOrigin = value['publisherOrigin']; + const rendererUrl = new URL('/integrations/aps/renderer/v2', publisherOrigin).href; + const policy: FirstDisplayApsPolicyV1 = Object.freeze({ version: 1, id: 'aps', publisherOrigin: value.publisherOrigin, @@ -300,11 +307,12 @@ export function installApsInitial( parseDocumentMessage, parseWindowMessage, createRenderStrategy: (options: FirstDisplayRenderOwnerOptionsV1) => - createFirstDisplayApsRenderStrategy(options, protocol), + createFirstDisplayApsRenderStrategy(options, policy), }); + const protocol: FirstDisplayApsProtocolV1 = Object.freeze([1, 'aps', policy]); const release = value.register(protocol); if (typeof release !== 'function') throw new TypeError('tsjs'); own(release); value.observe('protocol_version', 1); - return Object.freeze({ version: 1, id: 'aps' }); + return Object.freeze([1, 'aps']); } diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/browser_route_owner.ts b/crates/trusted-server-js/lib/src/first_display/leaf/browser_route_owner.ts new file mode 100644 index 000000000..c542417c0 --- /dev/null +++ b/crates/trusted-server-js/lib/src/first_display/leaf/browser_route_owner.ts @@ -0,0 +1,112 @@ +type BrowserRouteKind = 'script' | 'preload' | 'prefetch' | 'beacon' | 'fetch'; + +export interface FirstDisplayBrowserRouteRuleV1 { + readonly matches: (kind: BrowserRouteKind, url: string) => boolean; + readonly rewrite: (url: string) => string; +} + +function route(rule: FirstDisplayBrowserRouteRuleV1, kind: BrowserRouteKind, url: string): string { + try { + return rule.matches(kind, url) ? rule.rewrite(url) : url; + } catch { + return url; + } +} + +/** Own one compact parser-time route interceptor until takeover or rollback. */ +export function registerFirstDisplayBrowserRoute( + rule: FirstDisplayBrowserRouteRuleV1, + network = false +): () => void { + const prototype = Element.prototype; + const appendChild = prototype.appendChild; + const insertBefore = prototype.insertBefore; + const rewriteNode = (node: Node): void => { + if (node.nodeType !== Node.ELEMENT_NODE) return; + const element = node as Element; + if (element.tagName === 'SCRIPT') { + const script = element as HTMLScriptElement; + const source = script.getAttribute('src') ?? script.src; + const rewritten = source && route(rule, 'script', source); + if (rewritten && rewritten !== source) script.src = rewritten; + return; + } + if (element.tagName !== 'LINK') return; + const link = element as HTMLLinkElement; + const rel = link.getAttribute('rel'); + if ((rel !== 'preload' && rel !== 'prefetch') || link.getAttribute('as') !== 'script') return; + const source = link.getAttribute('href') ?? link.href; + const rewritten = source && route(rule, rel, source); + if (rewritten && rewritten !== source) link.href = rewritten; + }; + const appendWrapper: typeof prototype.appendChild = function ( + this: Element, + node: T + ): T { + rewriteNode(node); + return Reflect.apply(appendChild, this, [node]) as T; + }; + const insertWrapper: typeof prototype.insertBefore = function ( + this: Element, + node: T, + child: Node | null + ): T { + rewriteNode(node); + return Reflect.apply(insertBefore, this, [node, child]) as T; + }; + prototype.appendChild = appendWrapper; + prototype.insertBefore = insertWrapper; + + const Observer = document.defaultView?.MutationObserver; + const observer = Observer + ? new Observer((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + rewriteNode(node); + if (node.nodeType === Node.ELEMENT_NODE) { + for (const nested of (node as Element).querySelectorAll('script[src],link[href]')) { + rewriteNode(nested); + } + } + } + } + }) + : undefined; + observer?.observe(document.documentElement, { childList: true, subtree: true }); + + const navigator = window.navigator; + const sendBeacon = navigator.sendBeacon; + const fetch = window.fetch; + const beaconWrapper = function (url: string | URL, data?: BodyInit | null): boolean { + return Reflect.apply(sendBeacon, navigator, [route(rule, 'beacon', String(url)), data]); + }; + const fetchWrapper = function (input: RequestInfo | URL, init?: RequestInit): Promise { + let source: string | undefined; + if (typeof input === 'string' || input instanceof URL) source = String(input); + else if (typeof Request !== 'undefined' && input instanceof Request) source = input.url; + const rewritten = source && route(rule, 'fetch', source); + const request = + rewritten && + rewritten !== source && + typeof Request !== 'undefined' && + input instanceof Request + ? new Request(rewritten, input) + : (rewritten ?? input); + return Reflect.apply(fetch, window, [request, init]); + }; + if (network) { + if (typeof sendBeacon === 'function') navigator.sendBeacon = beaconWrapper; + if (typeof fetch === 'function') window.fetch = fetchWrapper; + } + + let active = true; + return (): void => { + if (!active) return; + active = false; + observer?.disconnect(); + if (prototype.appendChild === appendWrapper) prototype.appendChild = appendChild; + if (prototype.insertBefore === insertWrapper) prototype.insertBefore = insertBefore; + if (network && navigator.sendBeacon === beaconWrapper) navigator.sendBeacon = sendBeacon; + if (network && window.fetch === fetchWrapper) window.fetch = fetch; + }; +} diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/creative_guard.ts b/crates/trusted-server-js/lib/src/first_display/leaf/creative_guard.ts index 23e9fac9b..f636fce68 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/creative_guard.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/creative_guard.ts @@ -10,12 +10,6 @@ export interface FirstDisplayCreativeGuardV1 { } interface CreativeInitialBindings { - readonly config: Readonly<{ - version: 1; - enabled: true; - clickGuard: boolean; - renderGuard: boolean; - }>; readonly location: Readonly<{ href: string; origin: string }>; readonly observe: (name: 'guard_count', value: number) => void; readonly register: (guard: FirstDisplayCreativeGuardV1) => () => void; @@ -51,45 +45,24 @@ function exactRecord(value: unknown, keys: readonly string[]): Record + | undefined { + const config = exactRecord(candidate, ['version', 'enabled', 'clickGuard', 'renderGuard']); if ( - !fields || !config || config.version !== 1 || config.enabled !== true || typeof config.clickGuard !== 'boolean' || typeof config.renderGuard !== 'boolean' || - (!config.clickGuard && !config.renderGuard) || - !location || - typeof location.href !== 'string' || - typeof location.origin !== 'string' || - typeof fields.observe !== 'function' || - typeof fields.register !== 'function' + (!config.clickGuard && !config.renderGuard) ) { return undefined; } - try { - const href = new URL(location.href); - if (href.origin !== location.origin || !['http:', 'https:'].includes(href.protocol)) { - return undefined; - } - } catch { - return undefined; - } - return { - config: Object.freeze({ - version: 1, - enabled: true, - clickGuard: config.clickGuard, - renderGuard: config.renderGuard, - }), - location: Object.freeze({ href: location.href, origin: location.origin }), - observe: fields.observe as CreativeInitialBindings['observe'], - register: fields.register as CreativeInitialBindings['register'], - }; + return Object.freeze({ clickGuard: config.clickGuard, renderGuard: config.renderGuard }); } function normalizeNavigation(raw: string, base: string): string | undefined { @@ -123,17 +96,20 @@ function shouldProxyResource(raw: string, href: string, origin: string): boolean /** Register the selected parser-time creative policy with one generic provisional guard owner. */ export function installCreativeInitial( candidate: unknown, - own: FirstDisplaySliceActivationContext['own'] + own: FirstDisplaySliceActivationContext['own'], + configCandidate: unknown ): void { - const bindings = snapshotBindings(candidate); - if (!bindings || typeof own !== 'function') { + // The authenticated bootstrap owns bindings; only server-carried config is untrusted here. + const bindings = candidate as CreativeInitialBindings; + const config = snapshotConfig(configCandidate); + if (!config || typeof own !== 'function') { throw new TypeError('tsjs'); } const guard: FirstDisplayCreativeGuardV1 = Object.freeze({ version: 1, id: 'creative', - clickGuard: bindings.config.clickGuard, - renderGuard: bindings.config.renderGuard, + clickGuard: config.clickGuard, + renderGuard: config.renderGuard, normalizeNavigation: (raw: string) => normalizeNavigation(raw, bindings.location.href), shouldProxyResource: (raw: string) => shouldProxyResource(raw, bindings.location.href, bindings.location.origin), @@ -141,8 +117,5 @@ export function installCreativeInitial( const release = bindings.register(guard); if (typeof release !== 'function') throw new TypeError('tsjs'); own(release); - bindings.observe( - 'guard_count', - (bindings.config.clickGuard ? 1 : 0) + (bindings.config.renderGuard ? 2 : 0) - ); + bindings.observe('guard_count', (config.clickGuard ? 1 : 0) + (config.renderGuard ? 2 : 0)); } diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts index c96cdafaa..50a3c7b2c 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/gpt_protocol.ts @@ -1,41 +1,58 @@ import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; import type { FirstDisplayGoogletagBatch, + FirstDisplayGoogletagBatchCallbacks, FirstDisplayGoogletagBatchInput, + FirstDisplayGptCaptureCycleV1, + FirstDisplayGptDiagnosticsHandoffV1, } from '../adapters/googletag'; +import { enqueueFirstDisplayGamAttribution } from '../adapters/googletag'; + +export type { FirstDisplayGptCaptureCycleV1 } from '../adapters/googletag'; export interface FirstDisplayGptRequestPlanV1 { readonly operations: readonly ('display' | 'refresh')[]; readonly requestOperation: 0 | 1; } -export interface FirstDisplayGptProtocolV1 { - readonly version: 1; - readonly id: 'gpt'; +export type FirstDisplayGptProtocolV1 = readonly [ + version: 1, + id: 'gpt', + createBatch: (input: FirstDisplayGoogletagBatchInput) => FirstDisplayGptCapabilityV1, +]; + +export type FirstDisplayGptDiagnosticsCaptureV1 = FirstDisplayGptDiagnosticsHandoffV1; + +export type FirstDisplayGptCapabilityV1 = readonly [ + start: (callbacks: FirstDisplayGoogletagBatchCallbacks) => boolean, + closeIngress: (committedSlotIds: readonly string[]) => boolean, + captureHandoff: () => readonly FirstDisplayGptCaptureCycleV1[] | undefined, + captureDiagnosticsHandoff: () => FirstDisplayGptDiagnosticsCaptureV1 | undefined, + detachCommittedSlots: (slotIds: readonly string[]) => boolean, + dispose: () => void, +]; + +export interface FirstDisplayGptBatchPolicyV1 { readonly deadlines: Readonly<{ externalReadyMs: 10_000; requestStartMs: 3_000; completionMs: 10_000; }>; - readonly createBatch: (input: FirstDisplayGoogletagBatchInput) => FirstDisplayGoogletagBatch; readonly requestPlan: (candidate: unknown) => FirstDisplayGptRequestPlanV1 | undefined; - readonly validTargetingValue: (candidate: unknown) => candidate is string; readonly classifyRenderEnded: (candidate: unknown) => 'gam_empty' | 'nonempty_gam' | undefined; } interface GptInitialBindings { - readonly gam: () => boolean; + readonly browser: Window & { googletag?: unknown }; readonly observe: (name: 'gam' | 'v', value: boolean | number) => void; readonly register: (protocol: FirstDisplayGptProtocolV1) => () => void; } export type FirstDisplayGptBatchFactoryV1 = ( input: FirstDisplayGoogletagBatchInput, - protocol: FirstDisplayGptProtocolV1 + policy: FirstDisplayGptBatchPolicyV1 ) => FirstDisplayGoogletagBatch; -const textEncoder = new TextEncoder(); - function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { try { if ( @@ -65,29 +82,6 @@ function exactRecord(value: unknown, keys: readonly string[]): Record= 0xd800 && code <= 0xdbff) { - const next = candidate.charCodeAt(index + 1); - if (!(next >= 0xdc00 && next <= 0xdfff)) return false; - index += 1; - } else if (code >= 0xdc00 && code <= 0xdfff) { - return false; - } - scalars += 1; - if (scalars > 40) return false; - } - return textEncoder.encode(candidate).byteLength <= 160; -} - function classifyRenderEnded(candidate: unknown): 'gam_empty' | 'nonempty_gam' | undefined { const fields = exactRecord(candidate, ['isEmpty']); if (!fields || typeof fields.isEmpty !== 'boolean') return undefined; @@ -141,38 +116,50 @@ export function installGptInitial( own: FirstDisplaySliceActivationContext['own'], createBatch: FirstDisplayGptBatchFactoryV1, configCandidate: unknown -): Readonly<{ version: 1; id: 'gpt' }> { - const value = bindings(candidate); +): readonly [version: 1, id: 'gpt'] { + // The authenticated bootstrap is the sole caller and owns this capability object. + const value = candidate as GptInitialBindings; const gamAttributionEnabled = ( configCandidate as Readonly<{ gamAttributionEnabled?: unknown }> | undefined )?.gamAttributionEnabled; if ( - !value || typeof own !== 'function' || typeof createBatch !== 'function' || typeof gamAttributionEnabled !== 'boolean' ) { throw new TypeError('tsjs'); } - const protocol: FirstDisplayGptProtocolV1 = Object.freeze({ - version: 1, - id: 'gpt', + const policy: FirstDisplayGptBatchPolicyV1 = Object.freeze({ deadlines: Object.freeze({ externalReadyMs: 10_000, requestStartMs: 3_000, completionMs: 10_000, }), - createBatch: (input: FirstDisplayGoogletagBatchInput): FirstDisplayGoogletagBatch => - createBatch(input, protocol), requestPlan, - validTargetingValue, classifyRenderEnded, }); + const protocol: FirstDisplayGptProtocolV1 = Object.freeze([ + 1, + 'gpt', + (input: FirstDisplayGoogletagBatchInput): FirstDisplayGptCapabilityV1 => { + const batch = createBatch(input, policy); + return Object.freeze([ + batch.start, + batch.closeIngress, + batch.captureHandoff, + batch.captureDiagnosticsHandoff, + batch.detachCommittedSlots, + batch.dispose, + ]); + }, + ]); const release = value.register(protocol); if (typeof release !== 'function') throw new TypeError('tsjs'); own(release); value.observe('gam', gamAttributionEnabled); value.observe('v', 1); - if (gamAttributionEnabled && !value.gam()) throw new TypeError('tsjs'); - return Object.freeze({ version: 1, id: 'gpt' }); + if (gamAttributionEnabled && !enqueueFirstDisplayGamAttribution(value.browser)) { + throw new TypeError('tsjs'); + } + return Object.freeze([1, 'gpt']); } diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/prebid_protocol.ts b/crates/trusted-server-js/lib/src/first_display/leaf/prebid_protocol.ts index 88233c757..55c4a64bb 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/prebid_protocol.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/prebid_protocol.ts @@ -24,7 +24,7 @@ export interface FirstDisplayPreparedTrustedBidV1 { }>; } -export interface FirstDisplayPrebidProtocolV1 { +export interface FirstDisplayPrebidPolicyV1 { readonly version: 1; readonly id: 'prebid'; readonly bidderCode: 'trustedServer'; @@ -36,6 +36,12 @@ export interface FirstDisplayPrebidProtocolV1 { readonly snapshotTrustedBid: (candidate: unknown) => FirstDisplayPreparedTrustedBidV1 | undefined; } +export type FirstDisplayPrebidProtocolV1 = readonly [ + version: 1, + id: 'prebid', + policy: FirstDisplayPrebidPolicyV1, +]; + interface PrebidInitialBindings { readonly observe: (name: 'protocol_version', value: number) => void; readonly register: (protocol: FirstDisplayPrebidProtocolV1) => () => void; @@ -225,10 +231,10 @@ function snapshotTrustedBid(candidate: unknown): FirstDisplayPreparedTrustedBidV export function installPrebidInitial( candidate: unknown, own: FirstDisplaySliceActivationContext['own'] -): Readonly<{ version: 1; id: 'prebid' }> { +): readonly [version: 1, id: 'prebid'] { const value = bindings(candidate); if (!value || typeof own !== 'function') throw new TypeError('tsjs'); - const protocol: FirstDisplayPrebidProtocolV1 = Object.freeze({ + const policy: FirstDisplayPrebidPolicyV1 = Object.freeze({ version: 1, id: 'prebid', bidderCode: 'trustedServer', @@ -239,9 +245,10 @@ export function installPrebidInitial( normalizeEidSource, snapshotTrustedBid, }); + const protocol: FirstDisplayPrebidProtocolV1 = Object.freeze([1, 'prebid', policy]); const release = value.register(protocol); if (typeof release !== 'function') throw new TypeError('tsjs'); own(release); value.observe('protocol_version', 1); - return Object.freeze({ version: 1, id: 'prebid' }); + return Object.freeze([1, 'prebid']); } diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/projection.ts b/crates/trusted-server-js/lib/src/first_display/leaf/projection.ts index fcd289eda..66a319817 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/projection.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/projection.ts @@ -96,6 +96,14 @@ export interface FirstDisplayBatchV1 { readonly projection: FirstDisplayProjectionV1; } +/** Closure-compact batch retained by the bootstrap-owned first-display agent. */ +export type FirstDisplayAgentBatchV1 = readonly [ + projectionDigest: string, + requiredProtocols: readonly FirstDisplayAuctionProtocolId[], + outcomes: readonly (readonly [slotId: string, kind: FirstDisplayProjectedKind])[], + projection: FirstDisplayProjectionV1, +]; + /** * Admit the immutable same-script server projection without cloning it again. * @@ -104,7 +112,7 @@ export interface FirstDisplayBatchV1 { */ export function acceptServerFirstDisplayBatchV1( candidate: unknown -): FirstDisplayBatchV1 | undefined { +): FirstDisplayAgentBatchV1 | undefined { try { const envelope = candidate as Readonly<{ version: unknown; @@ -114,25 +122,11 @@ export function acceptServerFirstDisplayBatchV1( const projection = envelope.projection; const results = projection.auction.results; const { slots, bids } = projection; - if ( - !Object.isFrozen(candidate) || - envelope.version !== 1 || - typeof envelope.projectionDigest !== 'string' || - !HASH.test(envelope.projectionDigest) || - !Object.isFrozen(projection) || - projection.version !== 1 || - projection.auction.version !== 1 || - !Object.isFrozen(results) || - results.length === 0 || - results.length > MAX_SLOTS || - !Object.isFrozen(slots) || - slots.length !== results.length || - !Object.isFrozen(bids) || - bids.length > results.length - ) { - return undefined; - } - const outcomes: FirstDisplayBatchOutcomeV1[] = []; + // The authenticated bootstrap constructed this envelope from the already + // bounded, recursively frozen server transport. This leaf only derives the + // compact action plan and rechecks the bid/result relationships it consumes. + if (!Object.isFrozen(candidate)) return undefined; + const outcomes: Array = []; let winner = 0; let aps = false; let prebid = false; @@ -142,7 +136,7 @@ export function acceptServerFirstDisplayBatchV1( if (decision.slot !== slot.slot) return undefined; if (decision.outcome !== 'winner') { if (decision.outcome !== 'no_bid' && decision.outcome !== 'failed') return undefined; - outcomes.push(Object.freeze({ slotId: decision.slot, kind: decision.outcome })); + outcomes.push(Object.freeze([decision.slot, decision.outcome] as const)); continue; } const bid = bids[winner++]; @@ -157,24 +151,20 @@ export function acceptServerFirstDisplayBatchV1( aps ||= bid.renderSource.type === 'aps'; prebid ||= bid.provider === 'prebid'; outcomes.push( - Object.freeze({ - slotId: decision.slot, - kind: bid.renderSource.type === 'aps' ? 'aps' : 'gpt_adm', - }) + Object.freeze([decision.slot, bid.renderSource.type === 'aps' ? 'aps' : 'gpt_adm']) ); } if (winner !== bids.length) return undefined; - return Object.freeze({ - version: 1, - projectionDigest: envelope.projectionDigest, - requiredProtocols: Object.freeze([ + return Object.freeze([ + envelope.projectionDigest as string, + Object.freeze([ ...(aps ? (['aps'] as const) : []), ...(winner ? (['gpt'] as const) : []), ...(prebid ? (['prebid'] as const) : []), ]), - outcomes: Object.freeze(outcomes), + Object.freeze(outcomes), projection, - }); + ]); } catch { return undefined; } diff --git a/crates/trusted-server-js/lib/src/first_display/registration_client.ts b/crates/trusted-server-js/lib/src/first_display/registration_client.ts index 41ea58d4f..a25afa239 100644 --- a/crates/trusted-server-js/lib/src/first_display/registration_client.ts +++ b/crates/trusted-server-js/lib/src/first_display/registration_client.ts @@ -5,8 +5,7 @@ import type { FirstDisplayComponentRegistrationV1 } from '../shared/first_displa /** Submit one immutable component record and the synchronous current-script identity. */ export function registerCurrentFirstDisplayComponent( id: string, - order: number, - prepare: FirstDisplayComponentRegistrationV1['prepare'] + install: FirstDisplayComponentRegistrationV1['install'] ): boolean { try { const browser = (globalThis as unknown as { window: Window }).window; @@ -14,13 +13,7 @@ export function registerCurrentFirstDisplayComponent( const sink = Object.getOwnPropertyDescriptor(target, '_registerFirstDisplay')?.value; return ( Reflect.apply(sink, target, [ - Object.freeze({ - abi: 1, - id, - releaseId: __TSJS_EMBEDDED_RELEASE_ID_V1__, - order, - prepare, - }), + Object.freeze([1, id, __TSJS_EMBEDDED_RELEASE_ID_V1__, install]), browser.document.currentScript, ]) === true ); diff --git a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts index 64a74e53f..4e2175267 100644 --- a/crates/trusted-server-js/lib/src/first_display/render_bridge.ts +++ b/crates/trusted-server-js/lib/src/first_display/render_bridge.ts @@ -1,5 +1,5 @@ import type { FirstDisplayGptBoundCycleV1 } from './adapters/googletag'; -import type { FirstDisplayApsProtocolV1 } from './leaf/aps_protocol'; +import type { FirstDisplayApsPolicyV1 } from './leaf/aps_protocol'; import type { FirstDisplayCommittedRenderArtifactV1, FirstDisplayRenderOwnerOptionsV1, @@ -182,7 +182,7 @@ function snapshotFrameAttributes(frame: HTMLIFrameElement): string | undefined { /** Create the APS-owned URL, nonce, document-port, and overlay strategy. */ export function createFirstDisplayApsRenderStrategy( options: FirstDisplayRenderOwnerOptionsV1, - aps: FirstDisplayApsProtocolV1 + aps: FirstDisplayApsPolicyV1 ): FirstDisplayRenderStrategyV1 { const attempts = new Set(); const bootstrapNonces = new Map(); @@ -191,7 +191,7 @@ export function createFirstDisplayApsRenderStrategy( let disposed = false; const messageEventPrototype = (() => { try { - const constructor = Reflect.get(options.browser, 'MessageEvent'); + const constructor = Reflect.get(options[0], 'MessageEvent'); const prototype = typeof constructor === 'function' ? Reflect.get(constructor, 'prototype') : undefined; return typeof prototype === 'object' && prototype !== null ? prototype : undefined; @@ -202,7 +202,7 @@ export function createFirstDisplayApsRenderStrategy( const notifyNativeMutation = (): void => { try { - options.onNativeMutation?.(); + options[6]?.(); } catch { // Observation cannot alter admitted APS state. } @@ -211,7 +211,7 @@ export function createFirstDisplayApsRenderStrategy( const clearOwnedTimer = (handle: unknown): void => { if (handle === undefined || !timers.delete(handle)) return; try { - options.clearTimer(handle); + options[1](handle); } catch { // Timer state is already detached. } @@ -222,7 +222,7 @@ export function createFirstDisplayApsRenderStrategy( let scheduling = true; let firedSynchronously = false; try { - handle = options.setTimer(() => { + handle = options[7](() => { if (scheduling) { firedSynchronously = true; return; @@ -237,7 +237,7 @@ export function createFirstDisplayApsRenderStrategy( if (handle === undefined) return undefined; if (firedSynchronously) { try { - options.clearTimer(handle); + options[1](handle); } catch { // Synchronous timers are refused regardless of cleanup outcome. } @@ -254,7 +254,7 @@ export function createFirstDisplayApsRenderStrategy( for (let draw = 0; draw < MAX_DRAWS; draw += 1) { const bytes = new Uint8Array(16); try { - options.fillRandom(bytes); + options[4](bytes); } catch { return undefined; } @@ -292,7 +292,7 @@ export function createFirstDisplayApsRenderStrategy( if (!attempt.hostPositionOwned) return; attempt.hostPositionOwned = false; try { - const style = attempt.cycle.element.style; + const style = attempt.cycle[1].style; if ( style.getPropertyValue('position') !== 'relative' || style.getPropertyPriority('position') !== '' @@ -313,9 +313,9 @@ export function createFirstDisplayApsRenderStrategy( const acquireHostPosition = (attempt: ApsAttempt): boolean => { if (!attempt.overlay) return true; try { - const host = attempt.cycle.element; + const host = attempt.cycle[1]; const browser = host.ownerDocument.defaultView; - if (!browser || !attempt.cycle.isCurrent()) return false; + if (!browser || !attempt.cycle[2]()) return false; if (browser.getComputedStyle(host).position !== 'static') return true; attempt.previousHostPosition = host.style.getPropertyValue('position'); attempt.previousHostPositionPriority = host.style.getPropertyPriority('position'); @@ -323,7 +323,7 @@ export function createFirstDisplayApsRenderStrategy( attempt.hostPositionOwned = host.style.getPropertyValue('position') === 'relative' && host.style.getPropertyPriority('position') === ''; - return attempt.hostPositionOwned && attempt.cycle.isCurrent(); + return attempt.hostPositionOwned && attempt.cycle[2](); } catch { return false; } @@ -333,9 +333,9 @@ export function createFirstDisplayApsRenderStrategy( try { return ( attempt.active && - attempt.cycle.isCurrent() && + attempt.cycle[2]() && attempt.frame.isConnected && - attempt.frame.parentNode === attempt.cycle.element && + attempt.frame.parentNode === attempt.cycle[1] && attempt.frame.contentWindow === attempt.bootstrapSource && attempt.frame.getAttribute('src') === `${aps.rendererUrl}#${attempt.bootstrapNonceInternal}` && @@ -343,8 +343,8 @@ export function createFirstDisplayApsRenderStrategy( attempt.frame.getAttribute('sandbox') === (permanent ? aps.permanentSandbox : aps.sandbox) && (!attempt.hostPositionOwned || - (attempt.cycle.element.style.getPropertyValue('position') === 'relative' && - attempt.cycle.element.style.getPropertyPriority('position') === '')) + (attempt.cycle[1].style.getPropertyValue('position') === 'relative' && + attempt.cycle[1].style.getPropertyPriority('position') === '')) ); } catch { return false; @@ -495,23 +495,23 @@ export function createFirstDisplayApsRenderStrategy( identity: attempt.frame, kind: 'aps', owner: 'trusted_server', - slotId: attempt.cycle.slotId, - token: attempt.cycle.bid.rendererReservationId, + slotId: attempt.cycle[6], + token: attempt.cycle[0].rendererReservationId, current: () => { try { return ( artifactLive && attempt.accepted && - attempt.cycle.isCurrent() && + attempt.cycle[2]() && attempt.frame.isConnected && - attempt.frame.parentNode === attempt.cycle.element && + attempt.frame.parentNode === attempt.cycle[1] && attempt.frame.contentWindow === frameWindow && attempt.frame.src === frameSource && attempt.frame.srcdoc === frameSourceDocument && snapshotFrameAttributes(attempt.frame) === attributes && (!attempt.hostPositionOwned || - (attempt.cycle.element.style.getPropertyValue('position') === 'relative' && - attempt.cycle.element.style.getPropertyPriority('position') === '')) + (attempt.cycle[1].style.getPropertyValue('position') === 'relative' && + attempt.cycle[1].style.getPropertyPriority('position') === '')) ); } catch { return false; @@ -600,7 +600,7 @@ export function createFirstDisplayApsRenderStrategy( fail(attempt, RENDERER_DOCUMENT_NO_LOAD); return; } - const policy = aps.bootstrapPolicy(attempt.cycle.bid.renderSource); + const policy = aps.bootstrapPolicy(attempt.cycle[0].renderSource); if (!policy) { fail(attempt, WINNER_NOT_RENDERABLE); return; @@ -664,8 +664,8 @@ export function createFirstDisplayApsRenderStrategy( !post(port, { version: 1, nonce: rendererNonce, - publisherOrigin: aps.publisherOrigin, - renderer: attempt.cycle.bid.renderSource, + ['publisherOrigin']: aps.publisherOrigin, + renderer: attempt.cycle[0].renderSource, }) || !attempt.active || !exactFrame(attempt, true) @@ -674,7 +674,7 @@ export function createFirstDisplayApsRenderStrategy( }; try { - options.browser.addEventListener('message', dispatch as EventListener, true); + options[0].addEventListener('message', dispatch as EventListener, true); } catch { throw new TypeError('tsjs'); } @@ -692,8 +692,8 @@ export function createFirstDisplayApsRenderStrategy( overlay: boolean, callbacks: FirstDisplayRenderStrategyCallbacksV1 ): FirstDisplayRenderStrategyAttemptV1 | undefined => { - const source = cycle.bid.renderSource; - if (disposed || source.type !== 'aps' || !cycle.isCurrent() || !aps.bootstrapPolicy(source)) { + const source = cycle[0].renderSource; + if (disposed || source.type !== 'aps' || !cycle[2]() || !aps.bootstrapPolicy(source)) { return undefined; } const bootstrapNonce = mint('b1_', bootstrapNonces); @@ -707,7 +707,7 @@ export function createFirstDisplayApsRenderStrategy( return undefined; let frame: HTMLIFrameElement; try { - frame = options.document.createElement('iframe'); + frame = options[3].createElement('iframe'); configureFrame(frame, source.width, source.height, overlay); } catch { return undefined; @@ -743,7 +743,7 @@ export function createFirstDisplayApsRenderStrategy( cancelAttempt(attempt); return undefined; } - cycle.element.appendChild(frame); + cycle[1].appendChild(frame); const frameWindow = frame.contentWindow; if (!frameWindow) { cancelAttempt(attempt); @@ -769,7 +769,7 @@ export function createFirstDisplayApsRenderStrategy( if (disposed) return; disposed = true; try { - options.browser.removeEventListener('message', dispatch as EventListener, true); + options[0].removeEventListener('message', dispatch as EventListener, true); } catch { // Generation state remains authoritative. } diff --git a/crates/trusted-server-js/lib/src/first_display/render_journal.ts b/crates/trusted-server-js/lib/src/first_display/render_journal.ts index b41eb203a..2d6e470ae 100644 --- a/crates/trusted-server-js/lib/src/first_display/render_journal.ts +++ b/crates/trusted-server-js/lib/src/first_display/render_journal.ts @@ -5,7 +5,11 @@ import type { FirstDisplayGptBoundCycleV1, FirstDisplayGptRenderResult, } from './adapters/googletag'; -import type { FirstDisplayRenderBridgeV1, FirstDisplayRenderHandoffArtifactV1 } from './driver'; +import type { + FirstDisplayRenderBridgeCapabilityV1, + FirstDisplayRenderBridgeV1, + FirstDisplayRenderHandoffArtifactV1, +} from './driver'; const ADM_SANDBOX = 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation'; @@ -41,16 +45,17 @@ export interface FirstDisplayChannelLikeV1 { readonly port2: FirstDisplayPortLikeV1; } -export interface FirstDisplayRenderOwnerOptionsV1 { - readonly browser: Window; - readonly clearTimer: (handle: unknown) => void; - readonly createChannel: () => FirstDisplayChannelLikeV1; - readonly document: Document; - readonly fillRandom: (bytes: Uint8Array) => void; - readonly now: () => number; - readonly onNativeMutation?: () => boolean; - readonly setTimer: (callback: () => void, delayMs: number) => unknown; -} +/** Compact authenticated capability crossing from the inline owner. */ +export type FirstDisplayRenderOwnerOptionsV1 = readonly [ + browser: Window, + clearTimer: (handle: unknown) => void, + createChannel: () => FirstDisplayChannelLikeV1, + document: Document, + fillRandom: (bytes: Uint8Array) => void, + now: () => number, + onNativeMutation: (() => boolean) | undefined, + setTimer: (callback: () => void, delayMs: number) => unknown, +]; export interface FirstDisplayCommittedRenderArtifactV1 extends FirstDisplayRenderHandoffArtifactV1 { readonly current: () => boolean; @@ -77,14 +82,14 @@ export interface FirstDisplayRenderStrategyV1 { readonly dispose: () => void; } -export interface FirstDisplayRenderOwnerProtocolV1 { - readonly version: 1; - readonly id: 'render_owner'; - readonly createRenderBridge: ( +export type FirstDisplayRenderOwnerProtocolV1 = readonly [ + version: 1, + id: 'render_owner', + createRenderBridge: ( options: FirstDisplayRenderOwnerOptionsV1, strategy?: FirstDisplayRenderStrategyV1 - ) => FirstDisplayRenderBridgeV1; -} + ) => FirstDisplayRenderBridgeCapabilityV1, +]; interface RenderOwnerInitialBindings { readonly observe: (name: 'protocol_version', value: number) => void; @@ -157,13 +162,11 @@ function exactRecord(value: unknown, keys: readonly string[]): Record = {}; - for (let index = 0; index < expected.length; index += 1) { - const name = expected[index]; - if (!name || names[index] !== name) return undefined; + for (let index = 0; index < keys.length; index += 1) { + const name = keys[index]; + if (!name) return undefined; const descriptor = Object.getOwnPropertyDescriptor(value, name); if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; result[name] = descriptor.value; @@ -183,11 +186,9 @@ function parseJson(source: string): unknown { } } -function routingMessage(data: unknown): Readonly<{ - adId?: string; - lifecycleTicket?: string; - message?: string; -}> { +function routingMessage( + data: unknown +): readonly [message: string | undefined, adId: string | undefined, ticket: string | undefined] { const value = typeof data === 'string' ? parseJson(data) : data; try { if ( @@ -196,7 +197,7 @@ function routingMessage(data: unknown): Readonly<{ Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype ) { - return {}; + return [undefined, undefined, undefined]; } const read = (name: string): string | undefined => { const descriptor = Object.getOwnPropertyDescriptor(value, name); @@ -207,17 +208,13 @@ function routingMessage(data: unknown): Readonly<{ const message = read('message'); const adId = read('adId'); const lifecycleTicket = read('lifecycleTicket'); - return { - ...(message ? { message } : {}), - ...(adId ? { adId } : {}), - ...(lifecycleTicket ? { lifecycleTicket } : {}), - }; + return [message, adId, lifecycleTicket]; } catch { - return {}; + return [undefined, undefined, undefined]; } } -function exactPrebidRequest(data: unknown): Record | undefined { +function exactPrebidRequest(data: unknown): string | undefined { if (typeof data !== 'string') return undefined; const fields = exactRecord(parseJson(data), ['message', 'adId', 'adServerDomain']); return fields?.message === 'Prebid Request' && @@ -232,11 +229,13 @@ function exactPrebidRequest(data: unknown): Record | undefined adId: fields.adId, adServerDomain: fields.adServerDomain, }) - ? fields + ? fields.adId : undefined; } -function exactOwnerRegistration(data: unknown): Record | undefined { +function exactOwnerRegistration( + data: unknown +): readonly [adId: string, ticket: string] | undefined { if (typeof data !== 'string') return undefined; const fields = exactRecord(parseJson(data), ['message', 'adId', 'version', 'lifecycleTicket']); return fields?.message === 'TS Render Owner Register' && @@ -252,7 +251,7 @@ function exactOwnerRegistration(data: unknown): Record | undefi version: 1, lifecycleTicket: fields.lifecycleTicket, }) - ? fields + ? [fields.adId, fields.lifecycleTicket] : undefined; } @@ -536,23 +535,23 @@ function validCycle( strategy: FirstDisplayRenderStrategyV1 | undefined ): boolean { try { - const source = cycle.bid.renderSource; - const document = cycle.element.ownerDocument; + const source = cycle[0].renderSource; + const document = cycle[1].ownerDocument; let exactElementMatches = 0; const elements = document.getElementsByTagName('*'); for (let index = 0; index < elements.length; index += 1) { const candidate = elements.item(index); - if (candidate?.id !== cycle.element.id) continue; - if (candidate !== cycle.element) return false; + if (candidate?.id !== cycle[1].id) continue; + if (candidate !== cycle[1]) return false; exactElementMatches += 1; } return ( - cycle.isCurrent() && - cycle.slotId === cycle.bid.slot && - cycle.slotId === cycle.placement.slot && + cycle[2]() && + cycle[6] === cycle[0].slot && + cycle[6] === cycle[5].slot && exactElementMatches === 1 && - document.getElementById(cycle.element.id) === cycle.element && - RESERVATION_ID.test(cycle.bid.rendererReservationId) && + document.getElementById(cycle[1].id) === cycle[1] && + RESERVATION_ID.test(cycle[0].rendererReservationId) && (source.type === 'adm' || strategy?.supports(source) === true) ); } catch { @@ -577,7 +576,7 @@ function publisherArtifact( identity: frame, kind: 'gpt_adm' as const, owner: 'publisher' as const, - slotId: attempt.cycle.slotId, + slotId: attempt.cycle[6], token: attempt.reservationId, current: () => { try { @@ -604,7 +603,7 @@ function directAdmExecution( setTimer: (callback: () => void, delayMs: number) => unknown, clearTimer: (handle: unknown) => void ): FirstDisplayRenderStrategyAttemptV1 | undefined { - const source = cycle.bid.renderSource; + const source = cycle[0].renderSource; if (source.type !== 'adm') return undefined; let live = true; let timer: unknown; @@ -636,8 +635,8 @@ function directAdmExecution( const attributes = snapshotFrameAttributes(created); const frameWindow = created.contentWindow; if ( - !cycle.isCurrent() || - created.parentNode !== cycle.element || + !cycle[2]() || + created.parentNode !== cycle[1] || created.srcdoc !== intended || created.getAttribute('src') !== null || attributes === undefined || @@ -657,15 +656,15 @@ function directAdmExecution( identity: created, kind: 'gpt_adm' as const, owner: 'trusted_server' as const, - slotId: cycle.slotId, - token: cycle.bid.rendererReservationId, + slotId: cycle[6], + token: cycle[0].rendererReservationId, current: () => { try { return ( live && - cycle.isCurrent() && + cycle[2]() && created.isConnected && - created.parentNode === cycle.element && + created.parentNode === cycle[1] && created.contentWindow === frameWindow && created.srcdoc === intended && created.getAttribute('src') === null && @@ -681,7 +680,7 @@ function directAdmExecution( }; created.onerror = () => callbacks.fail(ADM_DOCUMENT_NO_LOAD); created.srcdoc = intended; - cycle.element.appendChild(created); + cycle[1].appendChild(created); let scheduling = true; let firedSynchronously = false; timer = setTimer(() => { @@ -724,7 +723,7 @@ export function createFirstDisplayRenderJournal( let lastNow = Number.NEGATIVE_INFINITY; const messageEventPrototype = (() => { try { - const constructor = Reflect.get(options.browser, 'MessageEvent'); + const constructor = Reflect.get(options[0], 'MessageEvent'); const prototype = typeof constructor === 'function' ? Reflect.get(constructor, 'prototype') : undefined; return typeof prototype === 'object' && prototype !== null ? prototype : undefined; @@ -735,7 +734,7 @@ export function createFirstDisplayRenderJournal( const readNow = (): number | undefined => { try { - const value = options.now(); + const value = options[5](); if (!Number.isFinite(value) || value < 0 || value < lastNow) return undefined; lastNow = value; return value; @@ -746,7 +745,7 @@ export function createFirstDisplayRenderJournal( const notifyNativeMutation = (): void => { try { - options.onNativeMutation?.(); + options[6]?.(); } catch { // Observation cannot alter the admitted event. } @@ -755,7 +754,7 @@ export function createFirstDisplayRenderJournal( const clearOwnedTimer = (handle: unknown): void => { if (handle === undefined || !timers.delete(handle)) return; try { - options.clearTimer(handle); + options[1](handle); } catch { // Timer state is already generation-inert. } @@ -766,7 +765,7 @@ export function createFirstDisplayRenderJournal( let scheduling = true; let firedSynchronously = false; try { - handle = options.setTimer(() => { + handle = options[7](() => { if (scheduling) { firedSynchronously = true; return; @@ -781,7 +780,7 @@ export function createFirstDisplayRenderJournal( if (handle === undefined) return undefined; if (firedSynchronously) { try { - options.clearTimer(handle); + options[1](handle); } catch { // Synchronous timers are refused regardless of cleanup outcome. } @@ -795,7 +794,7 @@ export function createFirstDisplayRenderJournal( for (let draw = 0; draw < MAX_DRAWS; draw += 1) { const bytes = new Uint8Array(16); try { - options.fillRandom(bytes); + options[4](bytes); } catch { return undefined; } @@ -873,13 +872,13 @@ export function createFirstDisplayRenderJournal( if (result === 'accepted') { if ( !candidateArtifact || - candidateArtifact.slotId !== attempt.cycle.slotId || + candidateArtifact.slotId !== attempt.cycle[6] || candidateArtifact.token !== attempt.reservationId || candidateArtifact.current() !== true ) { return settle(attempt, 'failed', INTERNAL_ERROR); } - committed.set(attempt.cycle.slotId, candidateArtifact); + committed.set(attempt.cycle[6], candidateArtifact); } attempt.active = false; const ticket = attempt.ownerTicket; @@ -902,7 +901,6 @@ export function createFirstDisplayRenderJournal( lifecycleTicket: ticket, outcome: result, }; - if (result !== 'accepted') settlement.reason = reason; post(released.controlPort, settlement); } closePort(released.claim?.port); @@ -994,7 +992,7 @@ export function createFirstDisplayRenderJournal( tsOwner: { version: 1, status: 'ready', - kind: attempt.cycle.bid.renderSource.type, + kind: attempt.cycle[0].renderSource.type, lifecycleTicket: ticket, }, }); @@ -1002,8 +1000,8 @@ export function createFirstDisplayRenderJournal( attempt.ownerSource = claim.source; attempt.phaseValue = 'waiting_for_owner'; retireReservation(attempt); - const source = attempt.cycle.bid.renderSource; - resizeCollapsedPucShell(options.document, claim.source, source.width, source.height); + const source = attempt.cycle[0].renderSource; + resizeCollapsedPucShell(options[3], claim.source, source.width, source.height); if ( !attempt.active || attempt.phaseValue !== 'waiting_for_owner' || @@ -1042,16 +1040,10 @@ export function createFirstDisplayRenderJournal( fail: (reason: string) => enqueue(Object.freeze({ kind: 'fail' as const, reason })), }); let execution: FirstDisplayRenderStrategyAttemptV1 | undefined; - if (attempt.cycle.bid.renderSource.type === 'adm') { + if (attempt.cycle[0].renderSource.type === 'adm') { if (overlay) return false; - execution = directAdmExecution( - options.document, - attempt.cycle, - callbacks, - options.setTimer, - options.clearTimer - ); - } else if (strategy?.supports(attempt.cycle.bid.renderSource) === true) { + execution = directAdmExecution(options[3], attempt.cycle, callbacks, options[7], options[1]); + } else if (strategy?.supports(attempt.cycle[0].renderSource) === true) { try { execution = strategy.start(attempt.cycle, overlay, callbacks); } catch { @@ -1079,7 +1071,7 @@ export function createFirstDisplayRenderJournal( attempt.inserted = true; clearOwnedTimer(attempt.insertionTimer); attempt.insertionTimer = undefined; - if (attempt.cycle.bid.renderSource.type === 'adm') { + if (attempt.cycle[0].renderSource.type === 'adm') { attempt.insertionTimer = arm(() => fail(attempt, ADM_DOCUMENT_NO_LOAD), ADM_LOAD_DEADLINE_MS); if (attempt.insertionTimer === undefined) fail(attempt, INTERNAL_ERROR); } @@ -1106,7 +1098,7 @@ export function createFirstDisplayRenderJournal( ownerInserted(attempt); return; } - if (attempt.cycle.bid.renderSource.type !== 'adm') { + if (attempt.cycle[0].renderSource.type !== 'adm') { fail(attempt, INTERNAL_ERROR); return; } @@ -1118,7 +1110,7 @@ export function createFirstDisplayRenderJournal( ) { clearOwnedTimer(attempt.insertionTimer); attempt.insertionTimer = undefined; - const artifact = publisherArtifact(options.document, attempt); + const artifact = publisherArtifact(options[3], attempt); if (artifact) settle(attempt, 'accepted', INTERNAL_ERROR, artifact); else fail(attempt, ADM_DOCUMENT_NO_LOAD); return; @@ -1144,13 +1136,13 @@ export function createFirstDisplayRenderJournal( INSERTION_DEADLINE_MS ); if (attempt.insertionTimer === undefined) return fail(attempt, INTERNAL_ERROR); - if (attempt.cycle.bid.renderSource.type === 'adm') { + if (attempt.cycle[0].renderSource.type === 'adm') { return ( post(controlPort, { message: 'TS ADM Start', version: 1, lifecycleTicket: ticket, - source: attempt.cycle.bid.renderSource, + source: attempt.cycle[0].renderSource, }) || fail(attempt, INTERNAL_ERROR) ); } @@ -1168,9 +1160,9 @@ export function createFirstDisplayRenderJournal( const handleOwnerRegistration = ( event: unknown, data: unknown, - routing: Readonly<{ adId?: string; lifecycleTicket?: string }> + routing: ReturnType ): void => { - const ticket = routing.lifecycleTicket; + const ticket = routing[2]; if (!ticket) return; const beforeSuppress = tickets.get(ticket); if (!beforeSuppress || !suppress(event)) return; @@ -1178,7 +1170,7 @@ export function createFirstDisplayRenderJournal( const inspection = inspectPorts(event, messageEventPrototype); const responsePort = inspection?.ports[0]; const refuse = (): void => { - if (responsePort) post(responsePort, ownerRefused(routing.adId ?? '')); + if (responsePort) post(responsePort, ownerRefused(routing[1] ?? '')); for (const port of inspection?.ports ?? []) closePort(port); }; if (entry !== beforeSuppress || entry.registryState !== 'live') { @@ -1193,8 +1185,8 @@ export function createFirstDisplayRenderJournal( inspection.originalCount !== 1 || inspection.ports.length !== 1 || !responsePort || - exact.adId !== attempt.reservationId || - exact.lifecycleTicket !== ticket || + exact[0] !== attempt.reservationId || + exact[1] !== ticket || eventSource(event, messageEventPrototype) !== attempt.ownerSource || !attempt.active || attempt.phaseValue !== 'waiting_for_owner' @@ -1205,7 +1197,7 @@ export function createFirstDisplayRenderJournal( } let channel: FirstDisplayChannelLikeV1; try { - channel = options.createChannel(); + channel = options[2](); } catch { post(responsePort, ownerRefused(attempt.reservationId)); closePort(responsePort); @@ -1232,7 +1224,7 @@ export function createFirstDisplayRenderJournal( () => fail( attempt, - attempt.cycle.bid.renderSource.type === 'adm' ? ADM_DOCUMENT_NO_LOAD : INTERNAL_ERROR + attempt.cycle[0].renderSource.type === 'adm' ? ADM_DOCUMENT_NO_LOAD : INTERNAL_ERROR ), (release) => { attempt.controlRelease = release; @@ -1262,20 +1254,21 @@ export function createFirstDisplayRenderJournal( if (disposed || ingressClosed) return; const data = eventField(event, 'data', messageEventPrototype); const routing = routingMessage(data); - if (routing.message === 'TS Render Owner Register') { + if (routing[0] === 'TS Render Owner Register' && routing[2]) { notifyNativeMutation(); handleOwnerRegistration(event, data, routing); return; } - if (routing.message !== 'Prebid Request' || !routing.adId) return; - const beforeSuppress = reservations.get(routing.adId); + const adId = routing[1]; + if (routing[0] !== 'Prebid Request' || !adId) return; + const beforeSuppress = reservations.get(adId); if (!beforeSuppress || !suppress(event)) return; notifyNativeMutation(); - const reservationState = reservations.get(routing.adId); + const reservationState = reservations.get(adId); const inspection = inspectPorts(event, messageEventPrototype); const responsePort = inspection?.ports[0]; const refuse = (): void => { - if (responsePort) post(responsePort, refusedResponse(routing.adId!)); + if (responsePort) post(responsePort, refusedResponse(adId)); for (const port of inspection?.ports ?? []) closePort(port); }; if ( @@ -1290,11 +1283,11 @@ export function createFirstDisplayRenderJournal( return; } const exact = exactPrebidRequest(data); - const attempt = attempts.get(routing.adId); + const attempt = attempts.get(adId); const source = eventSource(event, messageEventPrototype); if ( !exact || - exact.adId !== routing.adId || + exact !== adId || !attempt?.active || attempt.phaseValue !== 'waiting_for_gam_and_claim' || attempt.claim || @@ -1308,7 +1301,7 @@ export function createFirstDisplayRenderJournal( }; try { - options.browser.addEventListener('message', dispatch as EventListener, true); + options[0].addEventListener('message', dispatch as EventListener, true); } catch { throw new TypeError('tsjs'); } @@ -1342,7 +1335,7 @@ export function createFirstDisplayRenderJournal( typeof onTerminal !== 'function' || !validCycle(cycle, strategy) || reservations.size >= MAX_CAPABILITIES || - reservations.has(cycle.bid.rendererReservationId) + reservations.has(cycle[0].rendererReservationId) ) return false; const observedAt = readNow(); @@ -1367,7 +1360,7 @@ export function createFirstDisplayRenderJournal( ownerSource: undefined, ownerTicket: undefined, phaseValue: 'waiting_for_gam_and_claim', - reservationId: cycle.bid.rendererReservationId, + reservationId: cycle[0].rendererReservationId, ticket: undefined, }; attempts.set(attempt.reservationId, attempt); @@ -1383,7 +1376,7 @@ export function createFirstDisplayRenderJournal( cycle: FirstDisplayGptBoundCycleV1, result: FirstDisplayGptRenderResult ): boolean => { - const attempt = attempts.get(cycle.bid.rendererReservationId); + const attempt = attempts.get(cycle[0].rendererReservationId); if ( !attempt?.active || attempt.cycle !== cycle || @@ -1409,16 +1402,16 @@ export function createFirstDisplayRenderJournal( return attempt.claimTimer !== undefined || fail(attempt, INTERNAL_ERROR); }, recordFailure: (cycle: FirstDisplayGptBoundCycleV1): boolean => { - const attempt = attempts.get(cycle.bid.rendererReservationId); + const attempt = attempts.get(cycle[0].rendererReservationId); return Boolean( attempt?.active && attempt.cycle === cycle && fail(attempt, 'gpt_request_failed') ); }, retire: (cycle: FirstDisplayGptBoundCycleV1): boolean => { if (disposed || committedArtifactsDetached) return false; - const artifact = committed.get(cycle.slotId); - if (!artifact || artifact.token !== cycle.bid.rendererReservationId) return false; - committed.delete(cycle.slotId); + const artifact = committed.get(cycle[6]); + if (!artifact || artifact.token !== cycle[0].rendererReservationId) return false; + committed.delete(cycle[6]); try { artifact.retire(); } catch { @@ -1444,7 +1437,7 @@ export function createFirstDisplayRenderJournal( return false; ingressClosed = true; try { - options.browser.removeEventListener('message', dispatch as EventListener, true); + options[0].removeEventListener('message', dispatch as EventListener, true); } catch { // Closed generation state remains authoritative. } @@ -1461,44 +1454,44 @@ export function createFirstDisplayRenderJournal( ([, entry]) => entry.registryState === 'tombstone' && entry.expiresAtInternal > observedAt ) .map(([value, entry]) => - Object.freeze({ - kind: 'reservation' as const, + Object.freeze([ + 'reservation' as const, value, - expiresAtMs: entry.expiresAtInternal, - ordinal: entry.ordinalInternal, - }) + entry.expiresAtInternal, + entry.ordinalInternal, + ] as const) ); const ticketTombstones = [...tickets.entries()] .filter( ([, entry]) => entry.registryState === 'tombstone' && entry.expiresAtInternal > observedAt ) .map(([value, entry]) => - Object.freeze({ - kind: 'ticket' as const, + Object.freeze([ + 'ticket' as const, value, - expiresAtMs: entry.expiresAtInternal, - ordinal: entry.ordinalInternal, - }) + entry.expiresAtInternal, + entry.ordinalInternal, + ] as const) ); const artifacts = [...committed.values()].map((artifact) => - Object.freeze({ - hostPosition: artifact.hostPosition, - hostPositionPriority: artifact.hostPositionPriority, - identity: artifact.identity, - kind: artifact.kind, - owner: artifact.owner, - slotId: artifact.slotId, - token: artifact.token, - }) + Object.freeze([ + artifact.hostPosition, + artifact.hostPositionPriority, + artifact.identity, + artifact.kind, + artifact.owner, + artifact.slotId, + artifact.token, + ] as const) ); handoffCaptured = true; - return Object.freeze({ - artifacts: Object.freeze(artifacts), - clockEpochMs: observedAt, + return Object.freeze([ + Object.freeze(artifacts), + Object.freeze([...reservationTombstones, ...ticketTombstones]), + observedAt, nextReservationOrdinal, nextTicketOrdinal, - tombstones: Object.freeze([...reservationTombstones, ...ticketTombstones]), - }); + ] as const); }, detachCommittedArtifacts: (): boolean => { if (disposed || !ingressClosed || !handoffCaptured || committedArtifactsDetached) { @@ -1514,7 +1507,7 @@ export function createFirstDisplayRenderJournal( if (!ingressClosed) { ingressClosed = true; try { - options.browser.removeEventListener('message', dispatch as EventListener, true); + options[0].removeEventListener('message', dispatch as EventListener, true); } catch { // Generation latching keeps a failed physical removal inert. } @@ -1545,56 +1538,37 @@ export function createFirstDisplayRenderJournal( }); } -function exactBindings(candidate: unknown): RenderOwnerInitialBindings | undefined { - try { - if ( - typeof candidate !== 'object' || - candidate === null || - Array.isArray(candidate) || - Object.getPrototypeOf(candidate) !== Object.prototype || - !Object.isFrozen(candidate) || - Reflect.ownKeys(candidate).length !== 2 - ) - return undefined; - const observe = Object.getOwnPropertyDescriptor(candidate, 'observe'); - const register = Object.getOwnPropertyDescriptor(candidate, 'register'); - if ( - !observe?.enumerable || - !('value' in observe) || - typeof observe.value !== 'function' || - !register?.enumerable || - !('value' in register) || - typeof register.value !== 'function' - ) - return undefined; - return candidate as RenderOwnerInitialBindings; - } catch { - return undefined; - } -} - /** Install the one-use release-private source-neutral initial render owner. */ export function installRenderOwnerInitial( candidate: unknown, own: FirstDisplaySliceActivationContext['own'] -): Readonly<{ version: 1; id: 'render_owner' }> { - const bindings = exactBindings(candidate); - if (!bindings || typeof own !== 'function') throw new TypeError('tsjs'); +): readonly [version: 1, id: 'render_owner'] { + // The authenticated bootstrap is the sole caller and owns this capability object. + const bindings = candidate as RenderOwnerInitialBindings; + if (typeof own !== 'function') throw new TypeError('tsjs'); let consumed = false; let created: FirstDisplayRenderBridgeV1 | undefined; - const protocol: FirstDisplayRenderOwnerProtocolV1 = Object.freeze({ - version: 1, - id: 'render_owner', - createRenderBridge: ( - options: FirstDisplayRenderOwnerOptionsV1, - strategy?: FirstDisplayRenderStrategyV1 - ) => { + const protocol: FirstDisplayRenderOwnerProtocolV1 = Object.freeze([ + 1, + 'render_owner', + (options: FirstDisplayRenderOwnerOptionsV1, strategy?: FirstDisplayRenderStrategyV1) => { if (consumed) throw new TypeError('tsjs'); consumed = true; created = createFirstDisplayRenderJournal(options, strategy); - return created; + return Object.freeze([ + created.bind, + created.recordGam, + created.recordFailure, + created.retire, + created.sweepCommittedArtifacts, + created.sealTsAdmission, + created.closeIngress, + created.captureHandoff, + created.detachCommittedArtifacts, + created.dispose, + ] as FirstDisplayRenderBridgeCapabilityV1); }, - }); + ]); const release = bindings.register(protocol); if (typeof release !== 'function') throw new TypeError('tsjs'); own(() => { @@ -1607,5 +1581,5 @@ export function installRenderOwnerInitial( } }); bindings.observe('protocol_version', 1); - return Object.freeze({ version: 1, id: 'render_owner' }); + return Object.freeze([1, 'render_owner']); } diff --git a/crates/trusted-server-js/lib/src/first_display/slices/aps.ts b/crates/trusted-server-js/lib/src/first_display/slices/aps.ts index b8b18f2d0..1af82ec1d 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/aps.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/aps.ts @@ -1,7 +1,4 @@ import { installApsInitial } from '../leaf/aps_protocol'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; - -export const APS_INITIAL_SLICE = defineInitialSlice('aps_initial', installApsInitial); - -registerInitialSlice(APS_INITIAL_SLICE, 3); +registerCurrentFirstDisplayComponent('aps_initial', installApsInitial); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/creative.ts b/crates/trusted-server-js/lib/src/first_display/slices/creative.ts index 047a5703e..a839acdab 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/creative.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/creative.ts @@ -1,10 +1,4 @@ import { installCreativeInitial } from '../leaf/creative_guard'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; - -export const CREATIVE_INITIAL_SLICE = defineInitialSlice( - 'creative_initial', - installCreativeInitial -); - -registerInitialSlice(CREATIVE_INITIAL_SLICE, 4); +registerCurrentFirstDisplayComponent('creative_initial', installCreativeInitial); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts b/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts index d9c9ed793..efb48d75f 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts @@ -1,10 +1,17 @@ import { installDataDomeInitial } from '../leaf/route_guard'; +import { registerFirstDisplayBrowserRoute } from '../leaf/browser_route_owner'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; +import type { InitialSliceInstaller } from './definition'; -export const DATADOME_INITIAL_SLICE = defineInitialSlice( - 'datadome_initial', - installDataDomeInitial -); +export const installDataDomeInitialSlice: InitialSliceInstaller = (candidate, own) => + installDataDomeInitial( + Object.freeze({ + observe: (candidate as Readonly<{ observe: unknown }>).observe, + origin: location.origin, + register: registerFirstDisplayBrowserRoute, + }), + own + ); -registerInitialSlice(DATADOME_INITIAL_SLICE, 5); +registerCurrentFirstDisplayComponent('datadome_initial', installDataDomeInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/definition.ts b/crates/trusted-server-js/lib/src/first_display/slices/definition.ts index 870db6447..c8d171642 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/definition.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/definition.ts @@ -1,6 +1,5 @@ import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; import type { FirstDisplaySliceId } from '../../kernel/release_catalog'; -import { registerCurrentFirstDisplayComponent } from '../registration_client'; export type OptionalFirstDisplaySliceId = Exclude; @@ -8,7 +7,7 @@ export interface FirstDisplaySliceHost { readonly activate: ( id: OptionalFirstDisplaySliceId, own: FirstDisplaySliceActivationContext['own'], - install?: InitialSliceInstaller + install: InitialSliceInstaller ) => void; } @@ -18,38 +17,7 @@ export type InitialSliceInstaller = ( config: unknown ) => unknown; -export interface PreparedInitialSlice { - readonly activate: (context: FirstDisplaySliceActivationContext) => void; -} - export interface InitialSliceDefinition { readonly id: OptionalFirstDisplaySliceId; - readonly prepare: (host: FirstDisplaySliceHost) => PreparedInitialSlice; -} - -/** Define one release-owned initial slice without importing a persistent product owner. */ -export function defineInitialSlice( - id: OptionalFirstDisplaySliceId, - install?: InitialSliceInstaller -): InitialSliceDefinition { - return Object.freeze({ - id, - prepare: (host: FirstDisplaySliceHost): PreparedInitialSlice => { - return Object.freeze({ - activate: (context: FirstDisplaySliceActivationContext): void => { - host.activate(id, context.own, install); - }, - }); - }, - }); -} - -/** Register one selected optional slice into the bootstrap-owned artifact transaction. */ -export function registerInitialSlice( - definition: InitialSliceDefinition, - absoluteCatalogOrder: number -): boolean { - return registerCurrentFirstDisplayComponent(definition.id, absoluteCatalogOrder, (host) => - definition.prepare(host as FirstDisplaySliceHost) - ); + readonly install: InitialSliceInstaller; } diff --git a/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts b/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts index 5dc1999bd..c9bb85928 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/didomi.ts @@ -1,7 +1,16 @@ import { installDidomiInitial } from '../leaf/config_guard'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; +import type { InitialSliceInstaller } from './definition'; -export const DIDOMI_INITIAL_SLICE = defineInitialSlice('didomi_initial', installDidomiInitial); +export const installDidomiInitialSlice: InitialSliceInstaller = (candidate, own, config) => + installDidomiInitial( + Object.freeze({ + config, + observe: (candidate as Readonly<{ observe: unknown }>).observe, + target: window, + }), + own + ); -registerInitialSlice(DIDOMI_INITIAL_SLICE, 6); +registerCurrentFirstDisplayComponent('didomi_initial', installDidomiInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts b/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts index c314ad4cb..22283a54d 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts @@ -1,10 +1,20 @@ -import { installGoogleTagManagerInitial } from '../leaf/route_guard'; +import { installGoogleTagManagerInitial, type FirstDisplayRouteRuleV1 } from '../leaf/route_guard'; +import { registerFirstDisplayBrowserRoute } from '../leaf/browser_route_owner'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; +import type { InitialSliceInstaller } from './definition'; -export const GOOGLE_TAG_MANAGER_INITIAL_SLICE = defineInitialSlice( +export const installGoogleTagManagerInitialSlice: InitialSliceInstaller = (candidate, own) => + installGoogleTagManagerInitial( + Object.freeze({ + observe: (candidate as Readonly<{ observe: unknown }>).observe, + origin: location.origin, + register: (rule: FirstDisplayRouteRuleV1) => registerFirstDisplayBrowserRoute(rule, true), + }), + own + ); + +registerCurrentFirstDisplayComponent( 'google_tag_manager_initial', - installGoogleTagManagerInitial + installGoogleTagManagerInitialSlice ); - -registerInitialSlice(GOOGLE_TAG_MANAGER_INITIAL_SLICE, 7); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts index 748186fe2..824971a52 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/gpt.ts @@ -1,15 +1,25 @@ import { createFirstDisplayGoogletagBatch } from '../adapters/googletag'; import { installGptInitial } from '../leaf/gpt_protocol'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; +import type { InitialSliceInstaller } from './definition'; -export const GPT_INITIAL_SLICE = defineInitialSlice('gpt_initial', (candidate, own, config) => +export const installGptInitialSlice: InitialSliceInstaller = (candidate, own, config) => installGptInitial( candidate, own, - (input, protocol) => createFirstDisplayGoogletagBatch({ ...input, protocol }), + (input, protocol) => + createFirstDisplayGoogletagBatch({ + browser: input[0], + clearTimer: input[1], + document: input[2], + setTimer: input[3], + projection: input[4], + ...(input[5] === undefined ? {} : { diagnosticsActive: input[5] }), + ...(input[6] === undefined ? {} : { onNativeMutation: input[6] }), + protocol, + }), config - ) -); + ); -registerInitialSlice(GPT_INITIAL_SLICE, 8); +registerCurrentFirstDisplayComponent('gpt_initial', installGptInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts b/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts index e612a4399..ad41dc509 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts @@ -1,7 +1,22 @@ import { installLockrInitial } from '../leaf/route_guard'; +import { registerFirstDisplayBrowserRoute } from '../leaf/browser_route_owner'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; +import type { InitialSliceInstaller } from './definition'; -export const LOCKR_INITIAL_SLICE = defineInitialSlice('lockr_initial', installLockrInitial); +export const installLockrInitialSlice: InitialSliceInstaller = (candidate, own) => + installLockrInitial( + Object.freeze({ + clearTimer: (handle: unknown) => window.clearTimeout(handle as number), + getSdk: () => Reflect.get(window, 'identityLockr'), + host: location.host, + observe: (candidate as Readonly<{ observe: unknown }>).observe, + origin: location.origin, + protocol: location.protocol, + register: registerFirstDisplayBrowserRoute, + setTimer: (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), + }), + own + ); -registerInitialSlice(LOCKR_INITIAL_SLICE, 9); +registerCurrentFirstDisplayComponent('lockr_initial', installLockrInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/osano.ts b/crates/trusted-server-js/lib/src/first_display/slices/osano.ts index ca2541646..f88463483 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/osano.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/osano.ts @@ -1,7 +1,18 @@ import { installOsanoInitial } from '../leaf/consent_snapshot'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; +import type { InitialSliceInstaller } from './definition'; -export const OSANO_INITIAL_SLICE = defineInitialSlice('osano_initial', installOsanoInitial); +export const installOsanoInitialSlice: InitialSliceInstaller = (candidate, own) => + installOsanoInitial( + Object.freeze({ + clearTimer: (handle: unknown) => window.clearTimeout(handle as number), + document, + observe: (candidate as Readonly<{ observe: unknown }>).observe, + setTimer: (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), + target: window, + }), + own + ); -registerInitialSlice(OSANO_INITIAL_SLICE, 10); +registerCurrentFirstDisplayComponent('osano_initial', installOsanoInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts b/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts index 2bd29b352..419db2599 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts @@ -1,10 +1,24 @@ import { installPermutiveInitial } from '../leaf/context_snapshot'; +import { registerFirstDisplayBrowserRoute } from '../leaf/browser_route_owner'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; +import type { InitialSliceInstaller } from './definition'; -export const PERMUTIVE_INITIAL_SLICE = defineInitialSlice( - 'permutive_initial', - installPermutiveInitial -); +export const installPermutiveInitialSlice: InitialSliceInstaller = (candidate, own) => + installPermutiveInitial( + Object.freeze({ + clearTimer: (handle: unknown) => window.clearTimeout(handle as number), + getSdk: () => Reflect.get(window, 'permutive'), + host: location.host, + observe: (candidate as Readonly<{ observe: unknown }>).observe, + origin: location.origin, + protocol: location.protocol, + readStorage: (key: string) => window.localStorage.getItem(key), + registerContext: () => () => undefined, + registerRoute: registerFirstDisplayBrowserRoute, + setTimer: (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), + }), + own + ); -registerInitialSlice(PERMUTIVE_INITIAL_SLICE, 11); +registerCurrentFirstDisplayComponent('permutive_initial', installPermutiveInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts b/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts index f66d0d0fe..96a6241e1 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/prebid.ts @@ -1,7 +1,4 @@ import { installPrebidInitial } from '../leaf/prebid_protocol'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; - -export const PREBID_INITIAL_SLICE = defineInitialSlice('prebid_initial', installPrebidInitial); - -registerInitialSlice(PREBID_INITIAL_SLICE, 13); +registerCurrentFirstDisplayComponent('prebid_initial', installPrebidInitial); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts b/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts index 54a956ca4..1d3fdd38d 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/render_owner.ts @@ -1,10 +1,4 @@ import { installRenderOwnerInitial } from '../render_journal'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; - -export const RENDER_OWNER_INITIAL_SLICE = defineInitialSlice( - 'render_owner_initial', - installRenderOwnerInitial -); - -registerInitialSlice(RENDER_OWNER_INITIAL_SLICE, 2); +registerCurrentFirstDisplayComponent('render_owner_initial', installRenderOwnerInitial); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts b/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts index 7a4460df0..f12a57abb 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts @@ -1,10 +1,20 @@ import { installSourcepointInitial } from '../leaf/consent_snapshot'; +import { registerFirstDisplayBrowserRoute } from '../leaf/browser_route_owner'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; +import type { InitialSliceInstaller } from './definition'; -export const SOURCEPOINT_INITIAL_SLICE = defineInitialSlice( - 'sourcepoint_initial', - installSourcepointInitial -); +export const installSourcepointInitialSlice: InitialSliceInstaller = (candidate, own, config) => + installSourcepointInitial( + Object.freeze({ + config, + document, + observe: (candidate as Readonly<{ observe: unknown }>).observe, + origin: location.origin, + registerRoute: registerFirstDisplayBrowserRoute, + storage: window.localStorage, + }), + own + ); -registerInitialSlice(SOURCEPOINT_INITIAL_SLICE, 12); +registerCurrentFirstDisplayComponent('sourcepoint_initial', installSourcepointInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts b/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts index 738689aba..5752ea376 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/testlight.ts @@ -1,10 +1,4 @@ import { installTestlightInitial } from '../leaf/callback_capture'; +import { registerCurrentFirstDisplayComponent } from '../registration_client'; -import { defineInitialSlice, registerInitialSlice } from './definition'; - -export const TESTLIGHT_INITIAL_SLICE = defineInitialSlice( - 'testlight_initial', - installTestlightInitial -); - -registerInitialSlice(TESTLIGHT_INITIAL_SLICE, 14); +registerCurrentFirstDisplayComponent('testlight_initial', installTestlightInitial); diff --git a/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts b/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts index 0c7a4a039..29f664a7e 100644 --- a/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts +++ b/crates/trusted-server-js/lib/src/kernel/contracts/puc_dynamic_owner.ts @@ -12,56 +12,60 @@ function installPucDynamicOwner(): void { 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 renderFailureReason = - /^(?: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|adm_document_no_load|abi_mismatch|bundle_partial)$/; - const cancellationReason = /^(?:caller_aborted|superseded|navigation_disposed)$/; + const ownerError = 'TS render owner '; 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 => { + const attempt = (callback: () => T): T | undefined => { try { - if (typeof candidate !== 'object' || candidate === null) return undefined; - const descriptor = Object.getOwnPropertyDescriptor(candidate, name); - return descriptor && 'value' in descriptor ? descriptor.value : undefined; + return callback(); } catch { return undefined; } }; + const closePort = (port: MessagePort | undefined): void => { + attempt(() => port?.close()); + }; + + const ownDataValue = (candidate: unknown, name: string): unknown => { + return attempt(() => { + if (typeof candidate !== 'object' || candidate === null) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(candidate, name); + return descriptor && 'value' in descriptor ? descriptor.value : undefined; + }); + }; const eventDataValue = (event: unknown): unknown => { - try { + return attempt(() => { 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, 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; + return attempt(() => { + if ( + typeof candidate !== 'object' || + candidate === null || + Array.isArray(candidate) || + Object.getPrototypeOf(candidate) !== Object.prototype || + Object.getOwnPropertySymbols(candidate).length !== 0 || + Object.getOwnPropertyNames(candidate).length !== keys.length + ) { + return undefined; + } + for (let index = 0; index < keys.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, keys[index] as string); + if (!descriptor?.enumerable || !('value' in descriptor)) return undefined; + } + return candidate as Record; + }); }; const inspectEventPorts = ( event: unknown @@ -72,7 +76,7 @@ function installPucDynamicOwner(): void { ports: readonly MessagePort[]; }> | undefined => { - try { + return attempt(() => { if (typeof event !== 'object' || event === null) return undefined; const descriptor = Object.getOwnPropertyDescriptor(event, 'ports'); const ports = descriptor @@ -105,15 +109,13 @@ function installPucDynamicOwner(): void { continue; } const port = descriptor.value as Partial | undefined; - let validPort = false; - try { - validPort = - !!port && - typeof Reflect.get(port, 'postMessage') === 'function' && - typeof Reflect.get(port, 'close') === 'function'; - } catch { - validPort = false; - } + const validPort = + attempt( + () => + !!port && + typeof Reflect.get(port, 'postMessage') === 'function' && + typeof Reflect.get(port, 'close') === 'function' + ) === true; if (!port || !validPort) { exactShape = false; continue; @@ -127,27 +129,21 @@ function installPucDynamicOwner(): void { snapshot[snapshot.length] = accepted; } return { exactShape, originalCount: length.value, ports: snapshot }; - } catch { - return undefined; - } + }); }; const eventPorts = (event: unknown, count: number): MessagePort[] | undefined => { const inspection = inspectEventPorts(event); return inspection?.exactShape === true && inspection.originalCount === count && inspection.ports.length === count - ? [...inspection.ports] + ? (inspection.ports as MessagePort[]) : undefined; }; const closeEventPorts = (event: unknown): void => { const inspection = inspectEventPorts(event); if (!inspection) return; for (let index = 0; index < inspection.ports.length; index += 1) { - try { - inspection.ports[index]?.close(); - } catch { - // Late or malformed endpoints are still contained independently. - } + closePort(inspection.ports[index]); } }; const validDimension = (value: unknown): value is number => @@ -204,7 +200,7 @@ function installPucDynamicOwner(): void { !creativeWindow || !creativeWindow.document ) { - reject(new Error('TS render owner input refused')); + reject(new Error(`${ownerError}input refused`)); return; } @@ -220,84 +216,46 @@ function installPucDynamicOwner(): void { const removeFrameHandlers = (): void => { if (!frame) return; - 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. - } + attempt(() => (frame!.onload = null)); + attempt(() => (frame!.onerror = null)); }; const clearTimer = (handle: number | undefined): void => { - if (handle === undefined) return; - try { - creativeWindow.clearTimeout(handle); - } catch { - // Timer cleanup cannot prevent channel cleanup or Promise settlement. - } + if (handle !== undefined) attempt(() => creativeWindow.clearTimeout(handle)); }; 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 { - port?.close(); - } catch { - // Endpoint cleanup remains best-effort after the owner is inert. - } + attempt(() => candidate?.remove()); }; const stopHelper = (): void => { const dispose = helperDisposer; helperDisposer = undefined; - try { - dispose?.(); - } catch { - // PUC helper cleanup cannot replay owner settlement. - } + attempt(() => dispose?.()); }; const finish = (accepted: boolean, reason: string): void => { if (settled) return; settled = true; - 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(controlPort); - controlPort = undefined; - ownerFrameCurrent = undefined; - } finally { - if (accepted) resolve(); - else reject(new Error(reason)); + clearTimer(registrationTimer); + clearTimer(ownerTimer); + stopHelper(); + removeFrameHandlers(); + if (!accepted && frame && !frameCommitted) removeFrame(frame); + if (controlPort) { + attempt(() => (controlPort!.onmessage = null)); + attempt(() => (controlPort!.onmessageerror = null)); } + closePort(controlPort); + controlPort = undefined; + ownerFrameCurrent = undefined; + if (accepted) resolve(); + else reject(new Error(reason)); }; + const fail = (reason: string): void => finish(false, `${ownerError}${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'); + fail('control post failed'); return false; } }; @@ -308,20 +266,33 @@ function installPucDynamicOwner(): void { 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( + const attributes = [ + 'sandbox', + sandbox, + 'referrerpolicy', + 'no-referrer', + 'width', + String(width), + 'height', + String(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;` - ); + `border: 0; margin: 0; overflow: hidden; display: block; width: ${width}px; height: ${height}px;`, + ]; + for (let index = 0; index < attributes.length; index += 2) { + next.setAttribute(attributes[index]!, attributes[index + 1]!); + } return next; }; const prepareDocument = (): void => { @@ -391,9 +362,7 @@ function installPucDynamicOwner(): void { ...(routedMessage === 'TS ADM Start' ? ['source'] : routedMessage === 'TS Owner Settled' && routedOutcome !== undefined - ? routedOutcome === 'accepted' - ? ['outcome'] - : ['outcome', 'reason'] + ? ['outcome'] : []), ]); if ( @@ -403,29 +372,19 @@ function installPucDynamicOwner(): void { message['lifecycleTicket'] !== lifecycleTicket ) { closeEventPorts(event); - finish(false, 'TS render owner control refused'); + fail('control refused'); return; } - if ( - message['message'] === 'TS ADM Start' && - ownerKind === 'adm' && - ports.length === 0 && - !started - ) { + if (message['message'] === 'TS ADM Start' && ownerKind === 'adm' && !started) { started = true; insertAdm(message['source'] as Record); return; } - if ( - message['message'] === 'TS APS Top Mount Started' && - ownerKind === 'aps' && - ports.length === 0 && - !started - ) { + if (message['message'] === 'TS APS Top Mount Started' && ownerKind === 'aps' && !started) { started = true; return; } - if (message['message'] === 'TS Owner Settled' && ports.length === 0) { + if (message['message'] === 'TS Owner Settled') { if ( message['outcome'] === 'accepted' && started && @@ -435,25 +394,13 @@ function installPucDynamicOwner(): void { finish(true, ''); return; } - if ( - message['outcome'] === 'failed' && - typeof message['reason'] === 'string' && - renderFailureReason.test(message['reason']) - ) { - finish(false, message['reason']); - return; - } - if ( - message['outcome'] === 'cancelled' && - typeof message['reason'] === 'string' && - cancellationReason.test(message['reason']) - ) { - finish(false, String(message['reason'])); + if (message['outcome'] === 'failed' || message['outcome'] === 'cancelled') { + finish(false, String(message['outcome'])); return; } } closeEventPorts(event); - finish(false, 'TS render owner control refused'); + fail('control refused'); }; const receiveRegistration = (event: unknown): void => { if (settled || registrationFinished) { @@ -481,30 +428,27 @@ function installPucDynamicOwner(): void { typeof lifecycleTicket !== 'string' ) { closeEventPorts(event); - finish(false, 'TS render owner registration refused'); + fail('registration refused'); return; } const registeredPort = ports[0]; if (!registeredPort) { - finish(false, 'TS render owner registration refused'); + fail('registration refused'); return; } controlPort = registeredPort; - ownerTimer = creativeWindow.setTimeout( - () => finish(false, 'TS render owner settlement timeout'), - 20_000 - ); + ownerTimer = creativeWindow.setTimeout(() => fail('settlement timeout'), 20_000); registeredPort.onmessage = receiveControl; - registeredPort.onmessageerror = () => finish(false, 'TS render owner channel failed'); + registeredPort.onmessageerror = () => fail('channel failed'); try { registeredPort.start(); } catch { - finish(false, 'TS render owner channel failed'); + fail('channel failed'); } }; const registrationTimer = creativeWindow.setTimeout( - () => finish(false, 'TS render owner registration timeout'), + () => fail('registration timeout'), 3_000 ); try { @@ -514,13 +458,13 @@ function installPucDynamicOwner(): void { receiveRegistration, ]) as unknown; if (typeof disposer !== 'function') { - finish(false, 'TS render owner registration failed'); + fail('registration failed'); return; } helperDisposer = disposer as () => void; if (registrationFinished) stopHelper(); } catch { - finish(false, 'TS render owner registration failed'); + fail('registration failed'); } }); } diff --git a/crates/trusted-server-js/lib/src/kernel/identity.ts b/crates/trusted-server-js/lib/src/kernel/identity.ts index d00002dc2..a3538914f 100644 --- a/crates/trusted-server-js/lib/src/kernel/identity.ts +++ b/crates/trusted-server-js/lib/src/kernel/identity.ts @@ -27,6 +27,12 @@ export interface NavigationIdentityIssuer { readonly snapshotOrdinalForTest: () => readonly [number, number]; } +/** Initial-display-only issuer; the projected batch is bounded to 256 attempts. */ +export interface FirstDisplayNavigationIdentityIssuer { + readonly mintAttemptId: () => IdentityGenerationResult; + readonly snapshotPrefix: () => string; +} + /** Test-only controls for a deterministic navigation identity issuer. */ export interface TestNavigationIdentityIssuerOptions { readonly getRandomValues?: RandomValuesSource; @@ -210,6 +216,59 @@ export function createNavigationIdentityIssuerFromSource( return createNavigationIdentityIssuer(source, [0, 0], observer); } +/** + * Mint the same wire identities without bundling persistent adoption and u64 + * rollover machinery into the protected first-display artifact. + */ +export function createFirstDisplayNavigationIdentityIssuerFromSource( + source: RandomValuesSource, + observer?: IdentityFailureObserver +): IdentityGenerationResult { + const prefixResult = randomBytes(8, source, observer); + if (!prefixResult.ok) return prefixResult; + let prefix: Uint8Array; + try { + prefix = new Uint8Array(8); + prefix.set(prefixResult.value); + } catch { + return reportFailure(observer); + } + let ordinal = 0; + return Object.freeze({ + ok: true, + value: Object.freeze({ + mintAttemptId: (): IdentityGenerationResult => { + if (ordinal >= 256) return reportFailure(observer); + try { + const next = ordinal + 1; + const identity = new Uint8Array(16); + identity.set(prefix); + new DataView(identity.buffer).setUint32(12, next, false); + if ( + identity.byteLength !== 16 || + identity[12] !== 0 || + identity[13] !== 0 || + identity[14] !== next >>> 8 || + identity[15] !== (next & 0xff) + ) { + return reportFailure(observer); + } + for (let index = 0; index < prefix.length; index += 1) { + if (identity[index] !== prefix[index]) return reportFailure(observer); + } + const encoded = encodeBase64Url(identity); + if (encoded.length !== 22) return reportFailure(observer); + ordinal = next; + return Object.freeze({ ok: true, value: `a1_${encoded}` }); + } catch { + return reportFailure(observer); + } + }, + snapshotPrefix: () => encodeBase64Url(prefix), + }), + }); +} + function browserRandomSource(): RandomValuesSource | undefined { try { if (typeof crypto !== 'object' || typeof crypto.getRandomValues !== 'function') { diff --git a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts index a8241084b..fbb071d9c 100644 --- a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts +++ b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts @@ -1,6 +1,6 @@ import type { BootManifestV1 } from '../core/types'; import { - snapshotOutlinedFirstDisplayHandoffV1, + snapshotOutlinedFirstDisplayCaptureV1, type FirstDisplayHandoffV1, } from '../shared/first_display_contracts'; import { snapshotPersistentFirstDisplayAdoptionV1 } from '../shared/takeover'; @@ -9,6 +9,7 @@ import type { ReleaseConfigSourceV1 } from './release_catalog'; import { EMBEDDED_MAX_MANIFEST_MODULES } from './contracts/release_capacity'; import { DisposableStack, type DisposeCallback } from './disposable'; import { trustedArtifactOrigin, type BootFailureReason } from './fallback'; +import { createBrowserNavigationIdentityIssuer } from './identity'; const INTEGRATION_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; const RELEASE_ID = /^[0-9a-f]{64}$/; @@ -89,7 +90,8 @@ export interface PreparedKernelTakeover { /** Validate and freeze the complete handoff before either owner mutates state. */ readonly validateHandoff: ( handoff: unknown, - outline: unknown + outline: unknown, + boot?: unknown ) => FirstDisplayHandoffV1 | undefined; readonly activate: (adoption?: unknown) => void; readonly commit: () => void; @@ -1431,9 +1433,16 @@ class IntegrationRegistryOwner { let committed: IntegrationKernelResult | undefined; let validatedHandoff: FirstDisplayHandoffV1 | undefined; const prepared: PreparedKernelTakeover = Object.freeze({ - validateHandoff: (candidate: unknown, outlineCandidate: unknown) => { + validateHandoff: (candidate: unknown, outlineCandidate: unknown, bootCandidate?: unknown) => { if (activated || committed || validatedHandoff) return undefined; - const handoff = snapshotOutlinedFirstDisplayHandoffV1(candidate, outlineCandidate); + const identityIssuer = createBrowserNavigationIdentityIssuer(); + if (!identityIssuer.ok) return undefined; + const handoff = snapshotOutlinedFirstDisplayCaptureV1( + candidate, + outlineCandidate, + bootCandidate, + identityIssuer.value + ); if (!handoff) return undefined; validatedHandoff = handoff; return handoff; diff --git a/crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts b/crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts index e2e56d8de..1081f1d49 100644 --- a/crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts +++ b/crates/trusted-server-js/lib/src/kernel/lifecycle_module.ts @@ -2,13 +2,13 @@ import { type PersistentFirstDisplaySliceStateV1, validatePersistentFirstDisplaySliceAdoptionV1, } from '../shared/takeover'; +import { EMBEDDED_RUNTIME_CATALOG } from '../core/release'; import type { IntegrationActivationContext, IntegrationPrepareContext, IntegrationRegistration, } from './integration_registry'; -import { RELEASE_CATALOG } from './release_catalog'; const MAX_CONFIG_DEPTH = 16; const MAX_CONFIG_NODES = 512; @@ -109,7 +109,7 @@ export function createLifecycleIntegrationRegistration( releaseId: string, options: LifecycleIntegrationRegistrationOptions = {} ): IntegrationRegistration { - const catalogEntry = RELEASE_CATALOG.find((entry) => entry.id === id); + const catalogEntry = EMBEDDED_RUNTIME_CATALOG.find((entry) => entry.id === id); if (!catalogEntry) throw new TypeError(`Unknown release catalog module: ${id}`); const prepare = (context: IntegrationPrepareContext) => { const { config, interfaces } = context; diff --git a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts index 89deec0e5..aeb6db7d4 100644 --- a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts +++ b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts @@ -152,7 +152,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', 'first_display/render_journal', 'kernel/contracts/puc_dynamic_owner', ], @@ -168,7 +167,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', 'first_display/leaf/aps_protocol', 'first_display/render_bridge', ], @@ -183,7 +181,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', 'first_display/leaf/creative_guard', ], inputs: ['first_display.control.v1'], @@ -197,7 +194,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', + 'first_display/leaf/browser_route_owner', 'first_display/leaf/route_guard', ], inputs: ['first_display.control.v1'], @@ -211,7 +208,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', 'first_display/leaf/config_guard', ], inputs: ['first_display.control.v1'], @@ -225,7 +221,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', + 'first_display/leaf/browser_route_owner', 'first_display/leaf/route_guard', ], inputs: ['first_display.control.v1'], @@ -239,7 +235,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', 'first_display/adapters/googletag', 'first_display/leaf/gpt_protocol', ], @@ -254,7 +249,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', + 'first_display/leaf/browser_route_owner', 'first_display/leaf/route_guard', ], inputs: ['first_display.control.v1'], @@ -268,7 +263,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', 'first_display/leaf/consent_snapshot', ], inputs: ['first_display.control.v1'], @@ -282,7 +276,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', + 'first_display/leaf/browser_route_owner', 'first_display/leaf/context_snapshot', ], inputs: ['first_display.control.v1'], @@ -296,7 +290,7 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', + 'first_display/leaf/browser_route_owner', 'first_display/leaf/consent_snapshot', ], inputs: ['first_display.control.v1'], @@ -310,7 +304,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', 'first_display/leaf/prebid_protocol', ], inputs: ['first_display.control.v1', 'gpt.initial.v1'], @@ -325,7 +318,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/slices/definition', 'first_display/leaf/callback_capture', ], inputs: ['first_display.control.v1'], diff --git a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts index 6e0060678..44a5f7195 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts @@ -1230,6 +1230,323 @@ export function snapshotOutlinedFirstDisplayHandoffV1( return handoff; } +/** + * Expand the compact old-owner capture after the persistent bundle is available, + * then run the unchanged canonical handoff validator before either owner detaches. + */ +export function snapshotOutlinedFirstDisplayCaptureV1( + candidate: unknown, + outlineCandidate: unknown, + bootCandidate: unknown, + identityIssuer?: Readonly<{ + mintAttemptId: () => Readonly<{ ok: boolean; value?: string }>; + snapshotPrefix: () => string; + }> +): FirstDisplayHandoffV1 | undefined { + try { + const capture = exactRecord(candidate, [ + 'captureVersion', + 'releaseId', + 'generation', + 'data', + 'mutationRevision', + 'identityCount', + ]); + const data = exactArray(capture?.data, 14); + const boot = exactRecord(bootCandidate, [ + 'abi', + 'releaseId', + 'manifest', + 'auctionProjection', + 'integrations', + 'creative', + 'diagnostics', + ]); + const projection = exactRecord(boot?.auctionProjection, [ + 'version', + 'auction', + 'slots', + 'bids', + ]); + const auction = exactRecord(projection?.auction, ['version', 'auctionId', 'results']); + const placements = exactArray(projection?.slots, MAX_FIRST_DISPLAY_SLOTS); + const bids = exactArray(projection?.bids, MAX_FIRST_DISPLAY_SLOTS); + const decisions = exactArray(auction?.results, MAX_FIRST_DISPLAY_SLOTS); + const results = exactArray(data?.[3], MAX_FIRST_DISPLAY_SLOTS); + const bindings = exactArray(data?.[4], MAX_FIRST_DISPLAY_SLOTS); + const rawTombstones = exactArray(data?.[5], 512); + const rawArtifacts = exactArray(data?.[6], MAX_FIRST_DISPLAY_SLOTS); + const rawParserState = exactArray(data?.[7], FIRST_DISPLAY_CONTRACT_IDS.length); + const diagnostics = exactArray(data?.[8], 3); + const timing = exactArray(data?.[9], 4); + const highWater = exactArray(data?.[10], 4); + const cycles = exactArray(data?.[11], MAX_FIRST_DISPLAY_SLOTS); + if ( + !capture || + capture.captureVersion !== 1 || + !data || + data.length !== 14 || + !boot || + boot.abi !== 1 || + !projection || + projection.version !== 1 || + !auction || + auction.version !== 1 || + !placements || + !bids || + !decisions || + !results || + results.length !== placements.length || + decisions.length !== placements.length || + !bindings || + !rawTombstones || + !rawArtifacts || + !rawParserState || + !diagnostics || + diagnostics.length !== 3 || + !timing || + timing.length !== 4 || + !highWater || + highWater.length !== 4 || + !cycles || + !Number.isInteger(capture.identityCount) || + !identityIssuer + ) { + return undefined; + } + const bidBySlot = new Map>>(); + for (const value of bids) { + const bid = exactRecord( + value, + Object.prototype.hasOwnProperty.call(value, 'creativeId') + ? [ + 'candidateId', + 'slot', + 'provider', + 'upstreamBidId', + 'cpm', + 'currency', + 'targeting', + 'rendererReservationId', + 'renderSource', + 'creativeId', + ] + : [ + 'candidateId', + 'slot', + 'provider', + 'upstreamBidId', + 'cpm', + 'currency', + 'targeting', + 'rendererReservationId', + 'renderSource', + ] + ); + if (!bid || typeof bid.slot !== 'string' || bidBySlot.has(bid.slot)) return undefined; + bidBySlot.set(bid.slot, bid); + } + const bindingBySlot = new Map>>(); + for (const value of bindings) { + const fields = exactArray(value, 5); + if (!fields || fields.length !== 5 || typeof fields[0] !== 'string') return undefined; + const binding = { + slotId: fields[0], + domId: fields[1], + owner: fields[2], + targetingOwnership: fields[3], + gptToken: fields[4], + }; + if (bindingBySlot.has(binding.slotId)) { + return undefined; + } + bindingBySlot.set(binding.slotId, binding); + } + const tombstones: Readonly>[] = []; + for (const value of rawTombstones) { + const fields = exactArray(value, 4); + if (!fields || fields.length !== 4) return undefined; + tombstones.push({ + kind: fields[0], + value: fields[1], + expiresAtMs: fields[2], + ordinal: fields[3], + }); + } + const artifacts: Readonly>[] = []; + for (const value of rawArtifacts) { + const fields = exactArray(value, 6); + if (!fields || fields.length !== 6) return undefined; + artifacts.push({ + hostPosition: fields[0], + hostPositionPriority: fields[1], + slotId: fields[2], + kind: fields[3], + owner: fields[4], + token: fields[5], + }); + } + const parserState: Readonly>[] = []; + for (const value of rawParserState) { + const fields = exactArray(value, 2); + const values = exactArray(fields?.[1], 256); + if (!fields || fields.length !== 2 || !values) return undefined; + const observations: unknown[] = []; + for (const value of values) { + const pair = exactArray(value, 2); + if (!pair || pair.length !== 2) return undefined; + observations.push(pair[0]); + } + parserState.push({ sliceId: fields[0], observations, values }); + } + let acceptedBindings = 0; + const slots: Readonly>[] = []; + const attempts: Readonly>[] = []; + const traceSlots: Readonly>[] = []; + for (let index = 0; index < placements.length; index += 1) { + const placement = exactRecord(placements[index], [ + 'slot', + 'gamUnitPath', + 'divId', + 'formats', + 'targeting', + ]); + const decision = exactRecord( + decisions[index], + Object.prototype.hasOwnProperty.call(decisions[index], 'candidateId') + ? ['slot', 'outcome', 'candidateId'] + : Object.prototype.hasOwnProperty.call(decisions[index], 'reason') + ? ['slot', 'outcome', 'reason'] + : ['slot', 'outcome'] + ); + const result = exactArray(results[index], 4); + const slotId = placement?.slot; + if ( + !placement || + typeof slotId !== 'string' || + !decision || + decision.slot !== slotId || + !result || + result.length !== 4 + ) { + return undefined; + } + const bid = bidBySlot.get(slotId); + const renderSource = bid?.renderSource as Readonly> | undefined; + const projectedKind = + decision.outcome === 'no_bid' + ? 'no_bid' + : decision.outcome === 'failed' + ? 'failed' + : renderSource?.type === 'aps' + ? 'aps' + : renderSource?.type === 'adm' + ? 'gpt_adm' + : undefined; + if (!projectedKind) return undefined; + const binding = bindingBySlot.get(slotId); + const accepted = result[0] === 'accepted'; + if (accepted !== Boolean(binding) || (accepted && !bid)) return undefined; + if (!accepted && (result[2] !== null || result[3] !== null)) return undefined; + if (binding) acceptedBindings += 1; + const identity = identityIssuer.mintAttemptId(); + if (!identity.ok || typeof identity.value !== 'string') return undefined; + attempts.push({ + id: identity.value, + slotId, + ordinal: index + 1, + state: result[0], + reason: result[1], + }); + const targeting = { + ...(placement.targeting as Record), + ...(bid?.targeting as Record | undefined), + }; + if (bid) targeting['hb_adid'] = bid.rendererReservationId as string; + slots.push({ + id: slotId, + aliases: [], + domId: binding?.domId ?? placement.divId, + gamPath: placement.gamUnitPath, + formats: placement.formats, + owner: binding?.owner ?? 'trusted_server', + outcome: result[0], + targeting: Object.keys(targeting) + .sort() + .map((key) => [key, targeting[key]]), + targetingOwnership: binding?.targetingOwnership ?? [], + committedArtifact: + accepted && (projectedKind === 'gpt_adm' || projectedKind === 'aps') + ? projectedKind + : 'none', + gptToken: binding?.gptToken ?? null, + }); + traceSlots.push({ + slotId, + impressions: accepted ? 1 : 0, + bindings: accepted + ? [ + { + atMs: result[2], + cycleOrdinal: 1, + historySequence: result[3], + state: 'completed', + token: binding?.gptToken, + }, + ] + : [], + }); + } + if (bindingBySlot.size !== acceptedBindings) return undefined; + const expanded = { + version: 1, + releaseId: capture.releaseId, + generation: capture.generation, + projectionDigest: data[0], + integrationConfigDigest: data[1], + slices: data[2], + slots, + attempts, + tombstones, + artifacts, + parserState, + gptDiagnostics: { + facts: diagnostics[0], + overflowCount: diagnostics[1], + dropCount: diagnostics[2], + }, + timing: { + bidsScriptMs: timing[0], + firstDisplayMs: timing[1], + terminalMs: timing[2], + paintMs: timing[3], + }, + highWater: { + navigationAttemptPrefix: identityIssuer.snapshotPrefix(), + nextNavigationAttemptOrdinal: attempts.length + 1, + nextAttemptOrdinal: attempts.length + 1, + nextSlotRegistrationOrdinal: highWater[0], + reservationClockEpochMs: highWater[1], + nextReservationOrdinal: highWater[2], + nextTicketOrdinal: highWater[3], + }, + cycles, + trace: { + nextSequence: data[12], + nextGlobalSlotOrdinal: data[13], + slots: traceSlots, + }, + mutationRevision: capture.mutationRevision, + }; + const accepted = snapshotOutlinedFirstDisplayHandoffV1(expanded, outlineCandidate); + return accepted && capture.identityCount === accepted.cycles.length + accepted.artifacts.length + ? accepted + : undefined; + } catch { + return undefined; + } +} + /** Mint a closure-private, release/generation-bound one-use object-identity capsule. */ export function createFirstDisplayOwnershipCapsuleV1( releaseId: string, diff --git a/crates/trusted-server-js/lib/src/shared/first_display_handoff.ts b/crates/trusted-server-js/lib/src/shared/first_display_handoff.ts index 726133cf3..24f98367b 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_handoff.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_handoff.ts @@ -1,5 +1,12 @@ import type { BootFailureReason } from '../kernel/fallback_surface'; import type { PreparedKernelTakeover } from '../kernel/integration_registry'; +import type { FirstDisplaySliceId } from '../kernel/release_catalog'; +import type { FirstDisplayRenderCaptureV1 } from '../first_display/driver'; +import type { + FirstDisplayGptCaptureCycleV1, + FirstDisplayGptDiagnosticsCaptureV1, +} from '../first_display/leaf/gpt_protocol'; +import type { FirstDisplayProjectedKind } from '../first_display/leaf/projection'; import type { FirstDisplayHandoffV1, @@ -8,66 +15,30 @@ import type { const HASH = /^[0-9a-f]{64}$/; const MAX_U32 = 4_294_967_295; -const HANDOFF_FIELDS = Object.freeze([ - 'version', - 'releaseId', - 'generation', - 'projectionDigest', - 'integrationConfigDigest', - 'slices', - 'slots', - 'attempts', - 'tombstones', - 'artifacts', - 'parserState', - 'gptDiagnostics', - 'timing', - 'highWater', - 'cycles', - 'trace', - 'mutationRevision', -]); - /** Seal inert ordinary data before the runtime download; semantic authority stays at takeover. */ -function snapshotFirstDisplayHandoffEnvelopeV1( +export function snapshotFirstDisplayHandoffEnvelopeV1( candidate: unknown -): FirstDisplayHandoffV1 | undefined { +): Readonly> | undefined { try { const serialized = JSON.stringify(candidate); if (typeof serialized !== 'string' || serialized.length > 9 * 1024 * 1024) return undefined; - const snapshot = JSON.parse(serialized) as unknown; - if (typeof snapshot !== 'object' || snapshot === null || Array.isArray(snapshot)) { - return undefined; - } - const handoff = snapshot as unknown as FirstDisplayHandoffV1; - const keys = Reflect.ownKeys(snapshot); - if ( - keys.length !== HANDOFF_FIELDS.length || - !keys.every((key) => typeof key === 'string' && HANDOFF_FIELDS.includes(key)) || - handoff.version !== 1 || - !HASH.test(handoff.releaseId) || - !Number.isInteger(handoff.generation) || - handoff.generation < 1 || - handoff.generation > MAX_U32 || - !HASH.test(handoff.projectionDigest) || - !HASH.test(handoff.integrationConfigDigest) || - !Number.isInteger(handoff.mutationRevision) || - handoff.mutationRevision < 0 || - handoff.mutationRevision > MAX_U32 || - !Array.isArray(handoff.slices) || - !Array.isArray(handoff.slots) || - !Array.isArray(handoff.attempts) || - !Array.isArray(handoff.tombstones) || - !Array.isArray(handoff.artifacts) || - !Array.isArray(handoff.parserState) || - !Array.isArray(handoff.cycles) || - handoff.attempts.some( - (attempt) => !['accepted', 'no_bid', 'failed', 'cancelled'].includes(String(attempt.state)) - ) - ) { - return undefined; - } - return Object.freeze(handoff); + const handoff = JSON.parse(serialized) as Record; + return handoff && + !Array.isArray(handoff) && + handoff['captureVersion'] === 1 && + typeof handoff['releaseId'] === 'string' && + HASH.test(handoff['releaseId']) && + Number.isInteger(handoff['generation']) && + (handoff['generation'] as number) >= 1 && + (handoff['generation'] as number) <= MAX_U32 && + Number.isInteger(handoff['mutationRevision']) && + (handoff['mutationRevision'] as number) >= 0 && + (handoff['mutationRevision'] as number) <= MAX_U32 && + Number.isInteger(handoff['identityCount']) && + (handoff['identityCount'] as number) >= 0 && + (handoff['identityCount'] as number) <= 512 + ? Object.freeze(handoff) + : undefined; } catch { return undefined; } @@ -89,7 +60,7 @@ export interface FirstDisplayHandoffOwnerOptions { } export interface FinalizedFirstDisplayHandoffV1 { - readonly handoff: FirstDisplayHandoffV1; + readonly handoff: Readonly>; readonly capsule: FirstDisplayOwnershipCapsuleV1; } @@ -106,6 +77,7 @@ export interface FirstDisplayHandoffOwner { export interface FirstDisplayTakeoverOptions { readonly finalized: FinalizedFirstDisplayHandoffV1; readonly outline: unknown; + readonly boot?: unknown; readonly isCurrentGeneration: () => boolean; readonly authenticateRuntimeScript: () => boolean; readonly currentMutationRevision: () => number; @@ -115,7 +87,8 @@ export interface FirstDisplayTakeoverOptions { /** Persistent-core validator; executes before either owner mutates state. */ readonly validateHandoff: ( handoff: unknown, - outline: unknown + outline: unknown, + boot?: unknown ) => FirstDisplayHandoffV1 | undefined; readonly activatePersistent: ( handoff: FirstDisplayHandoffV1, @@ -133,7 +106,44 @@ export interface PreparedFirstDisplayTakeoverOptions extends Omit< readonly prepared: PreparedKernelTakeover; } -function createFirstDisplayOwnershipCapsuleV1( +/** Stack-local old-owner state accepted only by the release-matched persistent finalizer. */ +export type FirstDisplayAgentCaptureSourceV1 = readonly [ + handoff: Readonly<{ + releaseId: string; + generation: number; + integrationConfigDigest: string; + slices: readonly FirstDisplaySliceId[]; + }>, + batch: readonly [ + projectionDigest: string, + outcomes: readonly (readonly [slotId: string, kind: FirstDisplayProjectedKind])[], + ], + slotResults: ReadonlyMap, + reasons: ReadonlyMap, + acceptedTrace: ReadonlyMap>, + parserState: readonly (readonly [ + string, + readonly (readonly [string, string | number | boolean | null])[], + ])[], + gptCycles: readonly FirstDisplayGptCaptureCycleV1[] | undefined, + diagnostics: FirstDisplayGptDiagnosticsCaptureV1 | undefined, + render: FirstDisplayRenderCaptureV1 | undefined, + timing: readonly [ + startedAtMs: number, + firstActionAtMs: number | null, + terminalAtMs: number, + paintAtMs: number, + currentTimeMs: number, + ], + nextTraceSequence: number, + mutationRevision: number, +]; + +export type FirstDisplayAgentCaptureFinalizerV1 = ( + source: FirstDisplayAgentCaptureSourceV1 +) => FinalizedFirstDisplayHandoffV1 | undefined; + +export function createFirstDisplayOwnershipCapsuleV1( releaseId: string, generation: number, identities: readonly T[] @@ -172,6 +182,107 @@ function createFirstDisplayOwnershipCapsuleV1( }); } +/** Materialize the compact data envelope and one-use identities in the takeover task. */ +export function finalizeFirstDisplayAgentCaptureV1( + source: FirstDisplayAgentCaptureSourceV1 +): FinalizedFirstDisplayHandoffV1 | undefined { + try { + const [ + handoffSource, + batch, + slotResults, + reasons, + acceptedTrace, + parserState, + gptCycles, + diagnosticsSource, + renderSource, + timing, + nextTraceSequence, + mutationRevision, + ] = source; + const cycles = gptCycles ?? Object.freeze([]); + const diagnostics = + diagnosticsSource ?? Object.freeze([Object.freeze([]), Object.freeze([]), 1, 0, 0] as const); + const render = + renderSource ?? + Object.freeze([Object.freeze([]), Object.freeze([]), timing[4], 1, 1] as const); + if ( + diagnostics[0].length !== cycles.length || + diagnostics[0].some((cycle) => { + const physical = cycles.find((candidate) => candidate[0] === cycle[0]); + return !physical || physical[4] !== cycle[1]; + }) + ) { + return undefined; + } + const identities = [ + ...cycles.map((cycle) => cycle[5]), + ...render[0].map((artifact) => artifact[2]), + ]; + const capsule = createFirstDisplayOwnershipCapsuleV1( + handoffSource.releaseId, + handoffSource.generation, + identities + ); + if (!capsule) return undefined; + const results = batch[1].map(([slotId]) => { + const accepted = acceptedTrace.get(slotId); + return [ + slotResults.get(slotId) ?? 'failed', + reasons.get(slotId) ?? null, + accepted?.atMs ?? null, + accepted?.historySequence ?? null, + ]; + }); + const handoff = Object.freeze({ + captureVersion: 1, + releaseId: handoffSource.releaseId, + generation: handoffSource.generation, + data: [ + batch[0], + handoffSource.integrationConfigDigest, + handoffSource.slices, + results, + cycles.map((cycle) => cycle.slice(0, 5)), + render[1], + render[0].map((artifact) => [ + artifact[0], + artifact[1], + artifact[5], + artifact[3], + artifact[4], + artifact[6], + ]), + parserState, + [diagnostics[1], diagnostics[3], diagnostics[4]], + timing.slice(0, 4), + [results.length + 1, render[2], render[3], render[4]], + diagnostics[0].map((cycle) => ({ + slotId: cycle[0], + token: cycle[1], + nextCycleOrdinal: cycle[2], + unknownPriorCycle: cycle[3], + quarantines: cycle[4], + records: cycle[5].map((record) => ({ + ordinal: record[0], + responseIdentifier: record[1], + seen: record[2], + state: record[3], + })), + })), + nextTraceSequence, + diagnostics[2], + ], + mutationRevision, + identityCount: identities.length, + }); + return Object.freeze({ handoff, capsule }); + } catch { + return undefined; + } +} + /** * Own the final old-epoch revision and one-use object capsule. `finalize` is * deliberately synchronous: after ingress closes, no browser task can interleave @@ -248,10 +359,10 @@ export function createFirstDisplayHandoffOwner( const handoff = snapshotFirstDisplayHandoffEnvelopeV1(captured.candidate); if ( !handoff || - handoff.releaseId !== options.releaseId || - handoff.generation !== options.generation || - handoff.mutationRevision !== revision || - captured.identities.length !== handoff.cycles.length + handoff.artifacts.length || + handoff['releaseId'] !== options.releaseId || + handoff['generation'] !== options.generation || + handoff['mutationRevision'] !== revision || + captured.identities.length !== handoff['identityCount'] || new Set(captured.identities).size !== captured.identities.length ) { return publishFailure(); @@ -306,7 +417,11 @@ export function performFirstDisplayTakeoverV1(options: FirstDisplayTakeoverOptio }; try { - const handoff = options.validateHandoff(options.finalized.handoff, options.outline); + const handoff = options.validateHandoff( + options.finalized.handoff, + options.outline, + options.boot + ); const { capsule } = options.finalized; if ( !handoff || @@ -358,6 +473,7 @@ export function coordinatePreparedFirstDisplayTakeoverV1( return performFirstDisplayTakeoverV1({ finalized: options.finalized, outline: options.outline, + ...(options.boot === undefined ? {} : { boot: options.boot }), validateHandoff: options.prepared.validateHandoff, isCurrentGeneration: options.isCurrentGeneration, authenticateRuntimeScript: options.authenticateRuntimeScript, diff --git a/crates/trusted-server-js/lib/src/shared/first_display_registration.ts b/crates/trusted-server-js/lib/src/shared/first_display_registration.ts index 04b4d44c2..d6c73aa0e 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_registration.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_registration.ts @@ -29,7 +29,11 @@ export interface FirstDisplayComponentRegistrationV1 { readonly releaseId: string; /** Absolute catalog order, not the component's position in one selected mask. */ readonly order: number; - readonly prepare: (host: unknown) => unknown; + readonly install: ( + bindings: unknown, + own: (dispose: () => void) => void, + config: unknown + ) => unknown; } type FirstDisplayBootstrapTarget = object & { @@ -172,7 +176,7 @@ export function snapshotFirstDisplayComponentRegistration( keys.length !== 5 || !keys.every( (key) => - typeof key === 'string' && ['abi', 'id', 'releaseId', 'order', 'prepare'].includes(key) + typeof key === 'string' && ['abi', 'id', 'releaseId', 'order', 'install'].includes(key) ) ) { return undefined; @@ -189,7 +193,7 @@ export function snapshotFirstDisplayComponentRegistration( !Number.isInteger(registration.order) || registration.order < 1 || registration.order > 14 || - typeof registration.prepare !== 'function' + typeof registration.install !== 'function' ) { return undefined; } @@ -198,7 +202,7 @@ export function snapshotFirstDisplayComponentRegistration( id: registration.id, releaseId: registration.releaseId, order: registration.order, - prepare: registration.prepare, + install: registration.install, }); } catch { return undefined; @@ -285,14 +289,14 @@ export function registerFirstDisplayComponent( export function firstDisplayComponentRegistration( id: string, order: number, - prepare: FirstDisplayComponentRegistrationV1['prepare'] + install: FirstDisplayComponentRegistrationV1['install'] ): FirstDisplayComponentRegistrationV1 { return Object.freeze({ abi: 1, id, releaseId: __TSJS_EMBEDDED_RELEASE_ID_V1__, order, - prepare, + install, }); } diff --git a/crates/trusted-server-js/lib/src/shared/takeover.ts b/crates/trusted-server-js/lib/src/shared/takeover.ts index 50d20940f..eb5ab4cf1 100644 --- a/crates/trusted-server-js/lib/src/shared/takeover.ts +++ b/crates/trusted-server-js/lib/src/shared/takeover.ts @@ -1,4 +1,9 @@ -import type { PreparedKernelTakeover } from '../kernel/integration_registry'; +import type { BootFailureReason } from '../kernel/fallback_surface'; + +import type { + FinalizedFirstDisplayHandoffV1, + FirstDisplayAgentCaptureFinalizerV1, +} from './first_display_handoff'; export type FirstDisplayGptDiagnosticEventV1 = | 'slotRequested' @@ -265,20 +270,35 @@ export function validatePersistentFirstDisplaySliceAdoptionV1( export const FIRST_DISPLAY_TAKEOVER_FIELD = '_firstDisplayTakeover' as const; -export type FirstDisplayTakeoverCallback = (prepared: PreparedKernelTakeover) => void; +/** One-use old-owner lease claimed only after persistent preparation is complete. */ +export type ClaimedFirstDisplayTakeoverV1 = readonly [ + finalized: FinalizedFirstDisplayHandoffV1, + outline: unknown, + isCurrentGeneration: () => boolean, + authenticateRuntimeScript: () => boolean, + currentMutationRevision: () => number, + detachCommittedArtifacts: () => boolean, + disposeAgent: () => void, + onFailure: (reason: BootFailureReason) => void, + onCommit: () => void, +]; + +export type FirstDisplayTakeoverClaim = ( + finalize: FirstDisplayAgentCaptureFinalizerV1 +) => ClaimedFirstDisplayTakeoverV1 | undefined; export type FirstDisplayTakeoverTransportResult = | Readonly<{ status: 'absent' }> | Readonly<{ status: 'invalid' }> - | Readonly<{ status: 'accepted'; coordinate: FirstDisplayTakeoverCallback }>; + | Readonly<{ status: 'accepted'; claim: FirstDisplayTakeoverClaim }>; /** Install one non-enumerable, one-use handoff sink shared only by bootstrap and core IIFEs. */ export function installFirstDisplayTakeoverTransport( target: object, - coordinate: FirstDisplayTakeoverCallback + claim: FirstDisplayTakeoverClaim ): (() => void) | undefined { if ( - typeof coordinate !== 'function' || + typeof claim !== 'function' || Object.getOwnPropertyDescriptor(target, FIRST_DISPLAY_TAKEOVER_FIELD) ) { return undefined; @@ -287,7 +307,7 @@ export function installFirstDisplayTakeoverTransport( Object.defineProperty(target, FIRST_DISPLAY_TAKEOVER_FIELD, { configurable: true, enumerable: false, - value: coordinate, + value: claim, writable: false, }); } catch { @@ -299,7 +319,7 @@ export function installFirstDisplayTakeoverTransport( live = false; try { const descriptor = Object.getOwnPropertyDescriptor(target, FIRST_DISPLAY_TAKEOVER_FIELD); - if (descriptor && 'value' in descriptor && descriptor.value === coordinate) { + if (descriptor && 'value' in descriptor && descriptor.value === claim) { Reflect.deleteProperty(target, FIRST_DISPLAY_TAKEOVER_FIELD); } } catch { @@ -329,7 +349,7 @@ export function consumeFirstDisplayTakeoverTransport( } return Object.freeze({ status: 'accepted', - coordinate: descriptor.value as FirstDisplayTakeoverCallback, + claim: descriptor.value as FirstDisplayTakeoverClaim, }); } catch { return Object.freeze({ status: 'invalid' }); diff --git a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs index 2205476ef..a8ff3456f 100644 --- a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs +++ b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs @@ -10,7 +10,9 @@ import { JSDOM } from 'jsdom'; const dist = path.resolve(import.meta.dirname, '../../../dist'); const manifest = JSON.parse(readFileSync(path.join(dist, 'tsjs-release-v1.json'), 'utf8')); const source = readFileSync(path.join(dist, 'tsjs-bootstrap.js'), 'utf8'); +const artifactById = new Map(manifest.artifacts.map((artifact) => [artifact.id, artifact])); const selectedSrc = `/static/tsjs=tsjs-first-display.min.js?m=0001&v=${'c'.repeat(64)}`; +const selectedGptSrc = `/static/tsjs=tsjs-first-display.min.js?m=0081&v=${'c'.repeat(64)}`; const runtimeSrc = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; const EMPTY_INTEGRATION_CONFIGS = Object.freeze({ version: 1, entries: Object.freeze([]) }); const EMPTY_INTEGRATION_CONFIG_DIGEST = createHash('sha256') @@ -18,9 +20,9 @@ const EMPTY_INTEGRATION_CONFIG_DIGEST = createHash('sha256') .digest('hex'); const plain = (value) => JSON.parse(JSON.stringify(value)); -function createDocument() { +function createDocument(firstDisplaySrc = selectedSrc) { const dom = new JSDOM( - ``, + ``, { runScripts: 'outside-only', url: 'https://publisher.example/page' } ); dom.window.TextEncoder = TextEncoder; @@ -29,12 +31,20 @@ function createDocument() { configurable: true, value: () => undefined, }); + const animationFrames = []; + Object.defineProperty(dom.window, 'requestAnimationFrame', { + configurable: true, + value: (callback) => { + animationFrames.push(callback); + return animationFrames.length; + }, + }); const selected = dom.window.document.querySelector('script#trustedserver-js'); Object.defineProperty(dom.window.document, 'currentScript', { configurable: true, value: selected, }); - return { dom, selected }; + return { animationFrames, dom, selected }; } function boot() { @@ -87,6 +97,32 @@ function outline() { }; } +function selectGpt(bootValue, outlineValue) { + bootValue.manifest.firstDisplay = { + src: selectedGptSrc, + slices: ['first_display', 'gpt_initial'], + }; + outlineValue.slices = ['first_display', 'gpt_initial']; +} + +function selectInitialSlice(bootValue, outlineValue, id, config) { + const firstDisplay = manifest.artifacts.filter((artifact) => + ['first_display_base', 'first_display_slice'].includes(artifact.role) + ); + const index = firstDisplay.findIndex((artifact) => artifact.id === id); + assert.ok(index > 0, `missing first-display slice ${id}`); + const mask = (1 | (1 << index)).toString(16).padStart(4, '0'); + const artifact = artifactById.get(id); + const body = readFileSync(path.join(dist, artifact.file), 'utf8'); + const hash = createHash('sha256').update(body).digest('hex'); + const src = `/static/tsjs=tsjs-first-display.min.js?m=${mask}&v=${hash}`; + bootValue.manifest.firstDisplay = { src, slices: ['first_display', id] }; + const product = id.slice(0, -'_initial'.length); + bootValue.integrations = { version: 1, entries: [{ id: product, config }] }; + outlineValue.slices = ['first_display', id]; + return { body, src }; +} + function transport(bootValue = boot(), outlineValue = outline()) { const integrity = { version: 1, @@ -124,7 +160,7 @@ test('generated bootstrap bytes are stamped exactly once and expose no callable assert.equal(dom.window.tsjs_bootstrap, undefined); assert.equal(dom.window.tsjs.boot.releaseId, manifest.releaseId); - assert.equal(typeof dom.window.tsjs._registerFirstDisplay, 'function'); + assert.equal(Object.hasOwn(dom.window.tsjs, '_registerFirstDisplay'), false); dom.window.close(); }); @@ -270,8 +306,8 @@ test('generated bootstrap completes a claimed boot without a reentrant realm cal dom.window.close(); }); -test('generated takeover completion leaves terminal fallback ownership with bootstrap', () => { - const { dom, selected } = createDocument(); +test('generated base-only takeover failure leaves terminal fallback ownership with bootstrap', () => { + const { animationFrames, dom } = createDocument(); const target = { que: [] }; dom.window.tsjs = target; evaluateWithInput(dom); @@ -279,26 +315,9 @@ test('generated takeover completion leaves terminal fallback ownership with boot const accepted = dom.window.document.createElement('iframe'); accepted.id = 'accepted-first-display'; dom.window.document.body.append(accepted); - const agent = { - state: 'painted', - snapshot: () => ({ initialDisplayCommitted: true }), - }; - const registration = { - abi: 1, - id: 'first_display', - releaseId: manifest.releaseId, - prepare: (host) => - Object.freeze({ - sliceHost: Object.freeze({}), - activate: () => host.options.onAgentReady(agent), - }), - }; - assert.equal(target._registerFirstDisplay.call(target, registration, selected), true); - - const runtime = dom.window.document.createElement('script'); - runtime.id = 'trustedserver-js-runtime'; - runtime.src = runtimeSrc; - dom.window.document.head.append(runtime); + while (animationFrames.length > 0) animationFrames.shift()(dom.window.performance.now()); + const runtime = dom.window.document.querySelector('script#trustedserver-js-runtime'); + assert.ok(runtime, 'the bootstrap-owned base must reach protected paint without a marker'); Object.defineProperty(dom.window.document, 'currentScript', { configurable: true, value: runtime, @@ -313,7 +332,7 @@ test('generated takeover completion leaves terminal fallback ownership with boot state: 'fallback', releaseId: manifest.releaseId, reason: 'bundle_partial', - initialDisplayCommitted: true, + initialDisplayCommitted: false, } ); assert.equal(target.boot.manifest.firstDisplay.src, selectedSrc); @@ -321,58 +340,135 @@ test('generated takeover completion leaves terminal fallback ownership with boot dom.window.close(); }); -test('generated bootstrap stops slice activation after a reentrant terminal callback', () => { - const { dom, selected } = createDocument(); +test('generated bootstrap stops optional activation after a component installer fails', () => { + const { dom, selected } = createDocument(selectedGptSrc); const target = { que: [] }; const events = []; - const src = `/static/tsjs=tsjs-first-display.min.js?m=0081&v=${'c'.repeat(64)}`; - selected.src = src; + let bindingKeys; const bootValue = boot(); - bootValue.manifest.firstDisplay = { - src, - slices: ['first_display', 'gpt_initial'], - }; const outlineValue = outline(); - outlineValue.slices = ['first_display', 'gpt_initial']; + selectGpt(bootValue, outlineValue); + bootValue.integrations = { version: 1, entries: [{ id: 'gpt', config: {} }] }; dom.window.tsjs = target; evaluateTransport(dom, transport(bootValue, outlineValue)); - const base = { - abi: 1, - id: 'first_display', - releaseId: manifest.releaseId, - prepare: (host) => - Object.freeze({ - sliceHost: Object.freeze({}), - activate: ({ own }) => { - own(() => events.push('dispose-base')); - events.push('activate-base'); - host.options.onFailure('bundle_partial'); - }, - }), - }; - const gpt = { - abi: 1, - id: 'gpt_initial', - releaseId: manifest.releaseId, - prepare: () => - Object.freeze({ - activate: () => events.push('activate-gpt'), - }), - }; + const gpt = Object.freeze([ + 1, + 'gpt_initial', + manifest.releaseId, + (bindings) => { + bindingKeys = Reflect.ownKeys(bindings); + events.push('install-gpt'); + throw new TypeError('invalid generated slice'); + }, + ]); - assert.equal(target._registerFirstDisplay.call(target, base, selected), true); assert.equal(target._registerFirstDisplay.call(target, gpt, selected), false); - assert.deepEqual(events, ['activate-base', 'dispose-base']); - assert.equal( - Object.getOwnPropertyDescriptor(target, '_internal')?.value.reason, - 'bundle_partial' - ); + assert.deepEqual(bindingKeys, ['browser', 'observe', 'register']); + assert.deepEqual(events, ['install-gpt']); + assert.equal(Object.getOwnPropertyDescriptor(target, '_internal')?.value.reason, 'abi_mismatch'); dom.window.close(); }); +test('generated optional slices receive their exact parser-time browser bindings', () => { + const fixtures = [ + ['datadome_initial', {}], + ['didomi_initial', { proxyPath: '/integrations/didomi/consent/' }], + ['google_tag_manager_initial', {}], + ['lockr_initial', {}], + ['osano_initial', {}], + ['permutive_initial', {}], + ['sourcepoint_initial', { rewriteSdk: true }], + ['testlight_initial', {}], + ]; + for (const [id, config] of fixtures) { + const bootValue = boot(); + const outlineValue = outline(); + const { body, src } = selectInitialSlice(bootValue, outlineValue, id, config); + const { dom } = createDocument(src); + const callback = () => undefined; + if (id === 'lockr_initial') dom.window.identityLockr = { host: 'vendor.example' }; + if (id === 'osano_initial') { + dom.window.__uspapi = (_command, _version, complete) => complete({ uspString: '1YN-' }, true); + dom.window.__gpp = (_command, complete) => + complete({ gppString: 'DBABLA~BVQqAAAAAgA.QA', applicableSections: [7] }, true); + dom.window.__tcfapi = (_command, _version, complete) => + complete( + { + tcString: + 'COwK6gaOwK6gaFmAAAENAPCAAAAAAAAAAAAAAAAAAAAA.IFMsv_Z_G____bvQXQ1f9eY1f9_z_q7ff_3_3-_-3dV1v9zLv9____39nP___9v-_3_f__9P', + }, + true + ); + } + if (id === 'permutive_initial') { + dom.window.permutive = { + config: { + apiHost: 'api.vendor.example', + apiProtocol: 'https', + cdnBaseUrl: 'cdn.vendor.example', + cdnProtocol: 'https', + secureSignalsApiHost: 'signals.vendor.example', + segmentSyncApiHost: 'sync.vendor.example', + }, + }; + dom.window.localStorage.setItem( + 'permutive-app', + JSON.stringify({ core: { cohorts: { all: ['one', 2] } } }) + ); + } + if (id === 'sourcepoint_initial') { + dom.window.localStorage.setItem( + '_sp_user_consent_test', + JSON.stringify({ gppData: { gppString: 'DBABLA', applicableSections: [7] } }) + ); + } + if (id === 'testlight_initial') dom.window.testlight = { que: [callback] }; + dom.window.tsjs = { que: [] }; + + evaluateTransport(dom, transport(bootValue, outlineValue)); + dom.window.eval(body); + + assert.equal( + Object.getOwnPropertyDescriptor(dom.window.tsjs, '_internal'), + undefined, + `${id} must not force fallback` + ); + if (id === 'datadome_initial' || id === 'google_tag_manager_initial') { + const script = dom.window.document.createElement('script'); + script.src = + id === 'datadome_initial' + ? 'https://js.datadome.co/tags.js' + : 'https://www.googletagmanager.com/gtm.js?id=GTM-TEST'; + dom.window.document.head.appendChild(script); + assert.match(script.src, /\/integrations\/(?:datadome|google_tag_manager)\//); + } + if (id === 'didomi_initial') { + assert.equal( + dom.window.didomiConfig.sdkPath, + 'https://publisher.example/integrations/didomi/consent/' + ); + } + if (id === 'lockr_initial') { + assert.equal( + dom.window.identityLockr.host, + 'https://publisher.example/integrations/lockr/api' + ); + } + if (id === 'permutive_initial') { + assert.equal( + dom.window.permutive.config.apiHost, + 'publisher.example/integrations/permutive/api' + ); + } + if (id === 'sourcepoint_initial') assert.match(dom.window.document.cookie, /__gpp=DBABLA/); + if (id === 'testlight_initial') assert.ok(dom.window.tsjs.que.includes(callback)); + dom.window.close(); + } +}); + test('generated bootstrap commits one non-rendering terminal shell after registration failure', async () => { - const { dom, selected } = createDocument(); + const { dom, selected } = createDocument(selectedGptSrc); const drained = []; const target = { que: [ @@ -383,7 +479,10 @@ test('generated bootstrap commits one non-rendering terminal shell after registr }; dom.window.tsjs = target; - evaluateWithInput(dom); + const bootValue = boot(); + const outlineValue = outline(); + selectGpt(bootValue, outlineValue); + evaluateTransport(dom, transport(bootValue, outlineValue)); assert.equal(dom.window.tsjs._registerFirstDisplay.call(target, {}, selected), false); assert.deepEqual(Object.keys(target).sort(), [ @@ -417,8 +516,10 @@ test('generated bootstrap commits one non-rendering terminal shell after registr assert.deepEqual(drained, [target, 'late']); const malformedUnit = dom.window.eval("({code:'',mediaTypes:{}})"); assert.throws(() => target.addAdUnits(malformedUnit), { - name: 'AdUnitRegistrationError', - code: 'invalid_code', + name: 'TsjsUnavailableError', + code: 'runtime_unavailable', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', }); const validUnit = dom.window.eval( "({code:'programmatic',mediaTypes:{banner:{sizes:[[300,250]]}}})" @@ -429,17 +530,28 @@ test('generated bootstrap commits one non-rendering terminal shell after registr releaseId: manifest.releaseId, reason: 'abi_mismatch', }); - assert.deepEqual(plain(await target.requestAds()), { slots: [] }); + await assert.rejects(target.requestAds(), { + name: 'TsjsUnavailableError', + code: 'runtime_unavailable', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', + }); const explicitOptions = dom.window.eval("({slots:['slot-1']})"); - assert.deepEqual(plain(await target.requestAds(explicitOptions)), { - slots: [{ slot: 'slot-1', path: 'primary', outcome: 'failed', reason: 'slot_unresolved' }], + await assert.rejects(target.requestAds(explicitOptions), { + name: 'TsjsUnavailableError', + code: 'runtime_unavailable', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', }); const controller = new dom.window.AbortController(); controller.abort(); const abortedOptions = dom.window.eval("({slots:['slot-1']})"); abortedOptions.signal = controller.signal; - assert.deepEqual(plain(await target.requestAds(abortedOptions)), { - slots: [{ slot: 'slot-1', path: 'primary', outcome: 'cancelled', reason: 'caller_aborted' }], + await assert.rejects(target.requestAds(abortedOptions), { + name: 'TsjsUnavailableError', + code: 'runtime_unavailable', + releaseId: manifest.releaseId, + reason: 'abi_mismatch', }); assert.deepEqual(plain(target.boot.auctionProjection), { version: 1, @@ -452,7 +564,7 @@ test('generated bootstrap commits one non-rendering terminal shell after registr }); test('generated bootstrap does not overwrite a conflicting non-configurable namespace field', () => { - const { dom, selected } = createDocument(); + const { dom, selected } = createDocument(selectedGptSrc); const target = { que: [] }; const publisherApi = () => undefined; Object.defineProperty(target, 'publisherApi', { @@ -463,7 +575,10 @@ test('generated bootstrap does not overwrite a conflicting non-configurable name }); dom.window.tsjs = target; - evaluateWithInput(dom); + const bootValue = boot(); + const outlineValue = outline(); + selectGpt(bootValue, outlineValue); + evaluateTransport(dom, transport(bootValue, outlineValue)); assert.equal(dom.window.tsjs._registerFirstDisplay.call(target, {}, selected), false); assert.equal(target.publisherApi, publisherApi); 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 520ed7256..1643880c4 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 @@ -238,7 +238,7 @@ test('release id printer validates the complete generated inventory', () => { assert.equal(printed, manifest.releaseId); }); -test('generated first-display components self-register through one authenticated artifact sink', () => { +test('generated optional first-display slices self-register through one authenticated sink', () => { const release = JSON.parse( fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') ); @@ -280,219 +280,16 @@ test('generated first-display components self-register through one authenticated .join(';\n') ); assert.deepEqual( - registrations.map(({ id, order }) => ({ id, order })), - firstDisplay.map(({ id }, index) => ({ id, order: index + 1 })) + registrations.map((registration) => registration[1]), + firstDisplay.slice(1).map(({ id }) => id) ); for (const registration of registrations) { - assert.deepEqual(Reflect.ownKeys(registration), [ - 'abi', - 'id', - 'releaseId', - 'order', - 'prepare', - ]); - assert.equal(registration.abi, 1); - assert.equal(registration.releaseId, release.releaseId); - assert.equal(typeof registration.prepare, 'function'); + assert.deepEqual(Reflect.ownKeys(registration), ['0', '1', '2', '3', 'length']); + assert.equal(registration[0], 1); + assert.equal(registration[2], release.releaseId); + assert.equal(typeof registration[3], 'function'); assert.equal(Object.isFrozen(registration), true); } - const baseHost = dom.window.eval( - 'Object.freeze({options:Object.freeze({}),sliceBindings:function(){return undefined;}})' - ); - const base = registrations[0].prepare(baseHost); - assert.deepEqual(Reflect.ownKeys(base), ['activate', 'sliceHost']); - assert.equal(Object.isFrozen(base), true); - assert.deepEqual(Reflect.ownKeys(base.sliceHost), ['activate']); - assert.equal(Object.isFrozen(base.sliceHost), true); - for (const registration of registrations.slice(1)) { - const prepared = registration.prepare(base.sliceHost); - assert.deepEqual(Reflect.ownKeys(prepared), ['activate']); - assert.equal(Object.isFrozen(prepared), true); - } - - dom.window.eval(` - window.__firstDisplayEvents = []; - window.__firstDisplayDisposers = []; - window.__firstDisplayAfterActivate = undefined; - window.__firstDisplayGptListeners = {}; - window.__firstDisplayTerminal = undefined; - var slotElement = document.createElement('div'); - slotElement.id = 'slot-1'; - document.body.appendChild(slotElement); - window.__firstDisplaySlot = { - addService: function() { return window.__firstDisplaySlot; }, - getSlotElementId: function() { return 'slot-1'; }, - setTargeting: function() { return window.__firstDisplaySlot; } - }; - window.__firstDisplayPubads = { - addEventListener: function(name, listener) { - window.__firstDisplayGptListeners[name] = listener; - }, - getSlots: function() { return []; }, - refresh: function() {}, - removeEventListener: function() {} - }; - window.googletag = { - cmd: {push: function(command) { command(); }}, - defineSlot: function() { return window.__firstDisplaySlot; }, - destroySlots: function() { return true; }, - display: function() { - window.__firstDisplayEvents.push('gpt:display'); - window.__firstDisplayGptListeners.slotRequested({slot: window.__firstDisplaySlot}); - window.__firstDisplayGptListeners.slotRenderEnded({ - slot: window.__firstDisplaySlot, - isEmpty: true - }); - }, - getConfig: function() { return {disableInitialLoad: false}; }, - pubads: function() { return window.__firstDisplayPubads; } - }; - window.__firstDisplayBaseHost = Object.freeze({ - options: Object.freeze({ - batch: Object.freeze({ - version: 1, - projectionDigest: '${'c'.repeat(64)}', - projection: Object.freeze({ - version: 1, - auction: Object.freeze({ - version: 1, - auctionId: 'initial', - results: Object.freeze([Object.freeze({ - slot: 'slot-1', - outcome: 'winner', - candidateId: 'candidate001' - })]) - }), - slots: Object.freeze([Object.freeze({ - slot: 'slot-1', - gamUnitPath: '/123/example', - divId: 'slot-1', - formats: Object.freeze([Object.freeze([300, 250])]), - targeting: Object.freeze({placement: 'article'}) - })]), - bids: Object.freeze([Object.freeze({ - candidateId: 'candidate001', - slot: 'slot-1', - provider: 'example', - upstreamBidId: 'upstream-1', - cpm: 1.25, - currency: 'USD', - targeting: Object.freeze({hb_pb: '1.25'}), - rendererReservationId: 'r1_${'a'.repeat(22)}', - renderSource: Object.freeze({ - type: 'adm', - version: 1, - adm: '
fictional creative
', - width: 300, - height: 250 - }) - })]) - }) - }), - bootstrap: Object.freeze({ - get state() { return 'agent_registered'; }, - startedAtMs: 0, - registerAgent: function() { - window.__firstDisplayEvents.push('bootstrap:register'); - return true; - }, - startAction: function() { - window.__firstDisplayEvents.push('bootstrap:action'); - return true; - }, - settle: function() { return true; }, - fail: function() { return true; } - }), - gptInput: Object.freeze({ - browser: window, - clearTimer: function() {}, - document: document, - setTimer: function(callback) { return callback; } - }), - performance: Object.freeze({mark: function() {}}), - paint: Object.freeze({ - hidden: function() { return false; }, - requestFrame: function() {}, - scheduleHidden: function() {} - }), - onProtectedPaint: function() {}, - onFailure: function() {} - }), - sliceBindings: function(id) { - if (id === 'render_owner_initial') { - return Object.freeze({ - bindings: Object.freeze({ - observe: function() {}, - register: function(protocol) { - window.__firstDisplayEvents.push('render_owner:' + protocol.id); - return function() {}; - } - }), - config: Object.freeze({}) - }); - } - if (id === 'aps_initial') { - return Object.freeze({ - bindings: Object.freeze({ - observe: function() {}, - publisherOrigin: 'https://publisher.example', - register: function(protocol) { - window.__firstDisplayEvents.push('aps:' + protocol.id); - return function() {}; - } - }), - config: Object.freeze({}) - }); - } - if (id === 'gpt_initial') { - return Object.freeze({ - bindings: Object.freeze({ - gam: function() { return true; }, - observe: function() {}, - register: function(protocol) { - window.__firstDisplayEvents.push('gpt:' + protocol.id); - return function() {}; - } - }), - config: Object.freeze({gamAttributionEnabled: false, pageBidsEnabled: false}) - }); - } - return undefined; - } - }); - window.__firstDisplayActivation = Object.freeze({ - own: function(dispose) { window.__firstDisplayDisposers.push(dispose); }, - afterActivate: function(callback) { window.__firstDisplayAfterActivate = callback; } - }); - window.__firstDisplaySliceActivation = Object.freeze({ - own: function(dispose) { window.__firstDisplayDisposers.push(dispose); }, - afterActivate: function() { throw new Error('optional slice cannot start the agent'); } - }); - `); - const activatedBase = registrations[0].prepare(dom.window.__firstDisplayBaseHost); - const activatedRenderOwner = registrations - .find(({ id }) => id === 'render_owner_initial') - .prepare(activatedBase.sliceHost); - const activatedGpt = registrations - .find(({ id }) => id === 'gpt_initial') - .prepare(activatedBase.sliceHost); - activatedBase.activate(dom.window.__firstDisplayActivation); - activatedRenderOwner.activate(dom.window.__firstDisplaySliceActivation); - activatedGpt.activate(dom.window.__firstDisplaySliceActivation); - dom.window.__firstDisplayAfterActivate(); - const directFrame = dom.window.document.querySelector('#slot-1 iframe'); - assert.ok(directFrame, 'the generated bridge must stage the direct empty-GAM fallback'); - directFrame.dispatchEvent(new dom.window.Event('load')); - assert.deepEqual( - [...dom.window.__firstDisplayEvents], - [ - 'render_owner:render_owner', - 'gpt:gpt', - 'bootstrap:register', - 'bootstrap:action', - 'gpt:display', - ] - ); } finally { dom.window.close(); } @@ -518,6 +315,59 @@ test('takeover transport co-bundles core and render ownership exactly once', () ); }); +test('bootstrap physically owns the logical first-display base without transporting its marker', () => { + const { metrics } = readBuildEvidence(); + const bootstrapSources = new Set(metrics.bootstrap.sources.map(({ file }) => file)); + const marker = metrics.modules.find(({ file }) => file === 'tsjs-first_display.js'); + const reference = metrics.firstDisplay.masks.find(({ mask }) => mask === '008b'); + + assert.equal( + bootstrapSources.has('src/first_display/agent.ts'), + true, + 'the parser-inline bootstrap must physically own the first-display coordinator' + ); + assert.ok(marker, 'the logical first_display catalog marker must remain in release evidence'); + assert.deepEqual( + marker.sources.map(({ file }) => file), + ['src/first_display/base_marker.ts'], + 'the logical marker must not duplicate the bootstrap-owned implementation' + ); + assert.ok(reference, 'the exact semantic 008b first-display mask must be measured'); + assert.equal(reference.ids[0], 'first_display'); + assert.deepEqual( + reference.files, + reference.ids.slice(1).map((id) => `tsjs-${id}.js`), + 'the logical base bit must select bootstrap behavior without adding response bytes' + ); + + for (const forbidden of [ + 'src/first_display/render_journal.ts', + 'src/first_display/render_bridge.ts', + 'src/first_display/leaf/aps_protocol.ts', + 'src/first_display/slices/aps.ts', + 'src/first_display/slices/gpt.ts', + ]) { + assert.equal( + bootstrapSources.has(forbidden), + false, + `bootstrap must not inline optional first-display source ${forbidden}` + ); + } +}); + +test('first-display component entries register directly without a definition runtime', () => { + const { metrics } = readBuildEvidence(); + + for (const [id, component] of Object.entries(metrics.firstDisplay.components)) { + if (id === 'first_display') continue; + assert.equal( + component.sources.some(({ file }) => file === 'src/first_display/slices/definition.ts'), + false, + `${id} must not transport the test-only slice-definition helper` + ); + } +}); + test('generated integration artifacts execute their release-bound catalog entrypoints', () => { const release = JSON.parse( fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') @@ -1009,10 +859,13 @@ test('bundle metrics enumerate and hash every reachable first-display mask', () } assert.deepEqual( measurement.files, - measurement.ids.map((id) => `tsjs-${id}.js`) + measurement.ids.slice(1).map((id) => `tsjs-${id}.js`) ); for (const size of ['rawBytes', 'gzipBytes', 'brotliBytes']) { - assert.ok(Number.isSafeInteger(measurement[size]) && measurement[size] > 0); + assert.ok( + Number.isSafeInteger(measurement[size]) && + (size === 'rawBytes' ? measurement[size] >= 0 : measurement[size] > 0) + ); } assert.equal(typeof measurement.permitted, 'boolean'); assert.match(measurement.sha256, /^[0-9a-f]{64}$/u); diff --git a/crates/trusted-server-js/lib/test/core/bootstrap.test.ts b/crates/trusted-server-js/lib/test/core/bootstrap.test.ts index 4738e3fa4..cef433fa6 100644 --- a/crates/trusted-server-js/lib/test/core/bootstrap.test.ts +++ b/crates/trusted-server-js/lib/test/core/bootstrap.test.ts @@ -161,6 +161,15 @@ describe('sealed production boot transport', () => { expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); }); + it('rejects non-v1 outline capability metadata in the compact parser', () => { + const value = transportInput(true); + const outline = value.outline as Record; + outline.capabilities = ['persistent-only']; + outline.objectKinds = ['persistent-only']; + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); + }); + it('rejects a decoded transport above 10 MiB before parsing it', () => { const encoded = `${JSON.stringify(transportInput())}${' '.repeat(10 * 1024 * 1024)}`; expect(snapshotServerBootTransportV1(encoded, RELEASE)).toBeUndefined(); @@ -204,6 +213,20 @@ describe('sealed production boot transport', () => { expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); }); + it('rejects unselected integration config entries out of canonical order', () => { + const value = transportInput(); + const boot = value.boot as Record; + boot.integrations = { + version: 1, + entries: [ + { id: 'prebid', config: {} }, + { id: 'aps', config: {} }, + ], + }; + + expect(snapshotServerBootTransportV1(JSON.stringify(value), RELEASE)).toBeUndefined(); + }); + it.each([ ['APS without the render owner', '0085', ['first_display', 'aps_initial', 'gpt_initial']], ['the render owner without GPT', '0003', ['first_display', 'render_owner_initial']], diff --git a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts index 30c4b7d84..0624b86e1 100644 --- a/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/adm_render_bridge.test.ts @@ -13,8 +13,8 @@ function harness() { }); const element = dom.window.document.getElementById('slot-1'); if (!(element instanceof dom.window.HTMLElement)) throw new Error('missing fixture element'); - const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze({ - bid: Object.freeze({ + const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze([ + Object.freeze({ candidateId: 'candidate001', slot: 'slot-1', provider: 'example', @@ -32,19 +32,19 @@ function harness() { }), }), element, - isCurrent: () => true, - ownership: 'trusted_server', - physicalSlot: {}, - placement: Object.freeze({ + () => true, + 'trusted_server', + {}, + Object.freeze({ slot: 'slot-1', gamUnitPath: '/123/example', divId: 'slot-1', formats: Object.freeze([Object.freeze([300, 250] as const)]), targeting: Object.freeze({}), }), - slotId: 'slot-1', - traceToken: 'gt1_1', - }); + 'slot-1', + 'gt1_1', + ]); const timers = new Map void>(); let now = 0; const terminal = vi.fn(); @@ -93,16 +93,8 @@ describe('ADM-only shared first-display render journal', () => { h.bridge.sealTsAdmission(); expect(h.bridge.closeIngress()).toBe(true); - expect(h.bridge.captureHandoff()?.artifacts).toEqual([ - { - hostPosition: null, - hostPositionPriority: null, - identity: frame, - kind: 'gpt_adm', - owner: 'trusted_server', - slotId: 'slot-1', - token: RESERVATION_ID, - }, + expect(h.bridge.captureHandoff()?.[0]).toEqual([ + [null, null, frame, 'gpt_adm', 'trusted_server', 'slot-1', RESERVATION_ID], ]); expect(h.bridge.detachCommittedArtifacts()).toBe(true); h.bridge.dispose(); @@ -133,7 +125,7 @@ describe('ADM-only shared first-display render journal', () => { const aps = Object.freeze({ ...h.cycle, bid: Object.freeze({ - ...h.cycle.bid, + ...h.cycle[0], rendererReservationId: `r1_${'b'.repeat(22)}`, renderSource: Object.freeze({ type: 'aps' as const, diff --git a/crates/trusted-server-js/lib/test/first_display/agent.test.ts b/crates/trusted-server-js/lib/test/first_display/agent.test.ts index 65f5705b6..d6596ac15 100644 --- a/crates/trusted-server-js/lib/test/first_display/agent.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/agent.test.ts @@ -1,15 +1,22 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { createFirstDisplayAgent, - type FirstDisplayAuctionProtocolId, + type FirstDisplayAgentOptions, type FirstDisplayBatchOutcomeV1, - type FirstDisplayBootstrapController, type FirstDisplayDriver, type FirstDisplayTerminalResult, } from '../../src/first_display/agent'; +import type { FirstDisplayRenderBridgeCapabilityV1 } from '../../src/first_display/driver'; +import type { FirstDisplayGptProtocolV1 } from '../../src/first_display/leaf/gpt_protocol'; +import type { + FirstDisplayGoogletagBatchCallbacks, + FirstDisplayGoogletagBatchInput, + FirstDisplayGptBoundCycleV1, +} from '../../src/first_display/adapters/googletag'; import { snapshotFirstDisplayBatchV1 } from '../../src/first_display/leaf/projection'; import { createTestNavigationIdentityIssuer } from '../../src/kernel/identity'; +import { finalizeFirstDisplayAgentCaptureV1 } from '../../src/shared/first_display_handoff'; function batch( kinds: readonly ('no_bid' | 'failed' | 'gpt_adm' | 'aps')[] @@ -101,44 +108,15 @@ function harness(options: { now?: number; hidden?: boolean } = {}) { mark: (name: string) => marks.push(name), measure: (name: string, start: string, end: string) => measures.push([name, start, end]), }; - let bootstrapState: FirstDisplayBootstrapController['state'] = 'installing'; const startedAtMs = now; - const deadline = () => !Number.isFinite(now - startedAtMs) || now - startedAtMs >= 10_000; - const fail = (reason: 'abi_mismatch' | 'bundle_partial'): boolean => { - if (bootstrapState === 'settled' || bootstrapState === 'failed') return false; - bootstrapState = 'failed'; - failures.push(reason); - return true; - }; performance.mark('tsjs:bids-script'); - const bootstrap: FirstDisplayBootstrapController = Object.freeze({ - get state() { - return bootstrapState; - }, + const owner = Object.freeze({ startedAtMs, - registerAgent: () => { - if (bootstrapState !== 'installing') return false; - if (deadline()) return fail('bundle_partial') && false; - bootstrapState = 'agent_registered'; - return true; - }, - startAction: () => { - if (bootstrapState !== 'agent_registered') return false; - if (deadline()) return fail('bundle_partial') && false; - bootstrapState = 'action_started'; - return true; - }, - settle: () => { - if (bootstrapState !== 'agent_registered' && bootstrapState !== 'action_started') { - return false; - } - bootstrapState = 'settled'; - return true; - }, - fail, + now: () => now, + onSettled: () => undefined, }); return { - bootstrap, + owner, failures, frames, marks, @@ -204,24 +182,186 @@ function driver(events: string[]): FirstDisplayDriver & { }); } +function production( + driver: FirstDisplayDriver +): NonNullable { + let captured: ReturnType; + let closed: boolean | undefined; + let detached: boolean | undefined; + let disposed = false; + const terminals = new Map< + string, + (result: FirstDisplayTerminalResult, reason: string | null) => void + >(); + const capture = () => { + if (captured === undefined) captured = driver.captureHandoff(); + return captured; + }; + const close = () => { + if (closed === undefined) closed = driver.closeIngress(); + return closed; + }; + const detach = () => { + if (detached === undefined) detached = driver.detachCommittedArtifacts(); + return detached; + }; + const dispose = () => { + if (disposed) return; + disposed = true; + driver.dispose(); + }; + const gpt: FirstDisplayGptProtocolV1 = Object.freeze([ + 1, + 'gpt', + (input: FirstDisplayGoogletagBatchInput) => + Object.freeze([ + (callbacks: FirstDisplayGoogletagBatchCallbacks) => { + const outcomes: FirstDisplayBatchOutcomeV1[] = []; + for (const decision of input[4].auction.results) { + if (decision.outcome !== 'winner') continue; + const bid = input[4].bids.find( + ({ candidateId }) => candidateId === decision.candidateId + ); + if (bid) { + outcomes.push( + Object.freeze({ + kind: bid.renderSource.type === 'aps' ? 'aps' : 'gpt_adm', + slotId: decision.slot, + }) + ); + } + } + for (const outcome of outcomes) { + const bid = input[4].bids.find(({ slot }) => slot === outcome.slotId)!; + const placement = input[4].slots.find(({ slot }) => slot === outcome.slotId)!; + callbacks[0]( + Object.freeze([ + bid, + document.createElement('div'), + () => true, + 'trusted_server' as const, + {}, + placement, + outcome.slotId, + `gt1_${outcome.slotId}`, + ]) + ); + } + driver.start(outcomes, callbacks[2], (slotId, result, reason) => { + terminals.get(slotId)?.(result, reason); + }); + return true; + }, + () => close(), + () => + capture()?.cycles.map((cycle) => + Object.freeze([cycle[6], cycle[1].id, cycle[3], cycle[8], cycle[7], cycle[4]] as const) + ), + () => { + const value = capture(); + return value + ? Object.freeze([ + value.diagnosticCycles.map((cycle) => + Object.freeze([ + cycle.slotId, + cycle.token, + cycle.nextCycleOrdinal, + cycle.unknownPriorCycle, + cycle.quarantines, + cycle.records.map((record) => + Object.freeze([ + record.ordinal, + record.responseIdentifier, + record.seen, + record.state, + ] as const) + ), + ] as const) + ), + value.gptDiagnostics.facts, + value.nextTraceTokenOrdinal, + value.gptDiagnostics.overflowCount, + value.gptDiagnostics.dropCount, + ] as const) + : undefined; + }, + () => detach(), + dispose, + ] as const), + ] as const); + const renderer: FirstDisplayRenderBridgeCapabilityV1 = Object.freeze([ + ( + cycle: FirstDisplayGptBoundCycleV1, + onTerminal: (result: FirstDisplayTerminalResult, reason: string | null) => void + ) => { + terminals.set(cycle[6], onTerminal); + return true; + }, + () => true, + () => true, + () => true, + () => driver.sweepCommittedArtifacts(), + () => driver.sealTsAdmission(), + () => close(), + () => { + const value = capture(); + return value + ? Object.freeze([ + value.artifacts.map((artifact) => + Object.freeze([ + artifact.hostPosition, + artifact.hostPositionPriority, + artifact.identity, + artifact.kind, + artifact.owner, + artifact.slotId, + artifact.token, + ] as const) + ), + value.tombstones.map((entry) => + Object.freeze([entry.kind, entry.value, entry.expiresAtMs, entry.ordinal] as const) + ), + value.clockEpochMs, + value.nextReservationOrdinal, + value.nextTicketOrdinal, + ] as const) + : undefined; + }, + () => detach(), + dispose, + ] as const); + return Object.freeze({ + gpt, + gptInput: Object.freeze([ + window, + (handle: unknown) => window.clearTimeout(handle as number), + document, + (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), + ] as const), + renderer, + }); +} + describe('bounded first-display agent', () => { it('derives the protected action from the immutable projection and rejects outcome summaries', () => { const accepted = harness(); const received: FirstDisplayBatchOutcomeV1[][] = []; const projectionAgent = createFirstDisplayAgent({ batch: batch(['gpt_adm']), - bootstrap: accepted.bootstrap, - driver: Object.freeze({ - captureHandoff: () => undefined, - closeIngress: () => true, - detachCommittedArtifacts: () => true, - sweepCommittedArtifacts: () => 0, - start: (outcomes: readonly FirstDisplayBatchOutcomeV1[]) => { - received.push([...outcomes]); - }, - sealTsAdmission: () => undefined, - dispose: () => undefined, - }), + ...accepted.owner, + production: production( + Object.freeze({ + captureHandoff: () => undefined, + closeIngress: () => true, + detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, + start: (outcomes: readonly FirstDisplayBatchOutcomeV1[]) => { + received.push([...outcomes]); + }, + sealTsAdmission: () => undefined, + dispose: () => undefined, + }) + ), performance: accepted.performance, paint: accepted.paint, onProtectedPaint: () => undefined, @@ -239,8 +379,8 @@ describe('bounded first-display agent', () => { requiredProtocols: Object.freeze(['gpt']), outcomes: Object.freeze([Object.freeze({ slotId: 'slot-0', kind: 'gpt_adm' })]), }), - bootstrap: rejected.bootstrap, - driver: driver([]), + ...rejected.owner, + production: production(driver([])), performance: rejected.performance, paint: rejected.paint, onProtectedPaint: () => undefined, @@ -250,55 +390,14 @@ describe('bounded first-display agent', () => { expect(rejected.failures).toEqual(['abi_mismatch']); }); - it('requires exact activated protocol coverage for the immutable batch', () => { - const h = harness(); - const coverage = createFirstDisplayAgent({ - batch: batch(['aps']), - bootstrap: h.bootstrap, - driver: driver([]), - performance: h.performance, - paint: h.paint, - onProtectedPaint: () => undefined, - onFailure: () => undefined, - }); - const aps = Object.freeze({ version: 1, id: 'aps' }); - const gpt = Object.freeze({ version: 1, id: 'gpt' }); - expect( - coverage.coversProtocols( - new Map([ - ['aps', aps], - ['gpt', gpt], - ]) - ) - ).toBe(true); - expect( - coverage.coversProtocols(new Map([['aps', aps]])) - ).toBe(false); - expect( - coverage.coversProtocols( - new Map([ - ['aps', aps], - ['gpt', Object.freeze({ version: 1, id: 'prebid' })], - ]) - ) - ).toBe(false); - expect( - coverage.coversProtocols( - new Map([ - ['aps', Object.freeze({ version: 1, id: 'aps', extra: true })], - ['gpt', gpt], - ]) - ) - ).toBe(false); - }); it('emits the four authoritative marks around one mixed protected batch', () => { const h = harness(); const events: string[] = []; const ownedDriver = driver(events); const agent = createFirstDisplayAgent({ batch: batch(['no_bid', 'gpt_adm', 'aps']), - bootstrap: h.bootstrap, - driver: ownedDriver, + ...h.owner, + production: production(ownedDriver), performance: h.performance, paint: h.paint, onProtectedPaint: () => events.push('protected:paint'), @@ -338,8 +437,8 @@ describe('bounded first-display agent', () => { const events: string[] = []; const agent = createFirstDisplayAgent({ batch: batch(['no_bid', 'failed', 'failed']), - bootstrap: h.bootstrap, - driver: driver(events), + ...h.owner, + production: production(driver(events)), performance: h.performance, paint: h.paint, onProtectedPaint: () => events.push('protected:paint'), @@ -376,8 +475,8 @@ describe('bounded first-display agent', () => { }); const agent = createFirstDisplayAgent({ batch: batch(['gpt_adm']), - bootstrap: h.bootstrap, - driver: ownedDriver, + ...h.owner, + production: production(ownedDriver), performance: h.performance, paint: h.paint, onProtectedPaint: () => undefined, @@ -397,18 +496,23 @@ describe('bounded first-display agent', () => { let lateAction: (() => boolean) | undefined; const lateAgent = createFirstDisplayAgent({ batch: batch(['aps']), - bootstrap: late.bootstrap, - driver: Object.freeze({ - captureHandoff: () => undefined, - closeIngress: () => true, - detachCommittedArtifacts: () => true, - sweepCommittedArtifacts: () => 0, - start: (_outcomes: readonly FirstDisplayBatchOutcomeV1[], onFirstAction: () => boolean) => { - lateAction = onFirstAction; - }, - sealTsAdmission: () => undefined, - dispose: () => undefined, - }), + ...late.owner, + production: production( + Object.freeze({ + captureHandoff: () => undefined, + closeIngress: () => true, + detachCommittedArtifacts: () => true, + sweepCommittedArtifacts: () => 0, + start: ( + _outcomes: readonly FirstDisplayBatchOutcomeV1[], + onFirstAction: () => boolean + ) => { + lateAction = onFirstAction; + }, + sealTsAdmission: () => undefined, + dispose: () => undefined, + }) + ), performance: late.performance, paint: late.paint, onProtectedPaint: () => undefined, @@ -432,42 +536,37 @@ describe('bounded first-display agent', () => { const events: string[] = []; const agent = createFirstDisplayAgent({ batch: batch(['gpt_adm']), - bootstrap: h.bootstrap, - driver: driver(events), + ...h.owner, + production: production(driver(events)), performance: h.performance, paint: h.paint, onProtectedPaint: () => undefined, onFailure: (reason) => h.failures.push(reason), }); expect(agent.start()).toBe(accepted); - expect(events).toEqual(accepted ? ['driver:start'] : []); + expect(events).toEqual(accepted ? ['driver:start'] : ['driver:dispose']); expect(h.failures).toEqual(accepted ? [] : ['bundle_partial']); } }); - it('seals TS bidder admission at paint while native activity remains observable', () => { + it('seals the immutable TS batch at paint while native activity remains observable', () => { const h = harness(); const events: string[] = []; - const complete = vi.fn(); const agent = createFirstDisplayAgent({ batch: batch(['no_bid']), - bootstrap: h.bootstrap, - driver: driver(events), + ...h.owner, + production: production(driver(events)), performance: h.performance, paint: h.paint, onProtectedPaint: () => undefined, onFailure: (reason) => h.failures.push(reason), - onPrebidAdmissionFailure: (reason) => events.push(reason), }); agent.start(); h.frames.shift()?.(); h.frames.shift()?.(); - expect(agent.admitTsBid(complete)).toBe(false); - expect(complete).toHaveBeenCalledTimes(1); - expect(events).toContain('prebid_admission_failed'); expect(agent.observeNativeMutation()).toBe(true); - expect(agent.snapshot().mutationRevision).toBe(1); + expect(agent.mutationRevision).toBe(1); }); it('drains pending DOM mutation records into the final handoff revision', () => { @@ -480,8 +579,8 @@ describe('bounded first-display agent', () => { if (!issuer.ok) throw new Error('Expected first-display identity issuer'); const agent = createFirstDisplayAgent({ batch: batch(['no_bid']), - bootstrap: h.bootstrap, - driver: driver([]), + ...h.owner, + production: production(driver([])), performance: h.performance, paint: h.paint, mutationDocument: document, @@ -500,7 +599,7 @@ describe('bounded first-display agent', () => { h.frames.shift()?.(); host.append(document.createElement('span')); - const finalized = agent.finalizeHandoff(); + const finalized = agent.finalizeHandoff(finalizeFirstDisplayAgentCaptureV1); expect(finalized?.handoff.mutationRevision).toBe(1); expect(h.failures).toEqual([]); @@ -512,8 +611,8 @@ describe('bounded first-display agent', () => { const malformed = harness(); const invalidAgent = createFirstDisplayAgent({ batch: { ...batch(['no_bid']), extra: true }, - bootstrap: malformed.bootstrap, - driver: driver([]), + ...malformed.owner, + production: production(driver([])), performance: malformed.performance, paint: malformed.paint, onProtectedPaint: () => undefined, @@ -536,8 +635,8 @@ describe('bounded first-display agent', () => { }); const failedAgent = createFirstDisplayAgent({ batch: batch(['aps']), - bootstrap: throwing.bootstrap, - driver: throwingDriver, + ...throwing.owner, + production: production(throwingDriver), performance: throwing.performance, paint: throwing.paint, onProtectedPaint: () => undefined, @@ -550,8 +649,8 @@ describe('bounded first-display agent', () => { const revision = harness(); const revisionAgent = createFirstDisplayAgent({ batch: batch(['no_bid']), - bootstrap: revision.bootstrap, - driver: driver([]), + ...revision.owner, + production: production(driver([])), performance: revision.performance, paint: revision.paint, onProtectedPaint: () => undefined, @@ -614,17 +713,17 @@ describe('bounded first-display agent', () => { }), ]), cycles: Object.freeze([ - Object.freeze({ - bid: projectedBatch.projection.bids[0]!, + Object.freeze([ + projectedBatch.projection.bids[0]!, element, - isCurrent: () => true, - ownership: 'trusted_server' as const, + () => true, + 'trusted_server' as const, physicalSlot, - placement: projectedBatch.projection.slots[0]!, - slotId: 'slot-0', - targetingOwnership: Object.freeze([]), - traceToken: 'gt1_1', - }), + projectedBatch.projection.slots[0]!, + 'slot-0', + 'gt1_1', + Object.freeze([]), + ] as const), ]), diagnosticCycles: Object.freeze([ Object.freeze({ @@ -665,8 +764,8 @@ describe('bounded first-display agent', () => { }); const agent = createFirstDisplayAgent({ batch: batch(['gpt_adm', 'gpt_adm']), - bootstrap: h.bootstrap, - driver: ownedDriver, + ...h.owner, + production: production(ownedDriver), performance: h.performance, paint: h.paint, onProtectedPaint: () => undefined, @@ -674,14 +773,13 @@ describe('bounded first-display agent', () => { identityIssuer: issuer.value, parserState: () => Object.freeze([ - Object.freeze({ - sliceId: 'gpt_initial', - observations: Object.freeze(['gam', 'v']), - values: Object.freeze([ + Object.freeze([ + 'gpt_initial', + Object.freeze([ Object.freeze(['gam', false] as const), Object.freeze(['v', 1] as const), ]), - }), + ] as const), ]), handoff: Object.freeze({ releaseId: 'a'.repeat(64), @@ -691,62 +789,41 @@ describe('bounded first-display agent', () => { }), }); expect(agent.start()).toBe(true); - expect(agent.finalizeHandoff()).toBeUndefined(); + expect(agent.finalizeHandoff(finalizeFirstDisplayAgentCaptureV1)).toBeUndefined(); h.frames.shift()?.(); h.frames.shift()?.(); - const finalized = agent.finalizeHandoff(); + const finalized = agent.finalizeHandoff(finalizeFirstDisplayAgentCaptureV1); expect(events.slice(-2)).toEqual(['close', 'capture']); expect(finalized?.handoff).toMatchObject({ + captureVersion: 1, releaseId: 'a'.repeat(64), generation: 1, - projectionDigest: 'b'.repeat(64), - slices: ['first_display', 'gpt_initial'], mutationRevision: 0, - parserState: [ - { - sliceId: 'gpt_initial', - observations: ['gam', 'v'], - values: [ - ['gam', false], - ['v', 1], - ], - }, - ], - attempts: [ - expect.objectContaining({ id: 'a1_AAECAwQFBgcAAAAAAAAAAQ', ordinal: 1 }), - expect.objectContaining({ - id: 'a1_AAECAwQFBgcAAAAAAAAAAg', - ordinal: 2, - reason: 'renderer_document_no_load', - state: 'failed', - }), - ], - highWater: expect.objectContaining({ - navigationAttemptPrefix: 'AAECAwQFBgc', - nextNavigationAttemptOrdinal: 3, - }), - trace: { - nextGlobalSlotOrdinal: 2, - nextSequence: 2, - slots: [ - { - bindings: [ - { - atMs: 0, - cycleOrdinal: 1, - historySequence: 1, - state: 'completed', - token: 'gt1_1', - }, - ], - impressions: 1, - slotId: 'slot-0', - }, - { bindings: [], impressions: 0, slotId: 'slot-1' }, - ], - }, + identityCount: 2, }); + const data = finalized?.handoff['data'] as readonly unknown[]; + expect(data).toHaveLength(14); + expect(data.slice(0, 3)).toEqual([ + 'b'.repeat(64), + 'c'.repeat(64), + ['first_display', 'gpt_initial'], + ]); + expect(data[3]).toEqual([ + ['accepted', null, 0, 1], + ['failed', 'renderer_document_no_load', null, null], + ]); + expect(data[4]).toEqual([['slot-0', 'slot-0', 'trusted_server', [], 'gt1_1']]); + expect(data[7]).toEqual([ + [ + 'gpt_initial', + [ + ['gam', false], + ['v', 1], + ], + ], + ]); + expect(finalized?.handoff).not.toHaveProperty('attempts'); expect(finalized?.capsule.consume('a'.repeat(64), 1)).toEqual([physicalSlot, artifact]); expect(agent.detachCommittedArtifacts()).toBe(true); agent.dispose(); diff --git a/crates/trusted-server-js/lib/test/first_display/browser_route_owner.test.ts b/crates/trusted-server-js/lib/test/first_display/browser_route_owner.test.ts new file mode 100644 index 000000000..30cdeab0f --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/browser_route_owner.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { registerFirstDisplayBrowserRoute } from '../../src/first_display/leaf/browser_route_owner'; + +describe('first-display browser route owner', () => { + it('rewrites matching scripts and preloads before native insertion and restores exactly once', () => { + const appendChild = Element.prototype.appendChild; + const insertBefore = Element.prototype.insertBefore; + const release = registerFirstDisplayBrowserRoute({ + matches: (kind, url) => + (kind === 'script' || kind === 'preload') && url.includes('vendor.example'), + rewrite: (url) => `/integrations/proxy?source=${encodeURIComponent(url)}`, + }); + + const script = document.createElement('script'); + script.src = 'https://vendor.example/sdk.js?v=1'; + document.head.appendChild(script); + expect(script.getAttribute('src')).toContain('/integrations/proxy?source='); + + const preload = document.createElement('link'); + preload.rel = 'preload'; + preload.setAttribute('as', 'script'); + preload.href = 'https://vendor.example/later.js'; + document.head.insertBefore(preload, script); + expect(preload.getAttribute('href')).toContain('/integrations/proxy?source='); + + release(); + release(); + expect(Element.prototype.appendChild).toBe(appendChild); + expect(Element.prototype.insertBefore).toBe(insertBefore); + + const later = document.createElement('script'); + later.src = 'https://vendor.example/unowned.js'; + document.head.appendChild(later); + expect(later.src).toBe('https://vendor.example/unowned.js'); + script.remove(); + preload.remove(); + later.remove(); + }); + + it('owns matching beacon and fetch routes without changing publisher replacements on release', async () => { + const originalBeacon = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const originalFetch = Object.getOwnPropertyDescriptor(window, 'fetch'); + const beacon = vi.fn(() => true); + const fetch = vi.fn(async () => new Response()); + Object.defineProperty(navigator, 'sendBeacon', { + configurable: true, + value: beacon, + writable: true, + }); + Object.defineProperty(window, 'fetch', { + configurable: true, + value: fetch, + writable: true, + }); + const release = registerFirstDisplayBrowserRoute( + { + matches: (kind, url) => + (kind === 'beacon' || kind === 'fetch') && url.includes('analytics.example'), + rewrite: () => 'https://publisher.example/integrations/analytics', + }, + true + ); + + expect(navigator.sendBeacon('https://analytics.example/collect')).toBe(true); + await window.fetch('https://analytics.example/fetch'); + expect(beacon).toHaveBeenCalledWith( + 'https://publisher.example/integrations/analytics', + undefined + ); + expect(fetch).toHaveBeenCalledWith( + 'https://publisher.example/integrations/analytics', + undefined + ); + + const publisherFetch = vi.fn(async () => new Response()); + window.fetch = publisherFetch; + release(); + expect(window.fetch).toBe(publisherFetch); + if (originalBeacon) Object.defineProperty(navigator, 'sendBeacon', originalBeacon); + else Reflect.deleteProperty(navigator, 'sendBeacon'); + if (originalFetch) Object.defineProperty(window, 'fetch', originalFetch); + else Reflect.deleteProperty(window, 'fetch'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts index 64ea576fb..5e7c99fa8 100644 --- a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts @@ -6,6 +6,7 @@ import { MAX_GPT_FACT_BYTES, createFirstDisplayOwnershipCapsuleV1, snapshotFirstDisplayHandoffV1, + snapshotOutlinedFirstDisplayCaptureV1, snapshotTakeoverOutlineV1, } from '../../src/shared/first_display_contracts'; @@ -168,6 +169,97 @@ function handoff(overrides: Record = {}): Record { + it('expands one compact old-owner capture only against the validated boot projection', () => { + const full = handoff(); + (full.slots as Array>)[0]!.aliases = []; + const compact = { + captureVersion: 1, + releaseId: HASH, + generation: 1, + data: [ + 'b'.repeat(64), + 'c'.repeat(64), + ['first_display', 'gpt_initial'], + [['accepted', null, 3, 1]], + [['slot-1', 'div-1', 'trusted_server', [], 'gt1_1']], + [['reservation', RESERVATION_ID, 1_000, 1]], + [[null, null, 'slot-1', 'gpt_adm', 'trusted_server', RESERVATION_ID]], + [ + [ + 'gpt_initial', + [ + ['gam', false], + ['v', 1], + ], + ], + ], + [(full.gptDiagnostics as Record).facts, 0, 0], + [1, 2, 3, 4], + [2, 0, 2, 1], + full.cycles, + 2, + 2, + ], + mutationRevision: 7, + identityCount: 2, + }; + const boot = { + abi: 1, + releaseId: HASH, + manifest: {}, + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'slot-1', outcome: 'winner', candidateId: 'AAAAAAAAAAAA' }], + }, + slots: [ + { + slot: 'slot-1', + gamUnitPath: '/123/home', + divId: 'div-1', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [ + { + candidateId: 'AAAAAAAAAAAA', + slot: 'slot-1', + provider: 'trusted', + upstreamBidId: 'upstream-1', + cpm: 1, + currency: 'USD', + targeting: {}, + rendererReservationId: RESERVATION_ID, + renderSource: { + type: 'adm', + version: 1, + adm: '
trusted
', + width: 300, + height: 250, + }, + }, + ], + }, + integrations: {}, + creative: {}, + diagnostics: {}, + }; + + const issuer = { + mintAttemptId: () => ({ + ok: true as const, + value: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + }), + snapshotPrefix: () => 'AAECAwQFBgc', + }; + expect(snapshotOutlinedFirstDisplayCaptureV1(compact, outline(), boot, issuer)).toEqual(full); + compact.data[3] = [['accepted', null, null, 1]]; + expect(snapshotOutlinedFirstDisplayCaptureV1(compact, outline(), boot, issuer)).toBeUndefined(); + }); + it('snapshots an exact takeover outline into a fresh recursively frozen value', () => { const input = outline(); const accepted = snapshotTakeoverOutlineV1(input); diff --git a/crates/trusted-server-js/lib/test/first_display/driver.test.ts b/crates/trusted-server-js/lib/test/first_display/driver.test.ts index 9306d6fcc..06b82ef62 100644 --- a/crates/trusted-server-js/lib/test/first_display/driver.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/driver.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { FirstDisplayGoogletagBatchCallbacks, FirstDisplayGptBoundCycleV1, + FirstDisplayGptHandoffCycleV1, } from '../../src/first_display/adapters/googletag'; import { createFirstDisplayProjectedDriver } from '../../src/first_display/driver'; import type { FirstDisplayGptProtocolV1 } from '../../src/first_display/leaf/gpt_protocol'; @@ -65,30 +66,40 @@ function harness() { gptCallbacks = callbacks; return true; }), - captureDiagnosticsHandoff: vi.fn(() => ({ - cycles: [cycleOwner.value] - .filter((cycle): cycle is FirstDisplayGptBoundCycleV1 => cycle !== undefined) - .map((cycle) => ({ - nextCycleOrdinal: 2, - quarantines: [], - records: [ - { - ordinal: 1, - responseIdentifier: 'response-one', - seen: ['slotRequested', 'slotRenderEnded'] as const, - state: 'completed' as const, - }, - ], - slotId: cycle.slotId, - token: cycle.traceToken, - unknownPriorCycle: false, - })), - facts: [], - nextTraceTokenOrdinal: 2, - overflowCount: 0, - dropCount: 0, - })), - captureHandoff: vi.fn(() => [cycleOwner.value].filter(Boolean)), + captureDiagnosticsHandoff: vi.fn(() => + Object.freeze([ + [cycleOwner.value] + .filter((cycle): cycle is FirstDisplayGptHandoffCycleV1 => cycle !== undefined) + .map((cycle) => + Object.freeze([ + cycle[6], + cycle[7], + 2, + false, + Object.freeze([]), + Object.freeze([ + Object.freeze([ + 1, + 'response-one', + Object.freeze(['slotRequested', 'slotRenderEnded'] as const), + 'completed', + ] as const), + ]), + ] as const) + ), + Object.freeze([]), + 2, + 0, + 0, + ] as const) + ), + captureHandoff: vi.fn(() => + [cycleOwner.value] + .filter((cycle): cycle is FirstDisplayGptHandoffCycleV1 => cycle !== undefined) + .map((cycle) => + Object.freeze([cycle[6], cycle[1].id, cycle[3], cycle[8], cycle[7], cycle[4]] as const) + ) + ), closeIngress: vi.fn(() => { events.push('gpt:close'); return true; @@ -96,11 +107,22 @@ function harness() { detachCommittedSlots: vi.fn(() => true), dispose: vi.fn(), }; - const protocol = { - createBatch: vi.fn(() => gptBatch), - } as unknown as FirstDisplayGptProtocolV1; + const protocol: FirstDisplayGptProtocolV1 = Object.freeze([ + 1, + 'gpt', + vi.fn(() => + Object.freeze([ + gptBatch.start, + gptBatch.closeIngress, + gptBatch.captureHandoff, + gptBatch.captureDiagnosticsHandoff, + gptBatch.detachCommittedSlots, + gptBatch.dispose, + ] as const) + ), + ]); const events: string[] = []; - const cycleOwner: { value?: FirstDisplayGptBoundCycleV1 } = {}; + const cycleOwner: { value?: FirstDisplayGptHandoffCycleV1 } = {}; const renderer = { bind: vi.fn((_cycle: FirstDisplayGptBoundCycleV1, terminal: typeof renderTerminal) => { events.push('render:bind'); @@ -114,13 +136,7 @@ function harness() { recordFailure: vi.fn(() => true), retire: vi.fn(() => true), sweepCommittedArtifacts: vi.fn(() => 0), - captureHandoff: vi.fn(() => ({ - artifacts: [], - clockEpochMs: 0, - nextReservationOrdinal: 1, - nextTicketOrdinal: 1, - tombstones: [], - })), + captureHandoff: vi.fn(() => [[], [], 0, 1, 1] as const), closeIngress: vi.fn(() => { events.push('render:close'); return true; @@ -132,12 +148,7 @@ function harness() { const driver = createFirstDisplayProjectedDriver({ batch: value, gpt: protocol, - gptInput: { - browser: window, - clearTimer: () => undefined, - document, - setTimer: (callback) => callback, - }, + gptInput: [window, () => undefined, document, (callback) => callback], renderer, }); const terminals: unknown[] = []; @@ -149,17 +160,17 @@ function harness() { }, (slotId, result, reason) => terminals.push([slotId, result, reason]) ); - const cycle = Object.freeze({ - bid: value.projection.bids[0]!, - element: document.createElement('div'), - isCurrent: () => true, - ownership: 'trusted_server' as const, - physicalSlot: {}, - placement: value.projection.slots[0]!, - slotId: 'slot-1', - traceToken: 'gt1_1', - }); - cycleOwner.value = cycle; + const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze([ + value.projection.bids[0]!, + document.createElement('div'), + () => true, + 'trusted_server' as const, + {}, + value.projection.slots[0]!, + 'slot-1', + 'gt1_1', + ]); + cycleOwner.value = Object.freeze([...cycle, Object.freeze([])] as const); return { cycle, driver, @@ -175,9 +186,9 @@ function harness() { describe('projected first-display driver', () => { it('joins exact GPT delivery with renderer-owned completion', () => { const h = harness(); - expect(h.getGptCallbacks().onBound(h.cycle)).toBeUndefined(); - expect(h.getGptCallbacks().onFirstAction()).toBe(true); - h.getGptCallbacks().onRenderEnded(h.cycle, 'nonempty_gam'); + expect(h.getGptCallbacks()[0](h.cycle)).toBeUndefined(); + expect(h.getGptCallbacks()[2]()).toBe(true); + h.getGptCallbacks()[3](h.cycle, 'nonempty_gam'); expect(h.terminals).toEqual([]); h.getRenderTerminal()('accepted', null); @@ -185,33 +196,54 @@ describe('projected first-display driver', () => { expect(h.events).toEqual(['render:bind', 'action', 'render:gam:nonempty_gam']); expect(h.terminals).toEqual([['slot-1', 'accepted', null]]); - h.getGptCallbacks().onRetire?.(h.cycle); - h.getGptCallbacks().onRetire?.(Object.freeze({ ...h.cycle, physicalSlot: {} })); + h.getGptCallbacks()[4]?.(h.cycle); + h.getGptCallbacks()[4]?.( + Object.freeze([ + h.cycle[0], + h.cycle[1], + h.cycle[2], + h.cycle[3], + {}, + h.cycle[5], + h.cycle[6], + h.cycle[7], + ]) + ); expect(h.renderer.retire).toHaveBeenCalledExactlyOnceWith(h.cycle); }); it('fails an empty or mismatched physical GPT cycle without guessing', () => { const empty = harness(); - empty.getGptCallbacks().onBound(empty.cycle); - empty.getGptCallbacks().onFirstAction(); - empty.getGptCallbacks().onRenderEnded(empty.cycle, 'gam_empty'); + empty.getGptCallbacks()[0](empty.cycle); + empty.getGptCallbacks()[2](); + empty.getGptCallbacks()[3](empty.cycle, 'gam_empty'); expect(empty.renderer.recordGam).toHaveBeenCalledWith(empty.cycle, 'gam_empty'); const mismatched = harness(); - mismatched.getGptCallbacks().onBound(mismatched.cycle); - mismatched.getGptCallbacks().onFirstAction(); - mismatched - .getGptCallbacks() - .onRenderEnded(Object.freeze({ ...mismatched.cycle, physicalSlot: {} }), 'nonempty_gam'); + mismatched.getGptCallbacks()[0](mismatched.cycle); + mismatched.getGptCallbacks()[2](); + mismatched.getGptCallbacks()[3]( + Object.freeze([ + mismatched.cycle[0], + mismatched.cycle[1], + mismatched.cycle[2], + mismatched.cycle[3], + {}, + mismatched.cycle[5], + mismatched.cycle[6], + mismatched.cycle[7], + ]), + 'nonempty_gam' + ); expect(mismatched.terminals).toEqual([['slot-1', 'failed', 'gpt_request_failed']]); expect(mismatched.renderer.recordGam).not.toHaveBeenCalled(); }); it('owns exact-once sealing and disposal', () => { const h = harness(); - h.getGptCallbacks().onBound(h.cycle); - h.getGptCallbacks().onFirstAction(); - h.getGptCallbacks().onFailure('slot-1', 'gpt_request_timeout'); + h.getGptCallbacks()[0](h.cycle); + h.getGptCallbacks()[2](); + h.getGptCallbacks()[1]('slot-1', 'gpt_request_timeout'); expect(h.terminals).toEqual([['slot-1', 'failed', 'gpt_request_timeout']]); h.driver.sealTsAdmission(); @@ -224,9 +256,9 @@ describe('projected first-display driver', () => { it('captures accepted objects before detaching them from both provisional owners', () => { const h = harness(); - h.getGptCallbacks().onBound(h.cycle); - h.getGptCallbacks().onFirstAction(); - h.getGptCallbacks().onRenderEnded(h.cycle, 'nonempty_gam'); + h.getGptCallbacks()[0](h.cycle); + h.getGptCallbacks()[2](); + h.getGptCallbacks()[3](h.cycle, 'nonempty_gam'); h.getRenderTerminal()('accepted', null); h.driver.sealTsAdmission(); @@ -236,7 +268,7 @@ describe('projected first-display driver', () => { expect(h.driver.captureHandoff()).toEqual({ artifacts: [], clockEpochMs: 0, - cycles: [h.cycle], + cycles: [[...h.cycle, []]], diagnosticCycles: [ { nextCycleOrdinal: 2, @@ -255,7 +287,7 @@ describe('projected first-display driver', () => { }, ], gptDiagnostics: { facts: [], overflowCount: 0, dropCount: 0 }, - identities: [h.cycle.physicalSlot], + identities: [h.cycle[4]], nextReservationOrdinal: 1, nextTraceTokenOrdinal: 2, nextTicketOrdinal: 1, @@ -270,34 +302,27 @@ describe('projected first-display driver', () => { const value = batch(); const driver = createFirstDisplayProjectedDriver({ batch: value, - gpt: { - createBatch: () => ({ - start: () => true, - captureHandoff: () => [], - closeIngress: () => true, - detachCommittedSlots: () => true, - dispose: () => undefined, - }), - } as unknown as FirstDisplayGptProtocolV1, - gptInput: { - browser: window, - clearTimer: () => undefined, - document, - setTimer: (callback) => callback, - }, + gpt: Object.freeze([ + 1, + 'gpt', + () => + Object.freeze([ + () => true, + () => true, + () => [], + () => undefined, + () => true, + () => undefined, + ] as const), + ]), + gptInput: [window, () => undefined, document, (callback) => callback], renderer: { bind: () => true, recordGam: () => true, recordFailure: () => true, retire: () => true, sweepCommittedArtifacts: () => 0, - captureHandoff: () => ({ - artifacts: [], - clockEpochMs: 0, - nextReservationOrdinal: 1, - nextTicketOrdinal: 1, - tombstones: [], - }), + captureHandoff: () => [[], [], 0, 1, 1] as const, closeIngress: () => true, detachCommittedArtifacts: () => true, sealTsAdmission: () => undefined, diff --git a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts index 6ecc63caa..af1b57252 100644 --- a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts @@ -2,11 +2,36 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; import { createFirstDisplayGoogletagBatch } from '../../src/first_display/adapters/googletag'; -import { enqueueFirstDisplayGamAttribution } from '../../src/core/adapters/gam_attribution'; +import { enqueueFirstDisplayGamAttribution } from '../../src/first_display/adapters/googletag'; +import type { + FirstDisplayGoogletagBatch, + FirstDisplayGoogletagBatchCallbacks, +} from '../../src/first_display/adapters/googletag'; import { snapshotFirstDisplayBatchV1 } from '../../src/first_display/leaf/projection'; const RESERVATION_ID = `r1_${'a'.repeat(22)}`; +function startAdapter( + adapter: FirstDisplayGoogletagBatch, + callbacks: Readonly<{ + onBound: FirstDisplayGoogletagBatchCallbacks[0]; + onFailure: FirstDisplayGoogletagBatchCallbacks[1]; + onFirstAction: FirstDisplayGoogletagBatchCallbacks[2]; + onRenderEnded: FirstDisplayGoogletagBatchCallbacks[3]; + onRetire?: FirstDisplayGoogletagBatchCallbacks[4]; + }> +): boolean { + return adapter.start( + Object.freeze([ + callbacks.onBound, + callbacks.onFailure, + callbacks.onFirstAction, + callbacks.onRenderEnded, + callbacks.onRetire, + ]) + ); +} + function fixture() { return Object.freeze({ version: 1, @@ -243,7 +268,7 @@ describe('first-display GPT adapter', () => { setTimer: (callback) => callback, clearTimer: () => undefined, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => true, @@ -267,19 +292,12 @@ describe('first-display GPT adapter', () => { expect(retired).toHaveBeenCalledOnce(); expect(adapter.closeIngress(['slot-1'])).toBe(true); - expect(adapter.captureDiagnosticsHandoff()?.cycles).toEqual([ - expect.objectContaining({ - nextCycleOrdinal: 3, - records: [ - expect.objectContaining({ ordinal: 1, state: 'completed' }), - expect.objectContaining({ - ordinal: 2, - responseIdentifier: 'response-two', - seen: ['slotRequested', 'slotRenderEnded'], - state: 'completed', - }), - ], - }), + const diagnostics = adapter.captureDiagnosticsHandoff(); + const [cycle] = diagnostics?.[0] ?? []; + expect(cycle?.[2]).toBe(3); + expect(cycle?.[5]).toEqual([ + expect.arrayContaining([1, 'completed']), + [2, 'response-two', ['slotRequested', 'slotRenderEnded'], 'completed'], ]); mutations.mockClear(); slot.setTargeting('publisher', 'later'); @@ -343,17 +361,17 @@ describe('first-display GPT adapter', () => { }); expect( - adapter.start({ - onBound: ({ element, slotId, ownership }) => { - expect(element).toBe(dom.window.document.getElementById('slot-1')); - events.push(`bound:${slotId}:${ownership}`); + startAdapter(adapter, { + onBound: (cycle) => { + expect(cycle[1]).toBe(dom.window.document.getElementById('slot-1')); + events.push(`bound:${cycle[6]}:${cycle[3]}`); }, onFailure: (slotId, reason) => failures.push([slotId, reason]), onFirstAction: () => { events.push('first-action'); return true; }, - onRenderEnded: (cycle, result) => renders.push([cycle.slotId, result]), + onRenderEnded: (cycle, result) => renders.push([cycle[6], result]), }) ).toBe(true); expect(events).toEqual([ @@ -424,7 +442,7 @@ describe('first-display GPT adapter', () => { value: binding, }); const batch = snapshotFirstDisplayBatchV1(fixture())!; - let bound: { readonly isCurrent: () => boolean } | undefined; + const bound: { value?: Parameters[0] } = {}; const retired = vi.fn(); const adapter = createFirstDisplayGoogletagBatch({ browser: dom.window as unknown as Window, @@ -434,9 +452,9 @@ describe('first-display GPT adapter', () => { protocol: protocol(), setTimer: (callback) => callback, }); - adapter.start({ + startAdapter(adapter, { onBound: (cycle) => { - bound = cycle; + bound.value = cycle; }, onFailure: () => undefined, onFirstAction: () => true, @@ -444,17 +462,17 @@ describe('first-display GPT adapter', () => { onRetire: retired, }); - expect(bound?.isCurrent()).toBe(true); + expect(bound.value?.[2]()).toBe(true); listeners.get('slotRequested')?.({ slot }); listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); - expect(bound?.isCurrent()).toBe(true); + expect(bound.value?.[2]()).toBe(true); expect(() => binding.destroySlots([slot])).toThrow('fictional destroy failure'); - expect(bound?.isCurrent()).toBe(true); + expect(bound.value?.[2]()).toBe(true); expect(retired).not.toHaveBeenCalled(); destroyMode = 'false'; expect(binding.destroySlots([slot])).toBe(false); - expect(bound?.isCurrent()).toBe(true); + expect(bound.value?.[2]()).toBe(true); expect(retired).not.toHaveBeenCalled(); destroyMode = 'true'; const targets = [slot]; @@ -462,7 +480,7 @@ describe('first-display GPT adapter', () => { expect(targets).toEqual([]); expect(binding.defineSlot('/publisher/replacement', [[300, 250]], 'slot-1')).toBe(replacement); binding.display('slot-1'); - expect(bound?.isCurrent()).toBe(false); + expect(bound.value?.[2]()).toBe(false); expect(retired).toHaveBeenCalledOnce(); }); @@ -484,7 +502,7 @@ describe('first-display GPT adapter', () => { clearTimer: () => undefined, }); expect( - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => true, @@ -545,8 +563,8 @@ describe('first-display GPT adapter', () => { }); const firstAction = vi.fn(() => true); - adapter.start({ - onBound: ({ ownership }) => expect(ownership).toBe('publisher'), + startAdapter(adapter, { + onBound: (cycle) => expect(cycle[3]).toBe('publisher'), onFailure: () => undefined, onFirstAction: firstAction, onRenderEnded: () => undefined, @@ -560,7 +578,7 @@ describe('first-display GPT adapter', () => { listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); slot.setTargeting('placement', 'article'); expect(adapter.closeIngress(['slot-1'])).toBe(true); - expect(adapter.captureHandoff()?.[0]?.targetingOwnership).toEqual([ + expect(adapter.captureHandoff()?.[0]?.[3]).toEqual([ { installed: RESERVATION_ID, key: 'hb_adid', prior: [] }, { installed: '1.25', key: 'hb_pb', prior: ['publisher-original'] }, ]); @@ -611,7 +629,7 @@ describe('first-display GPT adapter', () => { setTimer: (callback) => callback, clearTimer: () => undefined, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => true, @@ -632,7 +650,7 @@ describe('first-display GPT adapter', () => { slot.setTargeting('hb_pb', '1.25'); expect(adapter.closeIngress(['slot-1'])).toBe(true); - expect(adapter.captureHandoff()?.[0]?.targetingOwnership).toEqual([]); + expect(adapter.captureHandoff()?.[0]?.[3]).toEqual([]); expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); adapter.dispose(); expect(replacement).toHaveBeenCalledWith('hb_pb', '1.25'); @@ -682,7 +700,7 @@ describe('first-display GPT adapter', () => { setTimer: (callback) => callback, clearTimer: () => undefined, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => true, @@ -764,7 +782,7 @@ describe('first-display GPT adapter', () => { setTimer: (callback) => callback, clearTimer: () => undefined, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => true, @@ -834,7 +852,7 @@ describe('first-display GPT adapter', () => { setTimer: (callback) => callback, clearTimer: () => undefined, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => true, @@ -845,9 +863,7 @@ describe('first-display GPT adapter', () => { sealing = true; expect(adapter.closeIngress(['slot-1'])).toBe(true); - expect( - adapter.captureHandoff()?.[0]?.targetingOwnership.some(({ key }) => key === 'hb_pb') - ).toBe(false); + expect(adapter.captureHandoff()?.[0]?.[3].some(({ key }) => key === 'hb_pb')).toBe(false); expect(reentrantSealingCalls).toBe(1); expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); adapter.dispose(); @@ -907,7 +923,7 @@ describe('first-display GPT adapter', () => { setTimer: (callback) => callback, clearTimer: () => undefined, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => true, @@ -920,9 +936,7 @@ describe('first-display GPT adapter', () => { expect(adapter.closeIngress(['slot-1'])).toBe(true); expect(destroySlots).toHaveBeenCalledOnce(); - expect( - adapter.captureHandoff()?.[0]?.targetingOwnership.some(({ key }) => key === 'hb_pb') - ).toBe(false); + expect(adapter.captureHandoff()?.[0]?.[3].some(({ key }) => key === 'hb_pb')).toBe(false); expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); adapter.dispose(); expect(destroySlots).toHaveBeenCalledOnce(); @@ -991,7 +1005,7 @@ describe('first-display GPT adapter', () => { setTimer: (callback) => callback, clearTimer: () => undefined, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => true, @@ -1006,9 +1020,7 @@ describe('first-display GPT adapter', () => { expect(adapter.closeIngress(['slot-1'])).toBe(true); expect(failedTargeting.get('hb_pb')).toEqual(['failed-original']); expect(reentrantCleanupCalls).toBe(1); - expect( - adapter.captureHandoff()?.[0]?.targetingOwnership.some(({ key }) => key === 'hb_pb') - ).toBe(false); + expect(adapter.captureHandoff()?.[0]?.[3].some(({ key }) => key === 'hb_pb')).toBe(false); const cleanupWrites = failedSlot.clearTargeting.mock.calls.length + failedSlot.setTargeting.mock.calls.length; @@ -1056,7 +1068,7 @@ describe('first-display GPT adapter', () => { setTimer: (callback) => callback, clearTimer: () => undefined, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => { @@ -1092,7 +1104,7 @@ describe('first-display GPT adapter', () => { }, clearTimer: () => undefined, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: (slotId, reason) => failures.push([slotId, reason]), onFirstAction: () => true, @@ -1150,11 +1162,11 @@ describe('first-display GPT adapter', () => { if (index >= 0) timers.splice(index, 1); }, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: (slotId, reason) => failures.push([slotId, reason]), onFirstAction: () => true, - onRenderEnded: (cycle, result) => renders.push([cycle.slotId, result]), + onRenderEnded: (cycle, result) => renders.push([cycle[6], result]), }); listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); @@ -1203,7 +1215,7 @@ describe('first-display GPT adapter', () => { setTimer: (callback) => callback, clearTimer: () => undefined, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => true, @@ -1251,7 +1263,7 @@ describe('first-display GPT adapter', () => { setTimer: (callback) => callback, clearTimer: () => undefined, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => true, @@ -1261,9 +1273,7 @@ describe('first-display GPT adapter', () => { listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); expect(adapter.closeIngress(['slot-1'])).toBe(true); - expect(adapter.captureHandoff()).toEqual([ - expect.objectContaining({ slotId: 'slot-1', physicalSlot: slot }), - ]); + expect(adapter.captureHandoff()).toEqual([expect.arrayContaining(['slot-1', slot])]); expect(adapter.detachCommittedSlots(['slot-1'])).toBe(true); expect(adapter.detachCommittedSlots(['slot-1'])).toBe(false); adapter.dispose(); @@ -1310,7 +1320,7 @@ describe('first-display GPT adapter', () => { protocol: protocol(), setTimer: (callback) => callback, }); - adapter.start({ + startAdapter(adapter, { onBound: () => undefined, onFailure: () => undefined, onFirstAction: () => true, @@ -1341,27 +1351,22 @@ describe('first-display GPT adapter', () => { 'impressionViewable', 'slotVisibilityChanged', ]); - expect(diagnostics).toMatchObject({ - nextTraceTokenOrdinal: 2, - overflowCount: 0, - dropCount: 0, - }); - expect(diagnostics?.facts).toHaveLength(6); - expect( - ( - diagnostics as unknown as { - readonly cycles: readonly unknown[]; - } - )?.cycles - ).toEqual([ - { - nextCycleOrdinal: 2, - quarantines: [], - records: [ - { - ordinal: 1, - responseIdentifier: 'response-one', - seen: [ + expect(diagnostics?.[2]).toBe(2); + expect(diagnostics?.[3]).toBe(0); + expect(diagnostics?.[4]).toBe(0); + expect(diagnostics?.[1]).toHaveLength(6); + expect(diagnostics?.[0]).toEqual([ + [ + 'slot-1', + 'gt1_1', + 2, + false, + [], + [ + [ + 1, + 'response-one', + [ 'slotRequested', 'slotResponseReceived', 'slotRenderEnded', @@ -1369,15 +1374,12 @@ describe('first-display GPT adapter', () => { 'impressionViewable', 'slotVisibilityChanged', ], - state: 'completed', - }, + 'completed', + ], ], - slotId: 'slot-1', - token: 'gt1_1', - unknownPriorCycle: false, - }, + ], ]); - expect(diagnostics?.facts[0]).toEqual({ + expect(diagnostics?.[1][0]).toEqual({ version: 1, event: 'slotRequested', token: 'gt1_1', @@ -1394,14 +1396,14 @@ describe('first-display GPT adapter', () => { slotContentChanged: null, visibilityPercent: null, }); - expect(diagnostics?.facts[2]).toMatchObject({ + expect(diagnostics?.[1][2]).toMatchObject({ event: 'slotRenderEnded', isEmpty: false, renderedSize: [300, 250], isBackfill: false, slotContentChanged: true, }); - expect(diagnostics?.facts[5]).toMatchObject({ + expect(diagnostics?.[1][5]).toMatchObject({ event: 'slotVisibilityChanged', visibilityPercent: 42, }); diff --git a/crates/trusted-server-js/lib/test/first_display/handoff.test.ts b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts index c7bcc9b33..2a02e9166 100644 --- a/crates/trusted-server-js/lib/test/first_display/handoff.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/handoff.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { createFirstDisplayHandoffOwner } from '../../src/shared/first_display_handoff'; +import { + createFirstDisplayHandoffOwner, + finalizeFirstDisplayAgentCaptureV1, +} from '../../src/shared/first_display_handoff'; const RELEASE_ID = 'a'.repeat(64); const RESERVATION_ID = `r1_${'a'.repeat(22)}`; @@ -10,7 +13,7 @@ function handoff( overrides: Record = {} ): Record { return { - version: 1, + captureVersion: 1, releaseId: RELEASE_ID, generation: 1, projectionDigest: 'b'.repeat(64), @@ -35,12 +38,14 @@ function handoff( cycles: [], trace: { nextSequence: 1, nextGlobalSlotOrdinal: 1, slots: [] }, mutationRevision: revision, + identityCount: 0, ...overrides, }; } function acceptedHandoff(revision: number): Record { return handoff(revision, { + identityCount: 2, slices: ['first_display', 'gpt_initial'], slots: [ { @@ -157,6 +162,46 @@ function owner(options: { initialRevision?: number } = {}) { } describe('first-display final handoff owner', () => { + it('materializes the compact data handoff and capsule only inside the finalizer call', () => { + const artifact = {}; + const physicalSlot = {}; + const finalized = finalizeFirstDisplayAgentCaptureV1([ + { + releaseId: RELEASE_ID, + generation: 1, + integrationConfigDigest: 'c'.repeat(64), + slices: ['first_display'], + }, + ['b'.repeat(64), []], + new Map(), + new Map(), + new Map(), + [], + [['slot-1', 'slot-1', 'trusted_server', [], 'gt1_1', physicalSlot]], + [[['slot-1', 'gt1_1', 2, false, [], []]], [], 2, 0, 0], + [ + [[null, null, artifact, 'gpt_adm', 'trusted_server', 'slot-1', 'reservation-1']], + [], + 3, + 1, + 1, + ], + [1, null, 2, 3, 3], + 1, + 0, + ]); + + expect(finalized?.handoff).toMatchObject({ + captureVersion: 1, + releaseId: RELEASE_ID, + generation: 1, + mutationRevision: 0, + identityCount: 2, + }); + expect(finalized?.capsule.consume(RELEASE_ID, 1)).toEqual([physicalSlot, artifact]); + expect(finalized?.capsule.consume(RELEASE_ID, 1)).toBeUndefined(); + }); + it('seals the final revision and mints one release-bound capsule in the same task', () => { const h = owner(); const physicalSlot = {}; @@ -234,7 +279,7 @@ describe('first-display final handoff owner', () => { expect(stale.failures).toEqual(['bundle_partial']); }); - it('rejects nonterminal/live-authority data, wrong identity, and revision exhaustion', () => { + it('defers semantic validation but rejects wrong identity and revision exhaustion', () => { const nonterminal = owner(); expect( nonterminal.value.finalize(() => ({ @@ -265,8 +310,8 @@ describe('first-display final handoff owner', () => { }), identities: [], })) - ).toBeUndefined(); - expect(nonterminal.failures).toEqual(['bundle_partial']); + ).toBeDefined(); + expect(nonterminal.failures).toEqual([]); const badIdentity = owner(); expect( diff --git a/crates/trusted-server-js/lib/test/first_display/helpers/compact_takeover.ts b/crates/trusted-server-js/lib/test/first_display/helpers/compact_takeover.ts new file mode 100644 index 000000000..0ab9bc76e --- /dev/null +++ b/crates/trusted-server-js/lib/test/first_display/helpers/compact_takeover.ts @@ -0,0 +1,191 @@ +export function failedFirstDisplayTakeover(releaseId: string) { + const projectionDigest = 'b'.repeat(64); + const integrationConfigDigest = 'c'.repeat(64); + const projection = { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'slot-1', outcome: 'failed', reason: 'internal_error' }], + }, + slots: [ + { + slot: 'slot-1', + gamUnitPath: '/123/slot-1', + divId: 'div-1', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [], + }; + return { + capture: { + captureVersion: 1, + releaseId, + generation: 1, + data: [ + projectionDigest, + integrationConfigDigest, + ['first_display'], + [['failed', 'internal_error', null, null]], + [], + [], + [], + [], + [[], 0, 0], + [0, null, 0, 0], + [2, 0, 1, 1], + [], + 1, + 2, + ], + mutationRevision: 0, + identityCount: 0, + }, + outline: { + version: 1, + releaseId, + generation: 1, + projectionDigest, + integrationConfigDigest, + slices: ['first_display'], + slotCount: 1, + outcomeCount: 1, + capabilities: [], + objectKinds: [], + }, + boot: { + abi: 1, + releaseId, + manifest: {}, + auctionProjection: projection, + integrations: {}, + creative: {}, + diagnostics: {}, + }, + } as const; +} + +export function acceptedGptFirstDisplayTakeover( + releaseId: string, + reservationId: string, + parserState: 'valid' | 'invalid' | 'absent', + gamAttributionEnabled: boolean +) { + const projectionDigest = 'b'.repeat(64); + const integrationConfigDigest = 'c'.repeat(64); + const slices = + parserState === 'absent' + ? (['first_display'] as const) + : (['first_display', 'gpt_initial'] as const); + const projection = { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'takeover-slot', outcome: 'winner', candidateId: 'AAAAAAAAAAAA' }], + }, + slots: [ + { + slot: 'takeover-slot', + gamUnitPath: '/123/takeover-slot', + divId: 'takeover-slot', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [ + { + candidateId: 'AAAAAAAAAAAA', + slot: 'takeover-slot', + provider: 'trusted', + upstreamBidId: 'bid-1', + cpm: 1, + currency: 'USD', + targeting: {}, + rendererReservationId: reservationId, + renderSource: { + type: 'adm', + version: 1, + adm: '
creative
', + width: 300, + height: 250, + }, + }, + ], + }; + const cycles = [ + { + nextCycleOrdinal: 2, + quarantines: [], + records: [ + { + ordinal: 1, + responseIdentifier: 'response-one', + seen: ['slotRequested', 'slotRenderEnded'], + state: 'completed', + }, + ], + slotId: 'takeover-slot', + token: 'gt1_1', + unknownPriorCycle: false, + }, + ]; + return { + capture: { + captureVersion: 1, + releaseId, + generation: 1, + data: [ + projectionDigest, + integrationConfigDigest, + slices, + [['accepted', null, 2, 1]], + [['takeover-slot', 'takeover-slot', 'publisher', [], 'gt1_1']], + [], + [[null, null, 'takeover-slot', 'gpt_adm', 'trusted_server', reservationId]], + parserState === 'valid' + ? [ + [ + 'gpt_initial', + [ + ['gam', gamAttributionEnabled], + ['v', 1], + ], + ], + ] + : [], + [[], 0, 0], + [1, 2, 3, 4], + [2, 0, 2, 1], + cycles, + 2, + 2, + ], + mutationRevision: 0, + identityCount: 2, + }, + outline: { + version: 1, + releaseId, + generation: 1, + projectionDigest, + integrationConfigDigest, + slices, + slotCount: 1, + outcomeCount: 1, + capabilities: [], + objectKinds: ['gpt_slot', 'dom_artifact'], + }, + boot: { + abi: 1, + releaseId, + manifest: {}, + auctionProjection: projection, + integrations: {}, + creative: {}, + diagnostics: {}, + }, + } as const; +} diff --git a/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts b/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts index e22141a78..9243bffd6 100644 --- a/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts +++ b/crates/trusted-server-js/lib/test/first_display/helpers/render_owner_composition.ts @@ -1,17 +1,36 @@ -import type { FirstDisplayApsProtocolV1 } from '../../../src/first_display/leaf/aps_protocol'; +import type { FirstDisplayApsPolicyV1 } from '../../../src/first_display/leaf/aps_protocol'; import { createFirstDisplayApsRenderStrategy } from '../../../src/first_display/render_bridge'; import { createFirstDisplayRenderJournal, type FirstDisplayRenderOwnerOptionsV1, } from '../../../src/first_display/render_journal'; -type TestRenderBridgeOptions = FirstDisplayRenderOwnerOptionsV1 & - Readonly<{ getAps: () => FirstDisplayApsProtocolV1 | undefined }>; +type TestRenderBridgeOptions = Readonly<{ + browser: FirstDisplayRenderOwnerOptionsV1[0]; + clearTimer: FirstDisplayRenderOwnerOptionsV1[1]; + createChannel: FirstDisplayRenderOwnerOptionsV1[2]; + document: FirstDisplayRenderOwnerOptionsV1[3]; + fillRandom: FirstDisplayRenderOwnerOptionsV1[4]; + now: FirstDisplayRenderOwnerOptionsV1[5]; + onNativeMutation?: NonNullable; + setTimer: FirstDisplayRenderOwnerOptionsV1[7]; + getAps: () => FirstDisplayApsPolicyV1 | undefined; +}>; /** Compose the production owner and APS capabilities without creating a production graph seam. */ export function createTestFirstDisplayRenderBridge(options: TestRenderBridgeOptions) { const { getAps, ...ownerOptions } = options; + const capabilities: FirstDisplayRenderOwnerOptionsV1 = Object.freeze([ + ownerOptions.browser, + ownerOptions.clearTimer, + ownerOptions.createChannel, + ownerOptions.document, + ownerOptions.fillRandom, + ownerOptions.now, + ownerOptions.onNativeMutation, + ownerOptions.setTimer, + ]); const aps = getAps(); - const strategy = aps ? createFirstDisplayApsRenderStrategy(ownerOptions, aps) : undefined; - return createFirstDisplayRenderJournal(ownerOptions, strategy); + const strategy = aps ? createFirstDisplayApsRenderStrategy(capabilities, aps) : undefined; + return createFirstDisplayRenderJournal(capabilities, strategy); } diff --git a/crates/trusted-server-js/lib/test/first_display/projection.test.ts b/crates/trusted-server-js/lib/test/first_display/projection.test.ts index bf8c3f54e..0de9fc5b9 100644 --- a/crates/trusted-server-js/lib/test/first_display/projection.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/projection.test.ts @@ -83,10 +83,11 @@ describe('first-display projection snapshot', () => { it('accepts only the already-frozen server envelope used by the production agent', () => { const candidate = freezeTree(winnerFixture({ provider: 'prebid' })); - expect(acceptServerFirstDisplayBatchV1(candidate)).toMatchObject({ - requiredProtocols: ['gpt', 'prebid'], - outcomes: [{ slotId: 'slot-1', kind: 'gpt_adm' }], - }); + expect(acceptServerFirstDisplayBatchV1(candidate)?.slice(0, 3)).toEqual([ + 'b'.repeat(64), + ['gpt', 'prebid'], + [['slot-1', 'gpt_adm']], + ]); expect(acceptServerFirstDisplayBatchV1(winnerFixture())).toBeUndefined(); expect( acceptServerFirstDisplayBatchV1(freezeTree(winnerFixture({ source: 'pbs_cache' }))) diff --git a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts index af9513d2b..c7f1bce48 100644 --- a/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/render_bridge.test.ts @@ -81,16 +81,16 @@ function fixture(kind: 'adm' | 'aps' = 'adm') { targeting: Object.freeze({}), }); let cycleCurrent = true; - const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze({ + const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze([ bid, element, - ownership: 'trusted_server', - physicalSlot: {}, - isCurrent: () => cycleCurrent, + () => cycleCurrent, + 'trusted_server', + {}, placement, - slotId: 'slot-1', - traceToken: 'gt1_1', - }); + 'slot-1', + 'gt1_1', + ]); return { cycle, dom, element, invalidateCycle: () => (cycleCurrent = false) }; } @@ -367,8 +367,8 @@ function bindMixedAdm(h: ReturnType) { element.id = 'slot-2'; h.dom.window.document.body.appendChild(element); const reservationId = `r1_${'b'.repeat(22)}`; - const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze({ - bid: Object.freeze({ + const cycle: FirstDisplayGptBoundCycleV1 = Object.freeze([ + Object.freeze({ candidateId: 'candidate002', slot: 'slot-2', provider: 'example', @@ -386,19 +386,19 @@ function bindMixedAdm(h: ReturnType) { }), }), element, - ownership: 'trusted_server', - physicalSlot: {}, - isCurrent: () => true, - placement: Object.freeze({ + () => true, + 'trusted_server', + {}, + Object.freeze({ slot: 'slot-2', gamUnitPath: '/123/example-2', divId: 'slot-2', formats: Object.freeze([Object.freeze([300, 250] as const)]), targeting: Object.freeze({}), }), - slotId: 'slot-2', - traceToken: 'gt1_2', - }); + 'slot-2', + 'gt1_2', + ]); const terminal = vi.fn(); expect(h.bridge.bind(cycle, terminal)).toBe(true); return { cycle, element, reservationId, terminal }; @@ -425,14 +425,18 @@ describe('bounded first-display render bridge', () => { h.bridge.sealTsAdmission(); expect(h.bridge.closeIngress()).toBe(true); const handoff = h.bridge.captureHandoff(); - expect(handoff?.artifacts.map(({ kind, slotId, token }) => ({ kind, slotId, token }))).toEqual([ + expect( + handoff?.[0].map((artifact) => ({ + kind: artifact[3], + slotId: artifact[5], + token: artifact[6], + })) + ).toEqual([ { kind: 'gpt_adm', slotId: 'slot-2', token: adm.reservationId }, { kind: 'aps', slotId: 'slot-1', token: RESERVATION_ID }, ]); expect( - new Set( - handoff?.tombstones.filter(({ kind }) => kind === 'reservation').map(({ value }) => value) - ) + new Set(handoff?.[1].filter((entry) => entry[0] === 'reservation').map((entry) => entry[1])) ).toEqual(new Set([adm.reservationId, RESERVATION_ID])); expect(h.bridge.detachCommittedArtifacts()).toBe(true); h.bridge.dispose(); @@ -560,7 +564,7 @@ describe('bounded first-display render bridge', () => { message: 'TS ADM Start', version: 1, lifecycleTicket: ticket, - source: h.cycle.bid.renderSource, + source: h.cycle[0].renderSource, }); h.channels[0]?.port1.dispatch({ @@ -728,7 +732,7 @@ describe('bounded first-display render bridge', () => { h.bridge.sealTsAdmission(); expect(h.bridge.closeIngress()).toBe(true); - expect(h.bridge.captureHandoff()?.artifacts).toEqual([]); + expect(h.bridge.captureHandoff()?.[0]).toEqual([]); expect(h.bridge.detachCommittedArtifacts()).toBe(true); } ); @@ -754,7 +758,7 @@ describe('bounded first-display render bridge', () => { h.bridge.sealTsAdmission(); expect(h.bridge.closeIngress()).toBe(true); - expect(h.bridge.captureHandoff()?.artifacts).toEqual([]); + expect(h.bridge.captureHandoff()?.[0]).toEqual([]); expect(h.bridge.detachCommittedArtifacts()).toBe(true); } ); @@ -837,7 +841,7 @@ describe('bounded first-display render bridge', () => { version: 1, nonce, publisherOrigin: 'https://publisher.example', - renderer: h.cycle.bid.renderSource, + renderer: h.cycle[0].renderSource, }, [] ); @@ -992,30 +996,13 @@ describe('bounded first-display render bridge', () => { h.bridge.sealTsAdmission(); expect(h.bridge.closeIngress()).toBe(true); - expect(h.bridge.captureHandoff()).toEqual({ - artifacts: [ - { - hostPosition: null, - hostPositionPriority: null, - identity: frame, - kind: 'gpt_adm', - owner: 'trusted_server', - slotId: 'slot-1', - token: RESERVATION_ID, - }, - ], - clockEpochMs: 0, - nextReservationOrdinal: 2, - nextTicketOrdinal: 1, - tombstones: [ - { - expiresAtMs: 900_000, - kind: 'reservation', - ordinal: 1, - value: RESERVATION_ID, - }, - ], - }); + expect(h.bridge.captureHandoff()).toEqual([ + [[null, null, frame, 'gpt_adm', 'trusted_server', 'slot-1', RESERVATION_ID]], + [['reservation', RESERVATION_ID, 900_000, 1]], + 0, + 2, + 1, + ]); expect(h.bridge.detachCommittedArtifacts()).toBe(true); expect(h.bridge.detachCommittedArtifacts()).toBe(false); h.bridge.dispose(); diff --git a/crates/trusted-server-js/lib/test/first_display/slices.test.ts b/crates/trusted-server-js/lib/test/first_display/slices.test.ts index 30362b5bd..5416466ec 100644 --- a/crates/trusted-server-js/lib/test/first_display/slices.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/slices.test.ts @@ -2,22 +2,27 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; import { + APS_INITIAL_SLICE, + CREATIVE_INITIAL_SLICE, + DATADOME_INITIAL_SLICE, + DIDOMI_INITIAL_SLICE, + GOOGLE_TAG_MANAGER_INITIAL_SLICE, + GPT_INITIAL_SLICE, INITIAL_SLICE_DEFINITIONS, + LOCKR_INITIAL_SLICE, + OSANO_INITIAL_SLICE, + PERMUTIVE_INITIAL_SLICE, + PREBID_INITIAL_SLICE, selectInitialSliceDefinitions, + SOURCEPOINT_INITIAL_SLICE, + TESTLIGHT_INITIAL_SLICE, } from '../../src/first_display/composition'; -import { DIDOMI_INITIAL_SLICE } from '../../src/first_display/slices/didomi'; -import { CREATIVE_INITIAL_SLICE } from '../../src/first_display/slices/creative'; -import { APS_INITIAL_SLICE } from '../../src/first_display/slices/aps'; -import { GPT_INITIAL_SLICE } from '../../src/first_display/slices/gpt'; -import { PREBID_INITIAL_SLICE } from '../../src/first_display/slices/prebid'; -import { DATADOME_INITIAL_SLICE } from '../../src/first_display/slices/datadome'; -import { GOOGLE_TAG_MANAGER_INITIAL_SLICE } from '../../src/first_display/slices/google_tag_manager'; -import { LOCKR_INITIAL_SLICE } from '../../src/first_display/slices/lockr'; -import { OSANO_INITIAL_SLICE } from '../../src/first_display/slices/osano'; -import { PERMUTIVE_INITIAL_SLICE } from '../../src/first_display/slices/permutive'; -import { SOURCEPOINT_INITIAL_SLICE } from '../../src/first_display/slices/sourcepoint'; -import { TESTLIGHT_INITIAL_SLICE } from '../../src/first_display/slices/testlight'; -import type { InitialSliceInstaller } from '../../src/first_display/slices/definition'; +import type { + FirstDisplaySliceHost, + InitialSliceDefinition, + InitialSliceInstaller, +} from '../../src/first_display/slices/definition'; +import type { FirstDisplaySliceActivationContext } from '../../src/shared/first_display_transaction'; import type { FirstDisplayRouteRuleV1 } from '../../src/first_display/leaf/route_guard'; import { installDidomiInitial } from '../../src/first_display/leaf/config_guard'; import type { FirstDisplayCreativeGuardV1 } from '../../src/first_display/leaf/creative_guard'; @@ -27,6 +32,7 @@ import { } from '../../src/first_display/leaf/aps_protocol'; import { installGptInitial, + type FirstDisplayGptBatchPolicyV1, type FirstDisplayGptProtocolV1, } from '../../src/first_display/leaf/gpt_protocol'; import { @@ -81,10 +87,18 @@ function componentRegistration(): FirstDisplayComponentRegistrationV1 { id: 'gpt_initial', releaseId: RELEASE_ID, order: 7, - prepare: () => Object.freeze({ activate: () => undefined }), + install: () => undefined, }); } +function activateInitialSlice( + definition: InitialSliceDefinition, + host: FirstDisplaySliceHost, + context: FirstDisplaySliceActivationContext +): void { + host.activate(definition.id, context.own, definition.install); +} + describe('first-display initial slice definitions', () => { it('captures bounded parser observations once per key in canonical slice order', () => { const collector = createFirstDisplayParserStateCollector(); @@ -152,7 +166,7 @@ describe('first-display initial slice definitions', () => { own ), installGptInitial( - Object.freeze({ gam: () => true, observe, register }), + Object.freeze({ browser: {} as Window, observe, register }), own, () => Object.freeze({ @@ -160,13 +174,7 @@ describe('first-display initial slice definitions', () => { closeIngress: () => true, captureHandoff: () => Object.freeze([]), captureDiagnosticsHandoff: () => - Object.freeze({ - cycles: Object.freeze([]), - facts: Object.freeze([]), - nextTraceTokenOrdinal: 1, - overflowCount: 0, - dropCount: 0, - }), + Object.freeze([Object.freeze([]), Object.freeze([]), 1, 0, 0] as const), detachCommittedSlots: () => true, dispose: () => undefined, }), @@ -176,12 +184,12 @@ describe('first-display initial slice definitions', () => { ]; expect(receipts).toEqual([ - { version: 1, id: 'aps' }, - { version: 1, id: 'gpt' }, - { version: 1, id: 'prebid' }, + [1, 'aps'], + [1, 'gpt'], + [1, 'prebid'], ]); for (const receipt of receipts) { - expect(Reflect.ownKeys(receipt)).toEqual(['version', 'id']); + expect(Reflect.ownKeys(receipt)).toEqual(['0', '1', 'length']); expect(Object.isFrozen(receipt)).toBe(true); } expect(register).toHaveBeenCalledTimes(3); @@ -211,32 +219,38 @@ describe('first-display initial slice definitions', () => { }), (dispose) => owned.push(dispose) ) - ).toEqual({ version: 1, id: 'render_owner' }); - const bridge = protocol?.createRenderBridge({ - browser: dom.window as unknown as Window, - clearTimer: () => undefined, - createChannel: () => { + ).toEqual([1, 'render_owner']); + expect(protocol).toEqual([1, 'render_owner', expect.any(Function)]); + expect(Object.isFrozen(protocol)).toBe(true); + const createRenderBridge = protocol?.[2]; + const bridge = createRenderBridge?.([ + dom.window as unknown as Window, + () => undefined, + () => { throw new Error('unused'); }, - document: dom.window.document, - fillRandom: () => undefined, - now: () => 0, - setTimer: () => ({}), - }); + dom.window.document, + () => undefined, + () => 0, + undefined, + () => ({}), + ]); - expect(bridge).toBeDefined(); + expect(bridge).toHaveLength(10); + expect(Object.isFrozen(bridge)).toBe(true); expect(() => - protocol?.createRenderBridge({ - browser: dom.window as unknown as Window, - clearTimer: () => undefined, - createChannel: () => { + createRenderBridge?.([ + dom.window as unknown as Window, + () => undefined, + () => { throw new Error('unused'); }, - document: dom.window.document, - fillRandom: () => undefined, - now: () => 0, - setTimer: () => ({}), - }) + dom.window.document, + () => undefined, + () => 0, + undefined, + () => ({}), + ]) ).toThrow(TypeError); expect(owned).toHaveLength(1); @@ -250,28 +264,28 @@ describe('first-display initial slice definitions', () => { 'projects the typed GAM attribution flag into the first-display GPT owner (%s)', (gamAttributionEnabled) => { const observe = vi.fn(); - const gam = vi.fn(() => true); + const commands: Array<() => void> = []; + const setConfig = vi.fn(); + const browser = { + googletag: { cmd: commands, setConfig }, + } as unknown as Window; let protocol: FirstDisplayGptProtocolV1 | undefined; - const createBatch = vi.fn((_input: unknown) => - Object.freeze({ + let policy: FirstDisplayGptBatchPolicyV1 | undefined; + const createBatch = vi.fn((_input: unknown, candidate: FirstDisplayGptBatchPolicyV1) => { + policy = candidate; + return Object.freeze({ start: () => true, closeIngress: () => true, captureHandoff: () => Object.freeze([]), captureDiagnosticsHandoff: () => - Object.freeze({ - cycles: Object.freeze([]), - facts: Object.freeze([]), - nextTraceTokenOrdinal: 1, - overflowCount: 0, - dropCount: 0, - }), + Object.freeze([Object.freeze([]), Object.freeze([]), 1, 0, 0] as const), detachCommittedSlots: () => true, dispose: () => undefined, - }) - ); + }); + }); installGptInitial( Object.freeze({ - gam, + browser, observe, register: (candidate: FirstDisplayGptProtocolV1) => { protocol = candidate; @@ -283,11 +297,31 @@ describe('first-display initial slice definitions', () => { Object.freeze({ gamAttributionEnabled, pageBidsEnabled: true }) ); - protocol?.createBatch({} as never); + expect(protocol).toEqual([1, 'gpt', expect.any(Function)]); + expect(Object.isFrozen(protocol)).toBe(true); + const batch = protocol?.[2]({} as never); + expect(batch).toHaveLength(6); + expect(Object.isFrozen(batch)).toBe(true); expect(createBatch).toHaveBeenCalledOnce(); expect(createBatch.mock.calls[0]?.[0]).toEqual({}); - expect(gam).toHaveBeenCalledTimes(gamAttributionEnabled ? 1 : 0); + expect(policy?.deadlines).toEqual({ + externalReadyMs: 10_000, + requestStartMs: 3_000, + completionMs: 10_000, + }); + expect( + policy?.requestPlan( + Object.freeze({ initialLoadDisabled: true, ownership: 'trusted_server' }) + ) + ).toEqual({ operations: ['display', 'refresh'], requestOperation: 1 }); + expect(policy?.classifyRenderEnded(Object.freeze({ isEmpty: false }))).toBe('nonempty_gam'); + expect(commands).toHaveLength(gamAttributionEnabled ? 1 : 0); + commands.splice(0).forEach((command) => command()); + expect(setConfig).toHaveBeenCalledTimes(gamAttributionEnabled ? 1 : 0); + if (gamAttributionEnabled) { + expect(setConfig).toHaveBeenCalledExactlyOnceWith({ targeting: { ts: 'true' } }); + } expect(observe).toHaveBeenCalledWith('gam', gamAttributionEnabled); } ); @@ -310,24 +344,27 @@ describe('first-display initial slice definitions', () => { ]); }); - it('prepares inertly and activates only its exact host obligation', () => { + it('exposes only each exact initial-slice installer obligation', () => { const events: string[] = []; const dispose = vi.fn(); const host = Object.freeze({ - activate: (id: string, own: (callback: () => void) => void) => { + activate: ( + id: string, + own: (callback: () => void) => void, + _install: InitialSliceInstaller + ) => { own(dispose); events.push(id); }, }); - const prepared = INITIAL_SLICE_DEFINITIONS.map((definition) => definition.prepare(host)); - expect(events).toEqual([]); - for (const slice of prepared) - slice.activate(Object.freeze({ own: () => undefined, afterActivate: () => undefined })); + for (const definition of INITIAL_SLICE_DEFINITIONS) { + host.activate(definition.id, () => undefined, definition.install); + } expect(events).toEqual(INITIAL_SLICE_DEFINITIONS.map(({ id }) => id)); expect( INITIAL_SLICE_DEFINITIONS.every( - (definition) => Reflect.ownKeys(definition).join(',') === 'id,prepare' + (definition) => Reflect.ownKeys(definition).join(',') === 'id,install' ) ).toBe(true); }); @@ -489,9 +526,9 @@ describe('first-display initial slice definitions', () => { install?.(bindings, own, undefined); }, }); - const prepared = DIDOMI_INITIAL_SLICE.prepare(host); - - prepared.activate( + activateInitialSlice( + DIDOMI_INITIAL_SLICE, + host, Object.freeze({ own: (dispose: () => void) => disposers.push(dispose), afterActivate: () => undefined, @@ -610,7 +647,9 @@ describe('first-display initial slice definitions', () => { }, }); - TESTLIGHT_INITIAL_SLICE.prepare(host).activate( + activateInitialSlice( + TESTLIGHT_INITIAL_SLICE, + host, Object.freeze({ own: (dispose: () => void) => disposers.push(dispose), afterActivate: () => undefined, @@ -683,7 +722,9 @@ describe('first-display initial slice definitions', () => { undefined ), }); - fixture.definition.prepare(host).activate( + activateInitialSlice( + fixture.definition, + host, Object.freeze({ own: (release: () => void) => disposers.push(release), afterActivate: () => undefined, @@ -732,7 +773,9 @@ describe('first-display initial slice definitions', () => { ) => install?.(bindings, own, undefined), }); - LOCKR_INITIAL_SLICE.prepare(host).activate( + activateInitialSlice( + LOCKR_INITIAL_SLICE, + host, Object.freeze({ own: (release: () => void) => disposers.push(release), afterActivate: () => undefined, @@ -803,7 +846,9 @@ describe('first-display initial slice definitions', () => { }, }); - PERMUTIVE_INITIAL_SLICE.prepare(host).activate( + activateInitialSlice( + PERMUTIVE_INITIAL_SLICE, + host, Object.freeze({ own: (release: () => void) => disposers.push(release), afterActivate: () => undefined, @@ -879,7 +924,9 @@ describe('first-display initial slice definitions', () => { }, }); - SOURCEPOINT_INITIAL_SLICE.prepare(host).activate( + activateInitialSlice( + SOURCEPOINT_INITIAL_SLICE, + host, Object.freeze({ own: (release: () => void) => disposers.push(release), afterActivate: () => undefined, @@ -948,7 +995,9 @@ describe('first-display initial slice definitions', () => { }, }); - OSANO_INITIAL_SLICE.prepare(host).activate( + activateInitialSlice( + OSANO_INITIAL_SLICE, + host, Object.freeze({ own: (release: () => void) => disposers.push(release), afterActivate: () => undefined, @@ -1103,11 +1152,13 @@ describe('first-display initial slice definitions', () => { install?: InitialSliceInstaller ) => { expect(id).toBe('creative_initial'); - install?.(bindings, own, undefined); + install?.(bindings, own, bindings.config); }, }); - CREATIVE_INITIAL_SLICE.prepare(host).activate( + activateInitialSlice( + CREATIVE_INITIAL_SLICE, + host, Object.freeze({ own: (dispose: () => void) => disposers.push(dispose), afterActivate: () => undefined, @@ -1173,11 +1224,13 @@ describe('first-display initial slice definitions', () => { register, }), own, - undefined + config ), }); expect(() => - CREATIVE_INITIAL_SLICE.prepare(host).activate( + activateInitialSlice( + CREATIVE_INITIAL_SLICE, + host, Object.freeze({ own: () => undefined, afterActivate: () => undefined }) ) ).toThrow(); @@ -1208,48 +1261,51 @@ describe('first-display initial slice definitions', () => { }, }); - APS_INITIAL_SLICE.prepare(host).activate( + activateInitialSlice( + APS_INITIAL_SLICE, + host, Object.freeze({ own: (dispose: () => void) => disposers.push(dispose), afterActivate: () => undefined, }) ); - expect(protocol?.rendererUrl).toBe('https://publisher.example/integrations/aps/renderer/v2'); - expect(protocol?.publisherOrigin).toBe('https://publisher.example'); - expect(protocol?.sandbox).toBe( + const policy = protocol?.[2]; + expect(policy?.rendererUrl).toBe('https://publisher.example/integrations/aps/renderer/v2'); + expect(policy?.publisherOrigin).toBe('https://publisher.example'); + expect(policy?.sandbox).toBe( 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation' ); - expect(protocol?.permanentSandbox).toBe( + expect(policy?.permanentSandbox).toBe( 'allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation' ); - expect(protocol?.deadlines).toEqual({ + expect(policy?.deadlines).toEqual({ documentAcceptanceMs: 3_000, completionMs: 10_000, }); - expect(protocol?.isBootstrapNonce(`b1_${'a'.repeat(22)}`)).toBe(true); - expect(protocol?.isRendererNonce(`n1_${'a'.repeat(22)}`)).toBe(true); - expect(protocol?.isBootstrapNonce(`n1_${'a'.repeat(22)}`)).toBe(false); + expect(policy?.isBootstrapNonce(`b1_${'a'.repeat(22)}`)).toBe(true); + expect(policy?.isRendererNonce(`n1_${'a'.repeat(22)}`)).toBe(true); + expect(policy?.isBootstrapNonce(`n1_${'a'.repeat(22)}`)).toBe(false); const nonce = `n1_${'b'.repeat(22)}`; expect( - protocol?.parseDocumentMessage( + policy?.parseDocumentMessage( Object.freeze({ message: 'TS APS Document Accepted', version: 1, nonce }), nonce ) ).toEqual({ kind: 'document_accepted' }); expect( - protocol?.parseDocumentMessage( + policy?.parseDocumentMessage( Object.freeze({ message: 'TS APS Runner Loaded', version: 1, nonce }), nonce ) ).toEqual({ kind: 'runner_loaded' }); expect( - protocol?.parseDocumentMessage( + policy?.parseDocumentMessage( Object.freeze({ message: 'TS APS Render Completed', version: 1, nonce }), nonce ) ).toEqual({ kind: 'render_completed' }); expect( - protocol?.parseDocumentMessage( + policy?.parseDocumentMessage( Object.freeze({ message: 'TS APS Render Failed', version: 1, @@ -1265,19 +1321,19 @@ describe('first-display initial slice definitions', () => { { message: 'TS APS Render Failed', version: 1, nonce, reason: 'unknown' }, Object.create({ message: 'TS APS Render Completed', version: 1, nonce }), ]) { - expect(protocol?.parseDocumentMessage(Object.freeze(message), nonce)).toBeUndefined(); + expect(policy?.parseDocumentMessage(Object.freeze(message), nonce)).toBeUndefined(); } disposers.reverse().forEach((dispose) => dispose()); expect(release).toHaveBeenCalledOnce(); }); - it('registers the GPT request-cycle and targeting policy before any initial action', () => { + it('registers only the attenuated GPT batch factory before any initial action', () => { const release = vi.fn(); const disposers: Array<() => void> = []; let protocol: FirstDisplayGptProtocolV1 | undefined; const bindings = Object.freeze({ - gam: vi.fn(() => true), + browser: {} as Window, observe: vi.fn(), register: (candidate: FirstDisplayGptProtocolV1) => { protocol = candidate; @@ -1299,39 +1355,16 @@ describe('first-display initial slice definitions', () => { }, }); - GPT_INITIAL_SLICE.prepare(host).activate( + activateInitialSlice( + GPT_INITIAL_SLICE, + host, Object.freeze({ own: (dispose: () => void) => disposers.push(dispose), afterActivate: () => undefined, }) ); - expect(protocol?.deadlines).toEqual({ - externalReadyMs: 10_000, - requestStartMs: 3_000, - completionMs: 10_000, - }); - expect(protocol?.createBatch).toBeTypeOf('function'); - expect( - protocol?.requestPlan( - Object.freeze({ initialLoadDisabled: false, ownership: 'trusted_server' }) - ) - ).toEqual({ operations: ['display'], requestOperation: 0 }); - expect( - protocol?.requestPlan( - Object.freeze({ initialLoadDisabled: true, ownership: 'trusted_server' }) - ) - ).toEqual({ operations: ['display', 'refresh'], requestOperation: 1 }); - expect( - protocol?.requestPlan(Object.freeze({ initialLoadDisabled: false, ownership: 'publisher' })) - ).toEqual({ operations: ['refresh'], requestOperation: 0 }); - expect(protocol?.validTargetingValue('x'.repeat(40))).toBe(true); - expect(protocol?.validTargetingValue('😀'.repeat(40))).toBe(true); - expect(protocol?.validTargetingValue('x'.repeat(41))).toBe(false); - expect(protocol?.validTargetingValue('😀'.repeat(41))).toBe(false); - expect(protocol?.validTargetingValue('bad\nvalue')).toBe(false); - expect(protocol?.classifyRenderEnded(Object.freeze({ isEmpty: true }))).toBe('gam_empty'); - expect(protocol?.classifyRenderEnded(Object.freeze({ isEmpty: false }))).toBe('nonempty_gam'); - expect(protocol?.classifyRenderEnded(Object.freeze({ isEmpty: 'false' }))).toBeUndefined(); + expect(protocol).toEqual([1, 'gpt', expect.any(Function)]); + expect(Object.isFrozen(protocol)).toBe(true); disposers.reverse().forEach((dispose) => dispose()); expect(release).toHaveBeenCalledOnce(); @@ -1359,22 +1392,25 @@ describe('first-display initial slice definitions', () => { }, }); - PREBID_INITIAL_SLICE.prepare(host).activate( + activateInitialSlice( + PREBID_INITIAL_SLICE, + host, Object.freeze({ own: (dispose: () => void) => disposers.push(dispose), afterActivate: () => undefined, }) ); - expect(protocol).toMatchObject({ + const policy = protocol?.[2]; + expect(policy).toMatchObject({ bidderCode: 'trustedServer', maxPendingOperations: 64, externalReadyMs: 10_000, admissionLeaseMs: 10_000, renderReservationMs: 15 * 60 * 1_000, }); - expect(protocol?.normalizeEidSource(' ID5-SYNC.COM ')).toBe('id5-sync.com'); - expect(protocol?.normalizeEidSource(' ')).toBeUndefined(); - const prepared = protocol?.snapshotTrustedBid( + expect(policy?.normalizeEidSource(' ID5-SYNC.COM ')).toBe('id5-sync.com'); + expect(policy?.normalizeEidSource(' ')).toBeUndefined(); + const prepared = policy?.snapshotTrustedBid( Object.freeze({ auctionId: 'auction-1', adUnitCode: 'slot-1', @@ -1402,7 +1438,7 @@ describe('first-display initial slice definitions', () => { expect(Object.isFrozen(prepared)).toBe(true); expect(prepared?.bid.adId).toBe(`r1_${'a'.repeat(22)}`); expect( - protocol?.snapshotTrustedBid( + policy?.snapshotTrustedBid( Object.freeze({ ...prepared, bid: Object.freeze({ ...prepared?.bid, bidderCode: 'publisherBidder' }), diff --git a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts index ea4c5cbcb..cdf4e43cc 100644 --- a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts @@ -15,6 +15,8 @@ const RESERVATION_ID = `r1_${'a'.repeat(22)}`; function handoff(revision = 0): Record { return { + captureVersion: 1, + identityCount: 2, version: 1, releaseId: RELEASE_ID, generation: 1, @@ -117,6 +119,16 @@ function handoff(revision = 0): Record { }; } +function validateCapturedHandoff(candidate: unknown, outlineValue: unknown) { + if (typeof candidate !== 'object' || candidate === null) return undefined; + const { + captureVersion: _captureVersion, + identityCount: _identityCount, + ...full + } = candidate as Record; + return snapshotOutlinedFirstDisplayHandoffV1(full, outlineValue); +} + function outline(): Record { return { version: 1, @@ -160,7 +172,7 @@ describe('atomic first-display takeover', () => { const events: string[] = []; let adoption: unknown; const prepared = Object.freeze({ - validateHandoff: snapshotOutlinedFirstDisplayHandoffV1, + validateHandoff: validateCapturedHandoff, activate: (candidate?: unknown) => { adoption = candidate; events.push('activate'); @@ -198,7 +210,7 @@ describe('atomic first-display takeover', () => { const physicalSlot = {}; const artifact = {}; const result = performFirstDisplayTakeoverV1({ - validateHandoff: snapshotOutlinedFirstDisplayHandoffV1, + validateHandoff: validateCapturedHandoff, finalized: finalized(physicalSlot, artifact), outline: outline(), isCurrentGeneration: () => true, @@ -229,7 +241,7 @@ describe('atomic first-display takeover', () => { it('rolls back partial persistent effects without resurrecting the agent', () => { const events: string[] = []; const result = performFirstDisplayTakeoverV1({ - validateHandoff: snapshotOutlinedFirstDisplayHandoffV1, + validateHandoff: validateCapturedHandoff, finalized: finalized(), outline: outline(), isCurrentGeneration: () => true, @@ -263,7 +275,7 @@ describe('atomic first-display takeover', () => { let revision = 0; const events: string[] = []; const result = performFirstDisplayTakeoverV1({ - validateHandoff: snapshotOutlinedFirstDisplayHandoffV1, + validateHandoff: validateCapturedHandoff, finalized: finalized(), outline: outline(), isCurrentGeneration: () => true, @@ -293,7 +305,7 @@ describe('atomic first-display takeover', () => { const events: string[] = []; expect( performFirstDisplayTakeoverV1({ - validateHandoff: snapshotOutlinedFirstDisplayHandoffV1, + validateHandoff: validateCapturedHandoff, finalized: finalized(), outline: outline(), isCurrentGeneration: () => true, @@ -321,7 +333,7 @@ describe('atomic first-display takeover', () => { if (failure === 'outline') candidate.projectionDigest = 'c'.repeat(64); expect( performFirstDisplayTakeoverV1({ - validateHandoff: snapshotOutlinedFirstDisplayHandoffV1, + validateHandoff: validateCapturedHandoff, finalized: finalized(), outline: candidate, isCurrentGeneration: () => failure !== 'generation', @@ -357,7 +369,7 @@ describe('atomic first-display takeover', () => { const events: string[] = []; expect( performFirstDisplayTakeoverV1({ - validateHandoff: snapshotOutlinedFirstDisplayHandoffV1, + validateHandoff: validateCapturedHandoff, finalized: sealed!, outline: outline(), isCurrentGeneration: () => true, @@ -379,7 +391,7 @@ describe('atomic first-display takeover', () => { const events: string[] = []; expect( performFirstDisplayTakeoverV1({ - validateHandoff: snapshotOutlinedFirstDisplayHandoffV1, + validateHandoff: validateCapturedHandoff, finalized: finalized(), outline: { ...outline(), objectKinds }, isCurrentGeneration: () => 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 28390d0d8..84209ac41 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 @@ -36,6 +36,7 @@ import { import { createTargetingService } from '../../../src/services/targeting'; import { createReservationService } from '../../../src/services/reservations'; import type { SlotRequestOutcome } from '../../../src/services/slots'; +import { acceptedGptFirstDisplayTakeover } from '../../first_display/helpers/compact_takeover'; const RELEASE_ID = 'a'.repeat(64); const RESERVATION_ID = `r1_${'a'.repeat(22)}`; @@ -1067,122 +1068,133 @@ describe('transactional GPT integration module', () => { ? (['first_display'] as const) : (['first_display', 'gpt_initial'] as const) ); + const candidate = acceptedGptFirstDisplayTakeover( + RELEASE_ID, + RESERVATION_ID, + parserStateValid === true + ? 'valid' + : parserStateValid === false + ? 'invalid' + : 'absent', + gamAttributionEnabled + ); const handoff = prepared.validateHandoff( - Object.freeze({ - version: 1 as const, - releaseId: RELEASE_ID, - generation: 1, - projectionDigest: 'b'.repeat(64), - integrationConfigDigest: 'c'.repeat(64), - slices, - slots: Object.freeze([ - Object.freeze({ - id: 'takeover-slot', - aliases: Object.freeze([]), - owner: 'publisher', - domId: 'takeover-slot', - gamPath: '/123/takeover-slot', - formats: Object.freeze([Object.freeze([300, 250])]), - outcome: 'accepted' as const, - targeting: Object.freeze([ - Object.freeze(['hb_adid', RESERVATION_ID] as const), - ]), - committedArtifact: 'gpt_adm' as const, - targetingOwnership: Object.freeze([]), - gptToken: 'gt1_1', - }), - ]), - attempts: Object.freeze([ - Object.freeze({ - id: 'a1_AAECAwQFBgcAAAAAAAAAAQ', - slotId: 'takeover-slot', - ordinal: 1, - state: 'accepted' as const, - reason: null, + candidate.capture || + Object.freeze({ + version: 1 as const, + releaseId: RELEASE_ID, + generation: 1, + projectionDigest: 'b'.repeat(64), + integrationConfigDigest: 'c'.repeat(64), + slices, + slots: Object.freeze([ + Object.freeze({ + id: 'takeover-slot', + aliases: Object.freeze([]), + owner: 'publisher', + domId: 'takeover-slot', + gamPath: '/123/takeover-slot', + formats: Object.freeze([Object.freeze([300, 250])]), + outcome: 'accepted' as const, + targeting: Object.freeze([ + Object.freeze(['hb_adid', RESERVATION_ID] as const), + ]), + committedArtifact: 'gpt_adm' as const, + targetingOwnership: Object.freeze([]), + gptToken: 'gt1_1', + }), + ]), + attempts: Object.freeze([ + Object.freeze({ + id: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + slotId: 'takeover-slot', + ordinal: 1, + state: 'accepted' as const, + reason: null, + }), + ]), + tombstones: Object.freeze([]), + artifacts: Object.freeze([ + Object.freeze({ + hostPosition: null, + hostPositionPriority: null, + slotId: 'takeover-slot', + kind: 'gpt_adm' as const, + owner: 'trusted_server' as const, + token: RESERVATION_ID, + }), + ]), + parserState: + parserStateValid === true + ? Object.freeze([ + Object.freeze({ + sliceId: 'gpt_initial', + observations: Object.freeze(['gam', 'v']), + values: Object.freeze([ + Object.freeze(['gam', gamAttributionEnabled] as const), + Object.freeze(['v', 1] as const), + ]), + }), + ]) + : Object.freeze([]), + gptDiagnostics: Object.freeze({ + facts: Object.freeze([]), + overflowCount: 0, + dropCount: 0, }), - ]), - tombstones: Object.freeze([]), - artifacts: Object.freeze([ - Object.freeze({ - hostPosition: null, - hostPositionPriority: null, - slotId: 'takeover-slot', - kind: 'gpt_adm' as const, - owner: 'trusted_server' as const, - token: RESERVATION_ID, + timing: Object.freeze({ + bidsScriptMs: 1, + firstDisplayMs: 2, + terminalMs: 3, + paintMs: 4, }), - ]), - parserState: - parserStateValid === true - ? Object.freeze([ - Object.freeze({ - sliceId: 'gpt_initial', - observations: Object.freeze(['gam', 'v']), - values: Object.freeze([ - Object.freeze(['gam', gamAttributionEnabled] as const), - Object.freeze(['v', 1] as const), - ]), - }), - ]) - : Object.freeze([]), - gptDiagnostics: Object.freeze({ - facts: Object.freeze([]), - overflowCount: 0, - dropCount: 0, - }), - timing: Object.freeze({ - bidsScriptMs: 1, - firstDisplayMs: 2, - terminalMs: 3, - paintMs: 4, - }), - highWater: Object.freeze({ - navigationAttemptPrefix: 'AAECAwQFBgc', - nextAttemptOrdinal: 2, - nextNavigationAttemptOrdinal: 2, - nextSlotRegistrationOrdinal: 2, - nextReservationOrdinal: 2, - nextTicketOrdinal: 1, - reservationClockEpochMs: 0, - }), - cycles: Object.freeze([ - Object.freeze({ - nextCycleOrdinal: 2, - quarantines: Object.freeze([]), - records: Object.freeze([ - Object.freeze({ - ordinal: 1, - responseIdentifier: 'response-one', - seen: Object.freeze(['slotRequested', 'slotRenderEnded'] as const), - state: 'completed' as const, - }), - ]), - slotId: 'takeover-slot', - token: 'gt1_1', - unknownPriorCycle: false, + highWater: Object.freeze({ + navigationAttemptPrefix: 'AAECAwQFBgc', + nextAttemptOrdinal: 2, + nextNavigationAttemptOrdinal: 2, + nextSlotRegistrationOrdinal: 2, + nextReservationOrdinal: 2, + nextTicketOrdinal: 1, + reservationClockEpochMs: 0, }), - ]), - trace: Object.freeze({ - nextGlobalSlotOrdinal: 2, - nextSequence: 2, - slots: Object.freeze([ + cycles: Object.freeze([ Object.freeze({ - bindings: Object.freeze([ + nextCycleOrdinal: 2, + quarantines: Object.freeze([]), + records: Object.freeze([ Object.freeze({ - atMs: 2, - cycleOrdinal: 1, - historySequence: 1, + ordinal: 1, + responseIdentifier: 'response-one', + seen: Object.freeze(['slotRequested', 'slotRenderEnded'] as const), state: 'completed' as const, - token: 'gt1_1', }), ]), - impressions: 1, slotId: 'takeover-slot', + token: 'gt1_1', + unknownPriorCycle: false, }), ]), + trace: Object.freeze({ + nextGlobalSlotOrdinal: 2, + nextSequence: 2, + slots: Object.freeze([ + Object.freeze({ + bindings: Object.freeze([ + Object.freeze({ + atMs: 2, + cycleOrdinal: 1, + historySequence: 1, + state: 'completed' as const, + token: 'gt1_1', + }), + ]), + impressions: 1, + slotId: 'takeover-slot', + }), + ]), + }), + mutationRevision: 0, }), - mutationRevision: 0, - }), Object.freeze({ version: 1 as const, releaseId: RELEASE_ID, @@ -1194,7 +1206,8 @@ describe('transactional GPT integration module', () => { outcomeCount: 1, capabilities: Object.freeze([]), objectKinds: Object.freeze(['gpt_slot', 'dom_artifact']), - }) + }), + candidate.boot ); if (!handoff) throw new Error('should validate test handoff'); prepared.activate( diff --git a/crates/trusted-server-js/lib/test/kernel/identity.test.ts b/crates/trusted-server-js/lib/test/kernel/identity.test.ts index f7bfb2619..82876fb83 100644 --- a/crates/trusted-server-js/lib/test/kernel/identity.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/identity.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createBrowserNavigationIdentityIssuer, + createFirstDisplayNavigationIdentityIssuerFromSource, createTestNavigationIdentityIssuer, mintTestBootstrapNonce, mintTestLifecycleTicket, @@ -56,6 +57,26 @@ describe('navigation identity issuer', () => { expect(created.value.snapshotOrdinalForTest()).toEqual([0, 2]); }); + it('uses the compact initial-display issuer for the same bounded wire identities', () => { + const { source, calls } = deterministicSource([0, 1, 2, 3, 4, 5, 6, 7]); + const created = createFirstDisplayNavigationIdentityIssuerFromSource(source); + + expect(created).toMatchObject({ ok: true }); + if (!created.ok) throw new Error('Expected an initial-display identity issuer'); + + expect(created.value.mintAttemptId()).toEqual({ + ok: true, + value: 'a1_AAECAwQFBgcAAAAAAAAAAQ', + }); + expect(created.value.mintAttemptId()).toEqual({ + ok: true, + value: 'a1_AAECAwQFBgcAAAAAAAAAAg', + }); + expect(created.value.snapshotPrefix()).toBe('AAECAwQFBgc'); + expect(calls).toHaveBeenCalledOnce(); + expect(calls.mock.calls[0]?.[0]).toHaveLength(8); + }); + it('adopts the next first-display ordinal once before minting', () => { const { source } = deterministicSource([0, 1, 2, 3, 4, 5, 6, 7]); const created = createTestNavigationIdentityIssuer({ getRandomValues: source }); diff --git a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts index 9edf54779..ddcbba5e6 100644 --- a/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts @@ -10,6 +10,7 @@ import { type TakeoverIntegrationRegistration, } from '../../src/kernel/integration_registry'; import { snapshotPersistentFirstDisplayAdoptionV1 } from '../../src/shared/takeover'; +import { failedFirstDisplayTakeover } from '../first_display/helpers/compact_takeover'; const RELEASE_ID = 'a'.repeat(64); const OTHER_RELEASE_ID = 'b'.repeat(64); @@ -129,8 +130,9 @@ describe('integration manifest and registration admission', () => { coordinateTakeover: (prepared) => { expect(Object.isFrozen(prepared)).toBe(true); expect(order).toEqual(['core:prepare', 'module:prepare']); + const candidate = failedFirstDisplayTakeover(RELEASE_ID); const handoff = prepared.validateHandoff( - { + candidate.capture || { version: 1, releaseId: RELEASE_ID, generation: 1, @@ -199,7 +201,8 @@ describe('integration manifest and registration admission', () => { outcomeCount: 1, capabilities: [], objectKinds: [], - } + }, + candidate.boot ); expect(handoff).toBeDefined(); adoption = Object.freeze({ 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 index 8177f77dc..0192b0eae 100644 --- a/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/lifecycle_module.test.ts @@ -182,23 +182,76 @@ describe('shared integration lifecycle module', () => { }, identities: Object.freeze([]), }); + const slices = selected ? ['first_display', 'datadome_initial'] : ['first_display']; + const compactCapture = { + captureVersion: 1, + releaseId: RELEASE_ID, + generation: 1, + data: [ + 'b'.repeat(64), + 'c'.repeat(64), + slices, + [['failed', 'internal_error', null, null]], + [], + [], + [], + state ? [['datadome_initial', [['route_guard', 'datadome']]]] : [], + [[], 0, 0], + [1, null, 2, 3], + [2, 0, 1, 1], + [], + 1, + 1, + ], + mutationRevision: 0, + identityCount: 0, + }; + const boot = { + abi: 1, + releaseId: RELEASE_ID, + manifest: {}, + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: 'initial', + results: [{ slot: 'slot-1', outcome: 'failed', reason: 'internal_error' }], + }, + slots: [ + { + slot: 'slot-1', + gamUnitPath: '/123/slot-1', + divId: 'slot-1', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [], + }, + integrations: {}, + creative: {}, + diagnostics: {}, + }; const result = await owner.install({ ...callbacks([]), coordinateTakeover: (prepared) => { - const slices = selected ? ['first_display', 'datadome_initial'] : ['first_display']; - const handoff = prepared.validateHandoff(adoption.handoff, { - version: 1, - releaseId: RELEASE_ID, - generation: 1, - projectionDigest: 'b'.repeat(64), - integrationConfigDigest: 'c'.repeat(64), - slices, - slotCount: 1, - outcomeCount: 1, - capabilities: [], - objectKinds: [], - }); + const handoff = prepared.validateHandoff( + compactCapture, + { + version: 1, + releaseId: RELEASE_ID, + generation: 1, + projectionDigest: 'b'.repeat(64), + integrationConfigDigest: 'c'.repeat(64), + slices, + slotCount: 1, + outcomeCount: 1, + capabilities: [], + objectKinds: [], + }, + boot + ); if (!handoff) throw new Error('should validate lifecycle handoff'); prepared.activate(Object.freeze({ ...adoption, handoff })); prepared.commit(); 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 b125a1730..37704329b 100644 --- a/crates/trusted-server-js/lib/test/kernel/runtime.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/runtime.test.ts @@ -14,6 +14,7 @@ import type { PreparedIntegration, } from '../../src/kernel/integration_registry'; import { createLifecycleIntegrationRegistration } from '../../src/kernel/lifecycle_module'; +import { failedFirstDisplayTakeover } from '../first_display/helpers/compact_takeover'; const RELEASE = 'a'.repeat(64); const TRUSTED_RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; @@ -176,7 +177,10 @@ function boot(results: readonly object[] = []) { function takeoverHandoff() { const projectionDigest = 'b'.repeat(64); + const compact = failedFirstDisplayTakeover(RELEASE); return { + capture: compact.capture, + boot: compact.boot, handoff: { version: 1, releaseId: RELEASE, @@ -1297,7 +1301,11 @@ describe('Runtime bootstrap owner', () => { coordinateTakeover: (prepared) => { order.push('takeover:begin'); const candidate = takeoverHandoff(); - const handoff = prepared.validateHandoff(candidate.handoff, candidate.outline); + const handoff = prepared.validateHandoff( + candidate.capture, + candidate.outline, + candidate.boot + ); expect(handoff).toBeDefined(); prepared.activate( Object.freeze({ @@ -1338,7 +1346,11 @@ describe('Runtime bootstrap owner', () => { const onInstallComplete = vi.fn(); const coordinateTakeover = vi.fn((prepared) => { const candidate = takeoverHandoff(); - const handoff = prepared.validateHandoff(candidate.handoff, candidate.outline); + const handoff = prepared.validateHandoff( + candidate.capture, + candidate.outline, + candidate.boot + ); expect(handoff).toBeDefined(); prepared.activate( 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 d13c544ac..03669b80b 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 @@ -1074,7 +1074,6 @@ describe('Universal Creative bridge dispatcher', () => { version: 1, lifecycleTicket: LIFECYCLE_TICKET, outcome: 'failed', - reason: 'adm_document_no_load', }, ports: [], }); diff --git a/crates/trusted-server-js/src/bundle.rs b/crates/trusted-server-js/src/bundle.rs index bcd3d240f..0a742be0d 100644 --- a/crates/trusted-server-js/src/bundle.rs +++ b/crates/trusted-server-js/src/bundle.rs @@ -159,9 +159,10 @@ pub fn first_display_component_bundle(id: &str) -> Option<&'static str> { .map(|artifact| artifact.bundle) } -/// Compose the base plus selected optional first-display slices in canonical order. +/// Compose selected optional first-display slices in canonical order. /// -/// Unknown, duplicate, or explicitly supplied base IDs fail closed. +/// The logical base is physically owned by the inline bootstrap. Unknown, +/// duplicate, or explicitly supplied base IDs fail closed. #[must_use] pub fn concatenate_first_display_slices(ids: &[&str]) -> Option { let selected = ids.iter().copied().collect::>(); @@ -177,10 +178,7 @@ pub fn concatenate_first_display_slices(ids: &[&str]) -> Option { } let parts = TSJS_ARTIFACTS .iter() - .filter(|artifact| { - artifact.role == "first_display_base" - || (artifact.role == "first_display_slice" && selected.contains(artifact.id)) - }) + .filter(|artifact| artifact.role == "first_display_slice" && selected.contains(artifact.id)) .map(|artifact| artifact.bundle) .collect::>(); Some(parts.join(";\n")) @@ -420,11 +418,12 @@ mod tests { let gpt = first_display_component_bundle("gpt_initial") .expect("should embed GPT first-display slice"); - assert!(body.starts_with(base)); - assert!( - body.find(aps).expect("should contain APS") - < body.find(gpt).expect("should contain GPT") + assert_eq!( + body, + [aps, gpt].join(";\n"), + "should exclude the logical base marker and compose selected slices canonically" ); + assert!(!body.contains(base)); assert!(concatenate_first_display_slices(&["aps_initial", "aps_initial"]).is_none()); assert!(concatenate_first_display_slices(&["first_display"]).is_none()); assert!(concatenate_first_display_slices(&["unknown"]).is_none()); 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 00a32f78c..96ae57867 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 @@ -1206,16 +1206,14 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled The generated fallback tests require the exact safe manifest, empty `integrations:{version:1,entries:[]}`, empty fallback auction projection, - disabled creative/diagnostics defaults, omitted `requestAds()` → `{slots:[]}`, - explicit valid id → `slot_unresolved`, already-aborted explicit id → - `caller_aborted`, same frozen reason on `addAdUnits` refusal/internal state, + disabled creative/diagnostics defaults, every `requestAds` and `addAdUnits` + call refusing with the same classified unavailable reason without reading its + input, the same frozen reason in internal state, both `initialDisplayCommitted` values, exact FIFO queue drain, accepted-DOM preservation, no pending call, no artifact retry, and both `abi_mismatch`/`bundle_partial` classifications. Fallback never needs the full - projection validator, but `addAdUnits` still validates before refusing. The - registration validator obtains its bounded-string primitive from an - effect-free bootstrap-safe leaf rather than importing the auction-projection - graph. The persistent-core graph must consume a generated numeric manifest + projection or public request validator. Full request validation belongs only + to a committed kernel. The persistent-core graph must consume a generated numeric manifest capacity and exclude the build-time release catalog. - [ ] **Step 3: Run RED and inspect the expected failures.** @@ -1455,6 +1453,63 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled git commit -m "Thin the APS first-display owner" ``` +### Task 15C: Consolidate bootstrap and first-display base ownership + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/core/bootstrap.ts` +- Modify: `crates/trusted-server-js/lib/src/first_display/agent.ts` +- Modify: `crates/trusted-server-js/lib/src/first_display/registration_client.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/release_catalog.ts` +- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/build.rs` +- Modify: `crates/trusted-server-js/src/bundle.rs` +- Modify: `crates/trusted-server-core/src/tsjs.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-js/lib/scripts/bundle-metrics.mjs` +- Modify: `crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs` +- Test: `crates/trusted-server-js/lib/test/core/bootstrap.test.ts` +- Test: `crates/trusted-server-js/lib/test/first_display/agent.test.ts` +- Test: `crates/trusted-server-js/lib/test/build/{generated-fallback,release-v1}.test.mjs` +- Test: colocated `crates/trusted-server-core/src/{tsjs,publisher}.rs` tests +- Test: `crates/trusted-server-integration-tests/browser/tests/shared/{tsjs-performance,tsjs-runtime}.spec.ts` + +- [ ] **Step 1: Add RED physical-ownership and route tests.** Keep the 14-bit + logical first-display catalog and exact mask order, but require + `first_display` to have physical owner `bootstrap`, no separately served + implementation, and no independently routable static asset. Assert that an + `008b` response is exactly render owner + creative + GPT initial bytes in + catalog order, with the base implementation present only in the inline + bootstrap graph. Reject a release where both bootstrap and the marker reach + base coordinator source, or where bootstrap reaches APS/GPT/creative/PUC or + another optional slice. + +- [ ] **Step 2: Add RED one-owner lifecycle tests.** Drive the generated bootstrap + followed by a selected slice response and prove one generation owns component + registration, action start, terminal/paint, queue ingress, fallback, and + takeover. Cover missing/partial selected bytes, duplicate or out-of-order slice + registration, reentrant registration/disposal, accepted DOM preservation, and + exact-once handoff. Assert there is no second base registration, controller, + timer, observer, or disposer. + +- [ ] **Step 3: Implement the minimal consolidation.** Move the source-neutral base + coordinator into the bootstrap physical graph and delete the separate base + registration path. Retain `first_display` as a release-stamped non-routable + logical marker so mask/capability ordering remains stable. Build and serve one + selected response containing optional slices only. Share one closure-private + generation and disposer stack; do not bridge two controllers or move code into + transport data. + +- [ ] **Step 4: Run focused GREEN and measure.** Run bootstrap, first-display, + release, static-route, typecheck, architecture, and bundle tests. Then run the + exact rc semantic comparator. If any raw/gzip/Brotli value still exceeds rc, + inspect the consolidated source graph and remove only duplicated owner state; + do not change the fixture, mask membership, baseline, or threshold. + +- [ ] **Step 5: Run full verification and commit.** Run the Task 15B three-browser + lifecycle proof, complete JS/Rust quality scripts, exact performance sample, + and clean-worktree checks before committing and pushing one replacement SHA. + ### Task 16: Enforce hard-cutover absence and supply-chain boundaries **Files:** 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 91c6ed76e..77e1527c6 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 @@ -2815,15 +2815,15 @@ freezes `initialDisplayCommitted` to whether the completed protected batch conta at least one `accepted` result, then drains those same callbacks. Wrong release/source/manifest/ABI identity is `abi_mismatch`; transport/load/deadline, preparation/activation failure, or a live TS entry at the seal is `bundle_partial`. -Every `requestAds` made by a drained or later callback is a new post-paint call against -the empty safe fallback projection defined below: omitted selection resolves -`{slots:[]}`, while an explicit valid id resolves `slot_unresolved` (or -`caller_aborted` for an already-aborted signal). It does not re-report, remove, or -replay the completed initial display. Every such `addAdUnits` throws -`TsjsUnavailableError{code:'runtime_unavailable',reason:fallbackReason}` before -mutation, and `_internal.reason` is that same frozen value. No queued callback or API -call remains pending, and no post-paint transient failure retries the runtime artifact -in that document generation. +Every `requestAds` made by a drained or later callback rejects +`TsjsUnavailableError{code:'runtime_unavailable',reason:fallbackReason}` without +reading its argument. Every `addAdUnits` throws the same classified unavailable +error without reading or validating its argument. This hard-cutover shell does not +emulate kernel input validation or synthesize auction results when no kernel exists; +it does not re-report, remove, or replay the completed initial display. +`_internal.reason` is the same frozen classification. No queued callback or API call +remains pending, and no post-paint transient failure retries the runtime artifact in +that document generation. Every installed effect is a repository-owned primitive with a synchronous, nonthrowing, identity-checked disposer. Rollback attempts physical removal/restoration @@ -3053,10 +3053,9 @@ any mutable DOM or realm authentication surface and rolls back only after a fail authentication. Its completion capability admits outcomes through primitive string comparisons, consumes its one-use guard before invoking the selected handler, and therefore remains one-shot under reentrant claim and completion attempts. -The compact fallback's `addAdUnits` surface still runs the exact public registration -validator before it refuses a valid call. That validator imports its bounded-string -primitive from an effect-free bootstrap-safe leaf; it does not make bootstrap reach -the auction-projection parser. +The compact fallback does not import either public request validator. Both public +operations refuse as unavailable without reading publisher input; full validation +belongs only to a successfully committed kernel. The persistent core receives the manifest-entry capacity as a generated numeric build constant; it does not import the build-time release catalog merely to learn that bound. Substitution may tree-shake the declaration module completely, and the @@ -3563,8 +3562,8 @@ commit atomically records one immutable boot failure reason: seal, or the owning deadline. Before draining user work it installs `version:'1.0.0'`, the embedded `releaseId`, a -safe frozen `TsjsBootV1`, the final `tsjs.requestAds` input validator, the -validating-then-refusing `tsjs.addAdUnits`, the local `tsjs.log`, the +safe frozen `TsjsBootV1`, unavailable-only `tsjs.requestAds` and +`tsjs.addAdUnits`, the local `tsjs.log`, the immediate-executor `tsjs.que`, a permanently refusing internal `_registerIntegration`, and a frozen `tsjs._internal` value containing only @@ -3581,10 +3580,10 @@ always substitutes exactly and the creative/diagnostics disabled safe defaults from §§5.4/5.8. The controller does not retain or partially trust the server projection on a failure path and does not import §§3.1–3.2 merely to improve fallback reporting. Consequently -`requestAds()` resolves `{slots:[]}`; each explicit valid id resolves -`slot_unresolved`, or `cancelled{reason:'caller_aborted'}` when its signal is already -aborted. Input-shape errors still reject with `RequestAdsInputError`. This deliberate -terminal-shell behavior is not a compatibility promise or a render recovery path. +`requestAds()` and every explicit selection reject the classified +`TsjsUnavailableError` without inspecting slot ids or abort signals. This deliberate +terminal-shell behavior is not a compatibility promise, input-validation substitute, +or render recovery path. After installing those surfaces, fallback drains the preexisting callback queue FIFO exactly once with `this === tsjs`; one callback throw does not prevent later callbacks. @@ -5046,6 +5045,42 @@ stores executable source in boot data, weakens the top-page owner, removes mixed APS/ADM support, reopens a handoff/retirement race, or relies on unsafe property mangling across independently built protocol components. +The first complete revision-43 local build against exact rc base +`58532d7c42d2a59f8e87f0d643f8d0b109a680cb` reduced the GPT semantic interval from +105,391 to 97,198 raw bytes. Applying the hard-cutover unavailable-only fallback +contract reduced it again to 89,803 bytes, but the unchanged rc ceiling is 72,148. +The selected `008b` body is already slightly smaller than the complete legacy +external body; the remaining 17,655-byte regression is the separately delivered +controller/base ownership split. This evidence triggers the Task 15A/15B stop rule: +more local compaction, a threshold change, or a different fixture is not an accepted +remedy. + +Revision 44 therefore makes `first_display` a logical catalog capability physically +owned by the bootstrap artifact. The bootstrap and first-display base are one +controller and one state machine: queue ingress, sealed transport, component +registration, projected action/terminal/paint state, fallback, and takeover cannot +exist in separate artifacts or duplicate their generation/registration/disposal +bookkeeping. The `first_display` catalog bit and mask position remain stable, but its +generated release row is a non-routable marker and contributes no implementation +bytes to the selected response. The one parser-blocking selected response contains +only the selected initial slices, in catalog order, and those slices register with +the already-installed controller. The server still emits exactly one inline sealed +transport/controller and exactly one selected first-display request; no executable +source moves into boot data and no auxiliary request, preload, cache assumption, or +vendored dependency is introduced. + +The consolidated controller retains the same one-use capsule, exact source +authentication, protected-paint boundary, fallback classifications, accepted-DOM +preservation, post-detach reentrancy defenses, and bootstrap-only terminal +publication. Its physical graph may reach the source-neutral base coordinator but +must not reach APS, GPT, creative, PUC, or any optional-slice implementation. Every +optional slice remains independently owned and is included only by its mask bit. +Release/source-ownership tests prove the logical marker has exactly one physical +owner, cannot be routed independently, and is absent from every served first-display +body. The exact semantic rc comparison remains the acceptance test; the consolidation +is incomplete until raw, gzip, and Brotli all pass without changing membership, +fixture, baseline, or ceiling. + The checked-in pre-change fixture is immutable historical evidence and is never regenerated or rewritten. Its original `bundles` values measured a different artifact model: minimal contained only the old core, reference omitted the now- @@ -5608,9 +5643,8 @@ Tests must cover at least: 9,999/10,000/10,001 ms post-paint deadline prove callback pushes remain queued until one commit; success drains against the full kernel, while failure freezes the exact true/false `initialDisplayCommitted`, drains once against fallback, makes - every new omitted-selection `requestAds` resolve the exact empty result, every - explicit valid id resolve `slot_unresolved` or already-aborted `caller_aborted`, and - every `addAdUnits` propagate the same classified + every new `requestAds` and `addAdUnits` call refuse without reading its input and + propagate the same classified `abi_mismatch`/`bundle_partial` fallback reason, preserves accepted DOM, and leaves no pending work or artifact retry; - final handoff boundaries for attempt/slot/GPT-token/cycle/trace ordinals at @@ -5677,8 +5711,8 @@ Tests must cover at least: immediate post-load return values, `this`, non-callables, and callback throws; - main bundle absence after server GPT projection, proving the old degraded bootstrap renderer is deliberately gone: no GPT definition/targeting/display/refresh occurs, - fallback retains no server-projected slot membership, omitted-selection - `requestAds` resolves `{slots:[]}`, explicit valid ids resolve `slot_unresolved`, the + fallback retains no server-projected slot membership, every `requestAds` refuses + with the classified unavailable error, the queue drains once, and a late bundle cannot revive rendering; - explicit `requestAds` selection with exact server-projected and programmatic slot ids, an unknown id beside a valid sibling, GPT-path and DOM-alias collisions, and From 44c7082e42c9e04851af228795600a6c38c0a028 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:37:11 -0700 Subject: [PATCH 843/844] Complete APS render and TSJS hard cutover --- .github/workflows/integration-tests.yml | 2 +- Cargo.lock | 2 + crates/trusted-server-adapter-axum/src/app.rs | 16 +- .../src/platform.rs | 9 +- .../tests/routes.rs | 32 +- .../src/app.rs | 21 +- .../tests/routes.rs | 11 +- .../trusted-server-adapter-fastly/src/app.rs | 64 +- .../src/platform.rs | 13 +- crates/trusted-server-adapter-spin/src/app.rs | 68 +- .../src/platform.rs | 36 +- .../tests/routes.rs | 11 +- .../benches/html_processor_bench.rs | 1 + .../src/auction/endpoints.rs | 34 +- .../src/auction/formats.rs | 259 +- crates/trusted-server-core/src/auction/mod.rs | 2 +- .../src/auction/openrtb.rs | 3 + .../src/auction/orchestrator.rs | 3275 +++++++++++------ .../src/auction/provider.rs | 7 + .../src/auction/telemetry.rs | 12 + .../trusted-server-core/src/auction/types.rs | 26 + crates/trusted-server-core/src/config.rs | 104 +- crates/trusted-server-core/src/constants.rs | 1 + .../trusted-server-core/src/html_processor.rs | 128 +- .../src/integrations/adserver_mock.rs | 24 +- .../src/integrations/aps.rs | 535 +-- .../src/integrations/datadome.rs | 247 +- .../src/integrations/datadome/protection.rs | 891 ++++- .../src/integrations/gpt.rs | 59 +- .../src/integrations/gpt_diagnostics.rs | 44 +- .../src/integrations/mod.rs | 1 + .../src/integrations/prebid.rs | 291 +- .../src/integrations/registry.rs | 272 +- crates/trusted-server-core/src/lib.rs | 1 + .../src/platform/test_support.rs | 21 + crates/trusted-server-core/src/proxy.rs | 25 +- crates/trusted-server-core/src/publisher.rs | 852 +++-- crates/trusted-server-core/src/settings.rs | 11 + crates/trusted-server-core/src/tsjs.rs | 94 +- .../Cargo.toml | 1 + .../browser/helpers/gpt-stub.ts | 1 + .../browser/helpers/tsjs-fixture.ts | 29 + .../browser/tests/shared/tsjs-policy.spec.ts | 418 +++ .../browser/tests/shared/tsjs-runtime.spec.ts | 90 +- .../configs/trusted-server.integration.toml | 1 - .../tests/aps_runner_proxy.rs | 24 +- crates/trusted-server-js/Cargo.toml | 2 + crates/trusted-server-js/lib/build-all.mjs | 8 +- .../lib/scripts/check-bundle-budgets.mjs | 36 +- .../scripts/check-hard-cutover-absence.mjs | 8 +- .../scripts/check-retired-concept-audit.mjs | 9 +- .../lib/src/adapters/googletag.ts | 54 + .../lib/src/composition/browser_test.ts | 2 +- .../lib/src/core/bootstrap.ts | 376 +- .../lib/src/core/first_impression.ts | 358 -- .../trusted-server-js/lib/src/core/index.ts | 172 +- .../trusted-server-js/lib/src/core/types.ts | 73 +- .../src/first_display/adapters/googletag.ts | 318 +- .../lib/src/first_display/agent.ts | 19 +- .../lib/src/first_display/driver.ts | 33 +- .../first_display/leaf/browser_route_owner.ts | 286 +- .../first_display/leaf/context_snapshot.ts | 34 +- .../src/first_display/leaf/creative_guard.ts | 86 +- .../src/first_display/registration_client.ts | 11 +- .../lib/src/first_display/slices/creative.ts | 20 +- .../lib/src/first_display/slices/datadome.ts | 11 +- .../slices/google_tag_manager.ts | 13 +- .../lib/src/first_display/slices/lockr.ts | 11 +- .../lib/src/first_display/slices/permutive.ts | 12 +- .../src/first_display/slices/sourcepoint.ts | 11 +- .../lib/src/integrations/aps/render.ts | 1 - .../lib/src/integrations/creative/click.ts | 40 +- .../lib/src/integrations/didomi/index.ts | 4 +- .../src/integrations/gpt/diagnostics_facts.ts | 193 +- .../lib/src/integrations/gpt/module.ts | 461 ++- .../src/integrations/gpt_diagnostics/api.ts | 66 +- .../integrations/gpt_diagnostics/badges.ts | 21 +- .../integrations/gpt_diagnostics/binding.ts | 17 +- .../integrations/gpt_diagnostics/data_api.ts | 16 + .../integrations/gpt_diagnostics/module.ts | 7 +- .../integrations/gpt_diagnostics/observer.ts | 106 +- .../integrations/gpt_diagnostics/overlay.ts | 16 +- .../gpt_diagnostics/presentation.ts | 6 + .../gpt_diagnostics/presentation_helpers.ts | 25 +- .../src/integrations/gpt_diagnostics/store.ts | 221 +- .../lib/src/integrations/permutive/module.ts | 79 +- .../lib/src/integrations/prebid/module.ts | 765 +--- .../src/integrations/render_runtime/module.ts | 39 + .../render_runtime/prebid_selection.ts | 726 ++++ .../lib/src/kernel/integration_registry.ts | 7 +- .../lib/src/kernel/phase_loader.ts | 34 +- .../lib/src/kernel/release_catalog.ts | 16 +- .../lib/src/kernel/runtime.ts | 11 +- .../lib/src/services/slots.ts | 167 +- .../lib/src/shared/first_display_contracts.ts | 25 + .../lib/src/shared/gpt_diagnostics.ts | 96 + .../lib/src/shared/takeover.ts | 34 +- .../lib/test/adapters/googletag.test.ts | 31 +- .../test/build/generated-fallback.test.mjs | 290 +- .../lib/test/build/release-v1.test.mjs | 106 +- .../lib/test/composition/browser.test.ts | 1 + .../contract/retired-concept-audit.test.mjs | 8 +- .../lib/test/core/index.test.ts | 97 +- .../lib/test/core/request.test.ts | 26 +- .../lib/test/first_display/agent.test.ts | 90 + .../first_display/browser_route_owner.test.ts | 168 +- .../lib/test/first_display/contracts.test.ts | 1 + .../lib/test/first_display/driver.test.ts | 33 + .../test/first_display/gpt_adapter.test.ts | 360 ++ .../lib/test/first_display/slices.test.ts | 92 +- .../lib/test/first_display/takeover.test.ts | 33 +- .../contracts/current-main-concept-audit.json | 52 +- .../lib/test/integrations/aps/render.test.ts | 14 - .../test/integrations/creative/click.test.ts | 96 +- .../gpt/diagnostics_facts.test.ts | 248 +- .../lib/test/integrations/gpt/module.test.ts | 84 +- .../integrations/gpt_diagnostics/api.test.ts | 35 + .../gpt_diagnostics/badges.test.ts | 6 +- .../gpt_diagnostics/bootstrap.test.ts | 75 - .../gpt_diagnostics/observer.test.ts | 29 +- .../gpt_diagnostics/overlay.test.ts | 17 +- .../gpt_diagnostics/presentation.test.ts | 5 + .../slot_size_observer.test.ts | 4 +- .../gpt_diagnostics/store.test.ts | 75 +- .../gpt_diagnostics/types.test.ts | 31 +- .../integrations/permutive/module.test.ts | 62 +- .../test/integrations/phase-slices.test.ts | 13 +- .../test/integrations/prebid/module.test.ts | 50 +- .../lib/test/kernel/phase_loader.test.ts | 64 +- .../test/prebid-artifact-integration.test.mjs | 2 +- .../lib/test/services/slots.test.ts | 140 +- docs/.gitignore | 1 + docs/guide/auction-orchestration.md | 25 +- docs/guide/integrations/aps.md | 70 +- docs/guide/integrations/gpt.md | 2 - ...8-04-aps-tsjs-resilience-implementation.md | 132 +- ...s-render-fix-and-tsjs-resilience-design.md | 149 +- scripts/ci/aps-tsjs-cutover.sh | 1 + trusted-server.example.toml | 15 +- 139 files changed, 11537 insertions(+), 4527 deletions(-) create mode 100644 crates/trusted-server-integration-tests/browser/tests/shared/tsjs-policy.spec.ts delete mode 100644 crates/trusted-server-js/lib/src/core/first_impression.ts create mode 100644 crates/trusted-server-js/lib/src/integrations/render_runtime/prebid_selection.ts create mode 100644 crates/trusted-server-js/lib/src/shared/gpt_diagnostics.ts delete mode 100644 crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 9de1f8952..7eb3cd870 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -373,7 +373,7 @@ jobs: INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} TS_BROWSER_FRAMEWORKS: nextjs TS_BROWSER_PROJECTS: chromium,firefox,webkit - run: ./scripts/integration-tests-browser.sh 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 + run: ./scripts/integration-tests-browser.sh tests/shared/aps-renderer.spec.ts tests/shared/aps-puc-lifecycle.spec.ts tests/shared/tsjs-runtime.spec.ts tests/shared/tsjs-policy.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 diff --git a/Cargo.lock b/Cargo.lock index c722c2c48..bb02b8022 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5555,6 +5555,8 @@ version = "0.1.0" dependencies = [ "build-print", "hex", + "serde", + "serde_json", "sha2 0.10.9", "which", ] diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index fb981283b..03ecf5704 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -36,6 +36,7 @@ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; +use trusted_server_core::trace_cookie::handle_trace_mode; use trusted_server_core::platform::RuntimeServices; @@ -81,8 +82,10 @@ fn build_state() -> Result, Report> { fn build_state_with_settings( settings: Settings, ) -> Result, Report> { - let orchestrator = build_orchestrator(&settings)?; - let registry = IntegrationRegistry::new(&settings)?; + let plan = Arc::new(compile_auction_plan(&settings)?); + plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; + let registry = IntegrationRegistry::with_plan(&settings, plan)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -366,6 +369,7 @@ enum NamedRouteHandler { /// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never /// reach the publisher fallback (which would leak admin credentials). LegacyAdminDenied, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -451,6 +455,11 @@ fn named_routes() -> [NamedRoute; 17] { primary_methods: LEGACY_ADMIN_DENY_METHODS, handler: NamedRouteHandler::LegacyAdminDenied, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -541,6 +550,9 @@ fn named_route_handler( handle_admin_eids_lookup(&partner_registry, &req) } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), + NamedRouteHandler::TraceMode => { + handle_trace_mode(&state.settings, req.uri().query()) + } NamedRouteHandler::Auction => { // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index fe299fce0..24c5c360e 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -9,10 +9,11 @@ use async_trait::async_trait; use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; use error_stack::{Report, ResultExt as _}; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, - RawProxyPolicyV1, RawProxyResponseV1, RuntimeServices, StoreId, StoreName, + BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, + PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, + PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, + ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1, + RuntimeServices, StoreId, StoreName, }; // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 045458bcd..458ed9217 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -40,8 +40,17 @@ fn test_settings() -> trusted_server_core::settings::Settings { [ec] passphrase = "test-secret-key-32-bytes-minimum" - [integrations.aps] + [auction] enabled = true + timeout_ms = 1000 + + [auction.providers.aps-main] + protocol = "openrtb-2.6" + profile = "aps" + endpoint = "https://aps.example/e/pb/bid" + routing = "all_eligible" + + [auction.providers.aps-main.profile_config] account_id = "route-test-aps-account" allow_script_creatives = true "#, @@ -87,7 +96,7 @@ fn assert_route_registered(method: &str, path: &str) { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn aps_profile_serves_renderer_through_adapter_fallback() { +async fn aps_profile_serves_canonical_renderer_through_reserved_dispatcher() { let mut settings = test_settings(); settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), @@ -103,22 +112,19 @@ async fn aps_profile_serves_renderer_through_adapter_fallback() { .expect("should parse APS profile config"), }, ); - let router = TrustedServerApp::routes_with_settings(settings) - .expect("should build router with APS profile"); - let mut service = EdgeZeroAxumService::new(router); let request = Request::builder() .method("GET") - .uri("/integrations/aps/renderer") + .uri("/integrations/aps/renderer/v2") .body(AxumBody::empty()) .expect("should build APS renderer request"); - - let response = service - .ready() - .await - .expect("should be ready") - .call(request) + let request = edgezero_adapter_axum::request::into_core_request(request) .await - .expect("should serve APS renderer"); + .expect("should convert APS renderer request"); + let response = + trusted_server_adapter_axum::app::dispatch_reserved_with_settings(settings, request) + .await + .expect("should build reserved APS dispatcher") + .expect("APS renderer should be reserved"); assert_eq!(response.status().as_u16(), 200); } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 827ece9b8..7625ce597 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -38,6 +38,7 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::build_runtime_services; @@ -141,8 +142,10 @@ fn settings_from_cloudflare_config_json() -> Result Result, Report> { - let orchestrator = build_orchestrator(&settings)?; - let registry = IntegrationRegistry::new(&settings)?; + let plan = Arc::new(compile_auction_plan(&settings)?); + plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Cloudflare)?; + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; + let registry = IntegrationRegistry::with_plan(&settings, plan)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -570,10 +573,9 @@ fn build_router(state: &Arc) -> RouterService { Ok::(admin_ec_lookup_not_supported()) }) .get( - "/_ts/admin/eids", + "/_ts/trace", make_handler(Arc::clone(&state), |s, _services, req| async move { - let partner_registry = PartnerRegistry::from_config(&s.settings.ec.partners)?; - handle_admin_eids_lookup(&partner_registry, &req) + handle_trace_mode(&s.settings, req.uri().query()) }), ) // Render-trace toggle: arms/disarms the ts-trace cookie and @@ -732,11 +734,10 @@ mod tests { let state = build_state_with_settings(aps_profile_settings()) .expect("Cloudflare startup should register APS renderer"); assert!( - state.registry.has_route( - &edgezero_core::http::Method::GET, - "/integrations/aps/renderer" - ), - "Cloudflare startup registry should expose the APS renderer" + state + .registry + .has_reserved_path("/integrations/aps/renderer/v2"), + "Cloudflare startup registry should reserve the APS v2 renderer" ); } diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 77a4b908b..8499199af 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -43,8 +43,17 @@ fn test_settings() -> Settings { [ec] passphrase = "test-secret-key-32-bytes-minimum" - [integrations.aps] + [auction] enabled = true + timeout_ms = 1000 + + [auction.providers.aps-main] + protocol = "openrtb-2.6" + profile = "aps" + endpoint = "https://aps.example/e/pb/bid" + routing = "all_eligible" + + [auction.providers.aps-main.profile_config] account_id = "route-test-aps-account" allow_script_creatives = true "#, diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 0bf502ca2..ad573d0aa 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -142,6 +142,7 @@ use trusted_server_core::request_signing::{ use trusted_server_core::settings::{ProxyAssetRoute, Settings}; use trusted_server_core::settings_data::{DEFAULT_CONFIG_STORE_ID, get_settings_from_config_store}; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ @@ -213,10 +214,16 @@ pub(crate) async fn dispatch_reserved_for_state( ) } -pub(crate) fn load_settings_from_config_store() -> Result> { - let store_name = default_config_store_name(); - let config_key = default_config_key(); - get_settings_from_config_store(&FastlyPlatformConfigStore, &store_name, &config_key) +pub(crate) fn load_settings_from_config_store( + stores: &RuntimeStoreConfig, +) -> Result> { + get_settings_from_config_store( + &FastlyPlatformConfigStore, + &FastlyPlatformSecretStore, + &stores.config_store_name, + &stores.config_key, + &stores.secret_store_name, + ) } pub(crate) fn build_state_from_settings( @@ -224,8 +231,10 @@ pub(crate) fn build_state_from_settings( ) -> Result, Report> { warn_if_certificate_check_disabled(&settings); - let orchestrator = build_orchestrator(&settings)?; - let registry = IntegrationRegistry::new(&settings)?; + let plan = Arc::new(compile_auction_plan(&settings)?); + plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Fastly)?; + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; + let registry = IntegrationRegistry::with_plan(&settings, plan)?; let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); let default_kv_store = Arc::new(UnavailableKvStore) as Arc; @@ -677,6 +686,7 @@ async fn run_named_route( } NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), + NamedRouteHandler::TraceMode => handle_trace_mode(&state.settings, req.uri().query()), NamedRouteHandler::Auction => { // The auction reads consent data, so the consent KV store must be // available — fail closed with 503 when it is configured but @@ -1096,6 +1106,7 @@ enum NamedRouteHandler { Identify, SetTester, ClearTester, + TraceMode, Auction, PageBids, FirstPartyProxy, @@ -1196,6 +1207,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET], handler: NamedRouteHandler::ClearTester, }, + NamedRoute { + path: "/_ts/trace", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TraceMode, + }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -1365,8 +1381,8 @@ mod tests { #[cfg(feature = "aps-runner-proxy-integration-test")] use super::dispatch_reserved_for_state; use super::{ - AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_PATH, TrustedServerApp, - build_state_from_settings, startup_error_router, + AppState, EnvConfig, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_PATH, RuntimeStoreConfig, + TrustedServerApp, build_state_from_settings, startup_error_router, }; use base64::Engine as _; use bytes::Bytes; @@ -1767,8 +1783,27 @@ mod tests { #[test] fn startup_registers_aps_renderer_route() { - let mut settings = test_settings(); - settings.auction.providers.clear(); + let mut settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-password" + + [publisher] + domain = "publisher.example" + cookie_domain = ".publisher.example" + origin_url = "https://origin.publisher.example" + proxy_secret = "fictional-proxy-secret" + + [ec] + passphrase = "fictional-secret-key-32-bytes-minimum" + + [auction] + enabled = true + "#, + ) + .expect("should parse APS startup settings"); settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), trusted_server_core::auction::ProviderConfig { @@ -1785,11 +1820,10 @@ mod tests { let state = build_state_from_settings(settings) .expect("Fastly startup should register APS renderer"); assert!( - state.registry.has_route( - &edgezero_core::http::Method::GET, - "/integrations/aps/renderer" - ), - "Fastly startup registry should expose the APS renderer" + state + .registry + .has_reserved_path("/integrations/aps/renderer/v2"), + "Fastly startup registry should reserve the APS v2 renderer" ); } diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 8b56bf8df..822cba246 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -19,12 +19,13 @@ use trusted_server_core::integrations::aps::{ }; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, - PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, - PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, - PlatformSecretStore, PlatformSelectResult, ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, - RawProxyPolicyV1, RawProxyResponseV1, StoreId, StoreName, + BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, + PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, + PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, + PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, + PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, + ProxyHeaderEvidenceV1, ProxyResponseEvidenceV1, RawProxyPolicyV1, RawProxyResponseV1, StoreId, + StoreName, }; use trusted_server_core::settings::TrustedClientIpConfig; diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2c321005a..50f0bb4b6 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -13,9 +13,11 @@ use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, he use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; -use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::auction::{ + AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, +}; use trusted_server_core::cache_policy::EdgeCacheHeader; -#[cfg(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32"))] +#[cfg(all(feature = "spin", target_arch = "wasm32"))] use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ @@ -43,6 +45,7 @@ use trusted_server_core::request_signing::{ use trusted_server_core::settings::Settings; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use trusted_server_core::settings_data::{default_config_key, default_secret_store_name}; +use trusted_server_core::trace_cookie::handle_trace_mode; use crate::middleware::{ AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware, SanitizeRequestMiddleware, @@ -78,6 +81,40 @@ fn build_state() -> Result, Report> { build_state_with_settings(settings) } +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +fn load_startup_settings() -> Result> { + let config_store_name = StoreName::from(SPIN_DEFAULT_CONFIG_STORE); + let config_key = default_config_key(); + let config_store = + futures::executor::block_on(SpinConfigStore::open(config_store_name.as_ref().to_owned())) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to open Spin Trusted Server config store".to_string(), + }) + .attach(error.to_string()) + })?; + let config_handle = ConfigStoreHandle::new(Arc::new(config_store)); + let config_adapter = ConfigStoreHandleAdapter(config_handle); + let raw_envelope = config_adapter + .get(&config_store_name, &config_key) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to read Spin Trusted Server app-config blob".to_string(), + }) + .attach(error.to_string()) + })?; + let secret_store = SpinSecretStoreAdapter; + settings_from_config_blob(&raw_envelope, &secret_store, &default_secret_store_name()) +} + +#[cfg(not(all(feature = "spin", target_arch = "wasm32")))] +fn load_startup_settings() -> Result> { + Err(Report::new(TrustedServerError::Configuration { + message: "Spin startup settings require the production config store".to_string(), + }) + .attach("use TrustedServerApp::routes_with_settings for host tests")) +} + #[cfg(all(feature = "aps-runner-proxy-integration-test", target_arch = "wasm32"))] fn build_state() -> Result, Report> { let envelope = @@ -101,8 +138,10 @@ fn build_state() -> Result, Report> { fn build_state_with_settings( settings: Settings, ) -> Result, Report> { - let orchestrator = build_orchestrator(&settings)?; - let registry = IntegrationRegistry::new(&settings)?; + let plan = Arc::new(compile_auction_plan(&settings)?); + plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Spin)?; + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; + let registry = IntegrationRegistry::with_plan(&settings, plan)?; Ok(Arc::new(AppState { settings: Arc::new(settings), @@ -234,6 +273,7 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 17] { ("/_ts/admin/eids", &[Method::GET]), ("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS), ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), + ("/_ts/trace", &[Method::GET]), ("/auction", &[Method::POST]), (PAGE_BIDS_PATH, &[Method::GET, Method::OPTIONS]), ("/__ts/page-bids", LEGACY_ADMIN_DENY_METHODS), @@ -837,6 +877,17 @@ fn build_router(state: &Arc) -> RouterService { }; let legacy_admin_deny = |_ctx: RequestContext| async { Ok::(legacy_admin_alias_denied()) }; + let s = Arc::clone(&state); + let trace_mode_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let req = ctx.into_request(); + Ok::( + handle_trace_mode(&s.settings, req.uri().query()) + .unwrap_or_else(|error| http_error(&error)), + ) + } + }; let mut builder = RouterService::builder() // Outermost middleware: strips the configured trusted-client-IP @@ -979,11 +1030,10 @@ mod tests { let state = build_state_with_settings(settings).expect("Spin startup should register APS renderer"); assert!( - state.registry.has_route( - &edgezero_core::http::Method::GET, - "/integrations/aps/renderer" - ), - "Spin startup registry should expose the APS renderer" + state + .registry + .has_reserved_path("/integrations/aps/renderer/v2"), + "Spin startup registry should reserve the APS v2 renderer" ); } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 63769f58e..d239cc4a1 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -456,13 +456,8 @@ struct SpinPendingResponse { /// `StubHttpClient` driver records the one-send 3xx behavior while adapter tests /// cover request/response policy around that single boundary. /// -/// # Known MVP limits -/// -/// **No configurable outbound timeout.** `spin_sdk::http::send` does not -/// expose per-request timeout control, and [`PlatformBackendSpec::first_byte_timeout`] -/// is ignored by [`NoopBackend`]. A slow or hung origin will block the Spin -/// invocation for whatever default the Spin runtime imposes. Operators requiring -/// deterministic timeout behaviour should use the Fastly adapter. +/// Per-request raw-proxy calls lower the core connect, first-byte, and +/// between-byte deadlines into WASI HTTP [`RequestOptions`]. #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub struct SpinPlatformHttpClient; @@ -681,25 +676,42 @@ impl SpinPlatformHttpClient { })?; } - let timeout_nanos = policy.total_timeout.as_nanos().try_into().map_err(|_| { + let connect_timeout_nanos = policy.total_timeout.as_nanos().try_into().map_err(|_| { Report::new(PlatformError::HttpClient) - .attach("raw proxy timeout exceeds WASI HTTP duration range") + .attach("raw proxy connect timeout exceeds WASI HTTP duration range") })?; + let first_byte_timeout_nanos = + policy + .first_byte_timeout + .as_nanos() + .try_into() + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy first-byte timeout exceeds WASI HTTP duration range") + })?; + let between_bytes_timeout_nanos = policy + .blocking_read_timeout + .as_nanos() + .try_into() + .map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("raw proxy between-bytes timeout exceeds WASI HTTP duration range") + })?; let options = RequestOptions::new(); options - .set_connect_timeout(Some(timeout_nanos)) + .set_connect_timeout(Some(connect_timeout_nanos)) .map_err(|_| { Report::new(PlatformError::Unsupported) .attach("Spin raw proxy connect timeout is unavailable") })?; options - .set_first_byte_timeout(Some(timeout_nanos)) + .set_first_byte_timeout(Some(first_byte_timeout_nanos)) .map_err(|_| { Report::new(PlatformError::Unsupported) .attach("Spin raw proxy first-byte timeout is unavailable") })?; options - .set_between_bytes_timeout(Some(timeout_nanos)) + .set_between_bytes_timeout(Some(between_bytes_timeout_nanos)) .map_err(|_| { Report::new(PlatformError::Unsupported) .attach("Spin raw proxy between-bytes timeout is unavailable") diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 1f0693987..d9cfa64c7 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -42,8 +42,17 @@ fn test_settings() -> Settings { [ec] passphrase = "test-secret-key-32-bytes-minimum" - [integrations.aps] + [auction] enabled = true + timeout_ms = 1000 + + [auction.providers.aps-main] + protocol = "openrtb-2.6" + profile = "aps" + endpoint = "https://aps.example/e/pb/bid" + routing = "all_eligible" + + [auction.providers.aps-main.profile_config] account_id = "route-test-aps-account" allow_script_creatives = true "#, diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 20993125e..ead5be30f 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -8,6 +8,7 @@ use trusted_server_core::streaming_processor::StreamProcessor as _; fn make_config() -> HtmlProcessorConfig { HtmlProcessorConfig { csp_nonce_observed: None, + csp_nonce: None, origin_host: "origin.bench.example.com".to_string(), request_host: "proxy.bench.example.com".to_string(), request_scheme: "https".to_string(), diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 35d73af19..ca1b42f2d 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -273,13 +273,19 @@ pub async fn handle_auction( winning_bids: HashMap::new(), total_time_ms: 0, metadata: HashMap::new(), + decision_set: AuctionDecisionSetV1::failed( + &auction_request, + AuctionSlotFailureReason::AuctionDisabled, + ), }; - return convert_to_openrtb_response( + return Ok(exact_auction_response_v1( &empty_result, settings, &auction_request, + &request_origin, ec_context.ec_allowed(), - ); + )? + .response); } // Server-side auction consent gate. The publisher-navigation and @@ -686,7 +692,7 @@ mod tests { NoopBackend, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, noop_services, }; use crate::platform::{ClientInfo, PlatformResponse}; - use crate::test_support::tests::create_test_settings; + use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use serde_json::json; @@ -810,21 +816,17 @@ mod tests { } #[tokio::test] - async fn disabled_creative_opportunities_do_not_disable_direct_auction() { - let mut settings = create_test_settings(); - settings.creative_opportunities = Some( - toml::from_str("enabled = false\ngam_network_id = \"12345\"\n") - .expect("should build disabled creative opportunity config"), + async fn direct_auction_remains_available_when_templates_are_disabled() { + let settings_toml = format!( + "{}\n[auction]\nenabled = true\n\n[creative_opportunities]\nenabled = false\ngam_network_id = \"12345\"\n", + crate_test_settings_str() ); - let config = AuctionConfig { - enabled: true, - providers: vec!["call_recording_provider".to_string()], - timeout_ms: 2_000, - mediator: None, - ..Default::default() - }; + let mut settings = Settings::from_toml(&settings_toml) + .expect("should parse settings with disabled templates"); + settings.auction.providers = + crate::auction::AuctionConfig::legacy_provider_map(&["call_recording_provider"]); let called = Arc::new(Mutex::new(false)); - let mut orchestrator = AuctionOrchestrator::new(config); + let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); orchestrator.register_provider(Arc::new(CallRecordingProvider { called: Arc::clone(&called), })); diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 50b6a2fec..b3474abc7 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -778,6 +778,26 @@ use coordinated_cutover_v1::{ canonicalize_browser_auction_projection_v1, serialize_trusted_server_auction_response_v1, }; +/// Delivery facts produced while serializing winning bids. +#[derive(Debug, Default)] +pub(crate) struct AuctionDeliveryReport { + /// Winning slot IDs included in the serialized response. + pub delivered_winner_slots: HashSet, + /// Winners omitted because they could not be delivered safely. + pub dropped_winner_count: usize, + /// Machine-readable reasons for omitted winners. + pub dropped_winner_reasons: AuctionDropReasons, +} + +/// Serialized response and the delivery facts used to produce it. +#[cfg(test)] +pub(crate) struct OpenRtbResponseConversion { + /// HTTP response returned to the auction client. + pub response: Response, + /// Delivery facts for telemetry and diagnostics. + pub delivery: AuctionDeliveryReport, +} + /// Convert `OrchestrationResult` to `OpenRTB` response format. /// /// Creative HTML in the `adm` field is optionally sanitized and optionally @@ -785,27 +805,64 @@ use coordinated_cutover_v1::{ /// ([`AuctionConfig::sanitize_creatives`], opt-in, and /// [`AuctionConfig::rewrite_creatives`], default-on); with both disabled the /// creative ships exactly as the bidder returned it, subject to the 1 MiB -/// per-creative cap. Typed renderers are serialized in the response extension -/// instead of entering that pipeline at all. +/// per-creative cap. /// /// [`AuctionConfig::sanitize_creatives`]: crate::auction_config_types::AuctionConfig::sanitize_creatives /// [`AuctionConfig::rewrite_creatives`]: crate::auction_config_types::AuctionConfig::rewrite_creatives /// /// # Errors /// -/// Returns an error if response serialization fails. -/// -/// Winners without a decoded price or a deliverable creative are omitted and -/// recorded in the returned delivery report so other slots can still render. +/// Returns an error if: +/// - A winning bid is missing a price or render source +/// - The response serialization fails pub fn convert_to_openrtb_response( result: &OrchestrationResult, settings: &Settings, auction_request: &AuctionRequest, ec_allowed: bool, ) -> Result, Report> { + convert_to_openrtb_response_impl(result, settings, auction_request, ec_allowed) +} + +#[cfg(test)] +pub(crate) fn convert_to_openrtb_response_with_report( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + ec_allowed: bool, +) -> Result> { + let (response, delivery) = convert_to_openrtb_response_impl_with_report( + result, + settings, + auction_request, + ec_allowed, + )?; + Ok(OpenRtbResponseConversion { response, delivery }) +} + +fn convert_to_openrtb_response_impl( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + ec_allowed: bool, +) -> Result, Report> { + let (response, _) = convert_to_openrtb_response_impl_with_report( + result, + settings, + auction_request, + ec_allowed, + )?; + Ok(response) +} + +fn convert_to_openrtb_response_impl_with_report( + result: &OrchestrationResult, + settings: &Settings, + auction_request: &AuctionRequest, + ec_allowed: bool, +) -> Result<(Response, AuctionDeliveryReport), Report> { let mut seatbids = Vec::with_capacity(result.winning_bids.len()); - let mut dropped_winner_count = 0; - let mut dropped_winner_reasons = AuctionDropReasons::new(); + let mut delivery = AuctionDeliveryReport::default(); for (slot_id, bid) in &result.winning_bids { let Some(price) = bid.price else { @@ -815,7 +872,11 @@ pub fn convert_to_openrtb_response( slot_id, bid.bidder ); - delivery.record_drop("no_decoded_price"); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::InvalidPrice, + ); continue; }; @@ -837,17 +898,17 @@ pub fn convert_to_openrtb_response( slot_id, bid.bidder ); - dropped_winner_count += 1; + delivery.dropped_winner_count += 1; record_auction_drop( - &mut dropped_winner_reasons, + &mut delivery.dropped_winner_reasons, AuctionDropReason::MultipleRenderSources, ); continue; } - // Ordinary markup remains on the mandatory sanitize/rewrite path. A - // typed render source is serialized separately and never enters the - // HTML sanitizer. + // Ordinary markup follows the independently configured processing + // path: sanitization is opt-in and rewriting is default-on. A typed + // render source is serialized separately and never enters either pass. let (adm, ext) = if let Some(raw_creative) = creative { let processed = creative::process_auction_creative(settings, raw_creative); @@ -857,47 +918,41 @@ pub fn convert_to_openrtb_response( slot_id, bid.bidder, settings.auction.sanitize_creatives, - rewrite_creatives, + settings.auction.rewrite_creatives, raw_creative.len(), processed.len() ); if processed.trim().is_empty() { - let Some(renderer) = bid.renderer.as_ref() else { - log::warn!( - "Auction {}: skipping winning bid for slot '{}' from '{}' because creative processing rejected its only render source", - auction_request.id, - slot_id, - bid.bidder - ); - delivery.record_drop("creative_processing_rejected"); - continue; - }; - let Some(ext) = serialize_renderer(renderer) else { - log::warn!( - "Auction {}: skipping winning bid for slot '{}' from '{}' because its renderer extension could not be serialized", - auction_request.id, - slot_id, - bid.bidder - ); - delivery.record_drop("renderer_extension_serialization_failed"); - continue; - }; - (None, Some(ext)) - } else { - (Some(processed), None) + log::warn!( + "Auction {}: skipping winning bid for slot '{}' from '{}' because creative processing rejected its only render source", + auction_request.id, + slot_id, + bid.bidder + ); + delivery.dropped_winner_count += 1; + record_auction_drop( + &mut delivery.dropped_winner_reasons, + AuctionDropReason::CreativeProcessingRejected, + ); + continue; } + + (Some(processed), None) } else if let Some(renderer) = bid.renderer.as_ref() { - let Some(ext) = serialize_renderer(renderer) else { + let Some(ext) = (BidExt { + trusted_server: BidTrustedServerExt { renderer }, + }) + .to_ext() else { log::warn!( "Auction {}: skipping winning bid for slot '{}' from '{}' because its renderer extension could not be serialized", auction_request.id, slot_id, bid.bidder ); - dropped_winner_count += 1; + delivery.dropped_winner_count += 1; record_auction_drop( - &mut dropped_winner_reasons, + &mut delivery.dropped_winner_reasons, AuctionDropReason::RendererExtensionSerializationFailed, ); continue; @@ -910,9 +965,9 @@ pub fn convert_to_openrtb_response( slot_id, bid.bidder ); - dropped_winner_count += 1; + delivery.dropped_winner_count += 1; record_auction_drop( - &mut dropped_winner_reasons, + &mut delivery.dropped_winner_reasons, AuctionDropReason::NoRenderSource, ); continue; @@ -940,6 +995,7 @@ pub fn convert_to_openrtb_response( bid: vec![openrtb_bid], ..Default::default() }); + delivery.delivered_winner_slots.insert(slot_id.clone()); } // Determine strategy name for response metadata @@ -966,8 +1022,8 @@ pub fn convert_to_openrtb_response( total_bids: result.total_bids(), time_ms: result.total_time_ms, provider_details, - dropped_winner_count, - dropped_winner_reasons, + dropped_winner_count: delivery.dropped_winner_count, + dropped_winner_reasons: delivery.dropped_winner_reasons.clone(), }, } .to_ext(), @@ -989,7 +1045,7 @@ pub fn convert_to_openrtb_response( attach_auction_response_headers(&mut response, auction_request, ec_allowed)?; - Ok(response) + Ok((response, delivery)) } #[cfg(test)] @@ -1669,7 +1725,8 @@ mod tests { #[test] fn convert_to_openrtb_response_serializes_winning_bid_and_orchestrator_ext() { - let settings = make_settings(); + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); @@ -1709,18 +1766,7 @@ mod tests { assert_eq!(bid["id"], json!("appnexus-div-gpt-top")); assert_eq!(bid["impid"], json!("div-gpt-top")); assert_eq!(bid["price"], json!(2.75)); - // Rewriting is on by default, and a body-less fragment still receives - // the creative runtime (prepended), so the markup is carried rather - // than returned verbatim. - let adm = bid["adm"].as_str().expect("should serialize adm"); - assert!( - adm.contains("
Ad
"), - "should carry the creative: {adm}" - ); - assert!( - adm.contains("/static/tsjs=tsjs-unified.min.js"), - "should inject the creative runtime into a body-less fragment: {adm}" - ); + assert_eq!(bid["adm"], json!("
Ad
")); assert_eq!(bid["crid"], json!("appnexus-creative")); assert_eq!(bid["w"], json!(300)); assert_eq!(bid["h"], json!(250)); @@ -1798,7 +1844,6 @@ mod tests { // 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()); @@ -1808,7 +1853,7 @@ mod tests { .expect("should have a creative fixture"); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) - .expect("should convert creative with sanitization disabled"); + .expect("should convert creative with rewriting disabled"); let adm = response_adm(response); assert_eq!( @@ -1851,7 +1896,7 @@ mod tests { } #[test] - fn sanitize_creatives_defaults_to_disabled() { + fn rewrite_creatives_defaults_to_enabled() { let config = crate::auction_config_types::AuctionConfig::default(); assert!( !config.sanitize_creatives, @@ -1865,8 +1910,6 @@ mod tests { #[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; @@ -1963,23 +2006,16 @@ mod tests { #[test] fn convert_to_openrtb_response_skips_invalid_winners_without_dropping_valid_slots() { - // Sanitization is opt-in, so enable it here: script-only markup is what - // makes the `rejected` and `renderer` fixtures below reach the - // processing-rejected path. Left at the default they would survive - // processing as ordinary (script-bearing) creatives. let mut settings = make_settings(); - settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); let mut missing = make_bid("missing", "invalid", Some(3.0)); missing.creative = None; let mut whitespace = make_bid("whitespace", "invalid", Some(2.9)); whitespace.creative = Some(" \n\t ".to_string()); - let mut rejected = make_bid("rejected", "invalid", Some(2.8)); - rejected.creative = Some("".to_string()); - let unpriced = make_bid("unpriced", "invalid", None); let ordinary = make_bid("ordinary", "appnexus", Some(2.75)); let mut renderer = make_bid("renderer", "aps", Some(2.5)); - renderer.creative = Some("".to_string()); + renderer.creative = Some(" ".to_string()); renderer.bid_id = Some("upstream-renderer-bid".to_string()); renderer.creative_id = None; renderer.renderer = Some(BidRenderSourceV1::Aps(ApsRendererV1 { @@ -1999,8 +2035,6 @@ mod tests { winning_bids: HashMap::from([ (missing.slot_id.clone(), missing), (whitespace.slot_id.clone(), whitespace), - (rejected.slot_id.clone(), rejected), - (unpriced.slot_id.clone(), unpriced), (ordinary.slot_id.clone(), ordinary), (renderer.slot_id.clone(), renderer), ]), @@ -2024,30 +2058,16 @@ mod tests { .collect(); assert_eq!(bids.len(), 2, "should omit only invalid winners"); - assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 4); + assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 2); assert_eq!( json["ext"]["orchestrator"]["dropped_winner_reasons"]["no_render_source"], 2 ); - assert_eq!( - json["ext"]["orchestrator"]["dropped_winner_reasons"]["no_decoded_price"], - 1 - ); - assert_eq!( - json["ext"]["orchestrator"]["dropped_winner_reasons"]["creative_processing_rejected"], - 1 - ); let ordinary = bids .iter() .find(|bid| bid["impid"] == "ordinary") .expect("should preserve ordinary winner"); - assert!( - ordinary["adm"] - .as_str() - .is_some_and(|adm| adm.contains("
Ad
")), - "should preserve ordinary creative markup: {}", - ordinary["adm"] - ); + assert_eq!(ordinary["adm"], "
Ad
"); let renderer = bids .iter() .find(|bid| bid["impid"] == "renderer") @@ -2061,6 +2081,32 @@ mod tests { assert!(renderer.get("ext").is_some(), "should include renderer ext"); } + #[test] + fn convert_to_openrtb_response_drops_creative_rejected_by_processing() { + let mut settings = make_settings(); + settings.auction.sanitize_creatives = true; + settings.auction.rewrite_creatives = false; + let auction_request = make_auction_request(); + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.creative = Some("".to_string()); + let result = make_result(bid); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should omit a creative rejected by configured processing"); + let json = response_json(response); + + assert!( + json["seatbid"].as_array().is_none_or(Vec::is_empty), + "should not serialize an empty adm" + ); + assert_eq!(json["ext"]["orchestrator"]["dropped_winner_count"], 1); + assert_eq!( + json["ext"]["orchestrator"]["dropped_winner_reasons"]["creative_processing_rejected"], + 1, + "should report the exact processing rejection" + ); + } + #[test] fn convert_to_openrtb_response_rejects_multiple_render_sources() { let mut settings = make_settings(); @@ -2198,7 +2244,8 @@ mod tests { #[test] fn convert_to_openrtb_response_serializes_multiple_winning_bids() { - let settings = make_settings(); + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; let auction_request = make_auction_request(); 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)); @@ -2255,12 +2302,10 @@ mod tests { "should preserve top slot impid" ); assert_eq!(top_bid["price"], json!(2.75), "should preserve top price"); - assert!( - top_bid["adm"] - .as_str() - .is_some_and(|adm| adm.contains("
Ad
")), - "should preserve top creative: {}", - top_bid["adm"] + assert_eq!( + top_bid["adm"], + json!("
Ad
"), + "should preserve top creative" ); let sidebar_seatbid = seatbids @@ -2288,12 +2333,10 @@ mod tests { json!(1.25), "should preserve sidebar price" ); - assert!( - sidebar_bid["adm"] - .as_str() - .is_some_and(|adm| adm.contains("
Sidebar
")), - "should preserve sidebar creative: {}", - sidebar_bid["adm"] + assert_eq!( + sidebar_bid["adm"], + json!("
Sidebar
"), + "should preserve sidebar creative" ); assert_eq!( json["ext"]["orchestrator"]["total_bids"], @@ -2332,9 +2375,15 @@ mod tests { assert!(conversion.delivery.delivered_winner_slots.is_empty()); assert_eq!(conversion.delivery.dropped_winner_count, 1); assert_eq!( - conversion.delivery.dropped_winner_reasons["no_decoded_price"], 1, + conversion.delivery.dropped_winner_reasons[&AuctionDropReason::InvalidPrice], + 1, "should report the omitted malformed winner" ); + assert_eq!( + conversion.response.status(), + StatusCode::OK, + "should still return a successful partial auction response" + ); } #[test] diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 432303928..e2923d250 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -228,6 +228,6 @@ mod plan_sharing_tests { let registry = IntegrationRegistry::with_plan(&settings, plan) .expect("should build APS renderer registry"); - assert!(registry.has_route(&http::Method::GET, "/integrations/aps/renderer")); + assert!(registry.has_reserved_path("/integrations/aps/renderer/v2")); } } diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs index d9f20c42e..89ef5f426 100644 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -629,6 +629,9 @@ fn extract_standard_bid(value: &Value, returned_seat: Option<&str>) -> Option>; + struct NormalizedProviderResponses { outcomes: Vec, candidates: HashMap, @@ -53,6 +59,7 @@ pub struct DispatchedAuction { planned_unused_bidder_params: HashMap, planned_unroutable_bidder_count: u32, planned_provider_order: HashMap, + eligible_slots_by_provider: EligibleSlotsByProvider, } struct ProviderLaunchState { @@ -63,6 +70,38 @@ struct ProviderLaunchState { parse_state: Option, } +/// Outcome of attempting to dispatch split-phase auction provider requests. +#[allow(clippy::large_enum_variant)] +pub enum DispatchAuctionOutcome { + /// No provider request was started and no provider failure was observed. + NotStarted, + /// No provider request could be launched, but launch failures were observed. + DispatchFailed { + /// Original auction request. + request: AuctionRequest, + /// Provider launch-failure responses. + provider_responses: Vec, + /// Fatal admission error that synchronous execution must propagate. + /// + /// Split publisher dispatch records the failure and continues without + /// attempting provider network I/O. + fatal_admission_error: Option>, + /// Auction-level metadata materialized before the failure. + metadata: HashMap, + /// Elapsed dispatch time. + elapsed_ms: u64, + /// Exact provider-to-slot eligibility established during routing. + eligible_slots_by_provider: EligibleSlotsByProvider, + }, + /// One or more providers produced an immediate response or started a request. + Dispatched(DispatchedAuction), +} + +/// A dispatch attempt either has in-flight transport to collect or is already terminal. +pub(crate) enum ResolvedDispatchOutcome { + InFlight(Box), + Complete(Box), +} impl DispatchedAuction { /// Consume the dispatch token without collecting provider responses. #[must_use] @@ -116,6 +155,7 @@ impl DispatchedAuction { planned_unused_bidder_params: HashMap::new(), planned_unroutable_bidder_count: 0, planned_provider_order: HashMap::new(), + eligible_slots_by_provider: HashMap::new(), } } } @@ -195,6 +235,13 @@ fn provider_timeout_response(provider_name: &str, response_time_ms: u64) -> Auct .with_metadata("message", serde_json::json!("Provider request timed out")) } +fn provider_skipped_response(provider_name: &str) -> AuctionResponse { + AuctionResponse::no_bid(provider_name, 0).with_metadata( + "routing", + serde_json::json!({"skipped_no_eligible_slots": true}), + ) +} + fn canonical_provider_response( expected_provider: &str, response: AuctionResponse, @@ -252,6 +299,23 @@ fn routing_metadata(unroutable_bidder_count: u32) -> HashMap EligibleSlotsByProvider { + routed + .inputs() + .iter() + .map(|input| { + ( + input.provider_id().as_str().to_string(), + input + .slots() + .iter() + .map(|slot| slot.slot().id.clone()) + .collect(), + ) + }) + .collect() +} + /// Attach only the count derived from the routed provider input at dispatch. /// /// This is intentionally applied after every provider outcome is materialized, @@ -313,6 +377,7 @@ pub(crate) struct AuctionOrchestratorHarness { plan: Arc, providers: Vec>, mediator: Option>, + identity_generator: Arc, } struct PlannedLaunchState { @@ -343,9 +408,18 @@ impl AuctionOrchestratorHarness { plan, providers, mediator, + identity_generator: Arc::new(SystemAuctionIdentityGenerator), } } + pub(crate) fn with_identity_generator( + mut self, + identity_generator: Arc, + ) -> Self { + self.identity_generator = identity_generator; + self + } + pub(crate) fn provider_count(&self) -> usize { self.providers.len() } @@ -615,12 +689,21 @@ impl AuctionOrchestratorHarness { .unwrap_or(usize::MAX) }); + let helper = AuctionOrchestrator::with_identity_generator( + AuctionConfig::default(), + Arc::clone(&self.identity_generator), + ); + let eligible_slots_by_provider = routed_eligible_slots(routed); + let normalized = helper.normalize_provider_responses_with_eligibility( + original_request, + &mut responses, + &eligible_slots_by_provider, + ); let floor_prices = original_request .slots .iter() .filter_map(|slot| slot.floor_price.map(|floor| (slot.id.clone(), floor))) .collect::>(); - let helper = AuctionOrchestrator::new(AuctionConfig::default()); let local_winners = || helper.select_winning_bids(&responses, &floor_prices); let (mediator_response, winning_bids) = if let Some(mediator) = &self.mediator { let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); @@ -640,10 +723,17 @@ impl AuctionOrchestratorHarness { "Planned mediator transport budget canonicalized to zero; using local ranking" ); let winning_bids = local_winners(); + let decision_set = helper.build_decision_set( + original_request, + &normalized.outcomes, + &winning_bids, + true, + ); return Ok(OrchestrationResult { provider_responses: responses, mediator_response: None, winning_bids, + decision_set, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: routing_metadata(routed.diagnostics().unroutable_bidder_count()), }); @@ -746,10 +836,18 @@ impl AuctionOrchestratorHarness { "Auction routing diagnostics: unroutable_bidder_count={}", unroutable_bidder_count ); + let mediation_failed = self.mediator.is_some() && mediator_response.is_none(); + let decision_set = helper.build_decision_set( + original_request, + &normalized.outcomes, + &winning_bids, + mediation_failed, + ); Ok(OrchestrationResult { provider_responses: responses, mediator_response, winning_bids, + decision_set, total_time_ms: auction_start.elapsed().as_millis() as u64, metadata: routing_metadata(unroutable_bidder_count), }) @@ -784,18 +882,6 @@ impl AuctionOrchestrator { } } - #[cfg(test)] - fn with_identity_generator( - config: AuctionConfig, - identity_generator: Arc, - ) -> Self { - Self { - config, - providers: HashMap::new(), - identity_generator, - } - } - /// Create the live orchestrator from one shared compiled auction plan. #[must_use] pub fn from_plan(plan: Arc, mediator: Option>) -> Self { @@ -816,15 +902,32 @@ impl AuctionOrchestrator { config: AuctionConfig::default(), #[cfg(test)] providers: HashMap::new(), + identity_generator: Arc::new(SystemAuctionIdentityGenerator), } } + #[cfg(test)] + fn with_identity_generator( + config: AuctionConfig, + identity_generator: Arc, + ) -> Self { + let mut orchestrator = Self::new(config); + orchestrator.identity_generator = identity_generator; + orchestrator + } + /// Return whether this orchestrator and another plan consumer share the same plan allocation. #[must_use] pub fn shares_plan(&self, plan: &Arc) -> bool { Arc::ptr_eq(&self.plan, plan) } + /// Return whether the compiled plan contains the named provider profile. + #[must_use] + pub fn has_profile(&self, profile_id: &str) -> bool { + self.plan.has_profile(profile_id) + } + /// Register an auction provider in the legacy parity harness. #[cfg(test)] pub(crate) fn register_provider(&mut self, provider: Arc) { @@ -844,15 +947,32 @@ impl AuctionOrchestrator { request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { - match self.dispatch_auction(request, context).await { - DispatchAuctionOutcome::Dispatched(dispatched) => Ok(self - .collect_dispatched_auction(dispatched, context.services, context) + match self + .resolve_dispatch_outcome(request, self.dispatch_auction(request, context).await)? + { + ResolvedDispatchOutcome::InFlight(dispatched) => Ok(self + .collect_dispatched_auction(*dispatched, context.services, context) .await), + ResolvedDispatchOutcome::Complete(result) => Ok(*result), + } + } + + /// Normalize a split dispatch result without starting any additional transport. + pub(crate) fn resolve_dispatch_outcome( + &self, + request: &AuctionRequest, + outcome: DispatchAuctionOutcome, + ) -> Result> { + match outcome { + DispatchAuctionOutcome::Dispatched(dispatched) => { + Ok(ResolvedDispatchOutcome::InFlight(Box::new(dispatched))) + } DispatchAuctionOutcome::DispatchFailed { - provider_responses, + mut provider_responses, fatal_admission_error, metadata, elapsed_ms, + eligible_slots_by_provider, .. } => { if let Some(error) = fatal_admission_error { @@ -860,17 +980,33 @@ impl AuctionOrchestrator { message: "Planned auction admission failed".to_string(), })); } - Ok(OrchestrationResult { - provider_responses, - mediator_response: None, - winning_bids: HashMap::new(), - total_time_ms: elapsed_ms, - metadata, - }) + let normalized = self.normalize_provider_responses_with_eligibility( + request, + &mut provider_responses, + &eligible_slots_by_provider, + ); + let winning_bids = HashMap::new(); + let decision_set = + self.build_decision_set(request, &normalized.outcomes, &winning_bids, false); + Ok(ResolvedDispatchOutcome::Complete(Box::new( + OrchestrationResult { + provider_responses, + mediator_response: None, + winning_bids, + decision_set, + total_time_ms: elapsed_ms, + metadata, + }, + ))) } DispatchAuctionOutcome::NotStarted => { if self.planned_providers.is_empty() { - Ok(OrchestrationResult::no_bid()) + Ok(ResolvedDispatchOutcome::Complete(Box::new( + OrchestrationResult::no_bid( + request, + AuctionSlotFailureReason::SlotNotEligible, + ), + ))) } else { Err(Report::new(TrustedServerError::Auction { message: "No planned provider request was started".to_string(), @@ -880,6 +1016,7 @@ impl AuctionOrchestrator { } } + #[cfg(test)] fn provider_is_eligible_for_slot( &self, provider_name: &str, @@ -894,6 +1031,7 @@ impl AuctionOrchestrator { }) } + #[cfg(test)] fn eligible_slot_ids(&self, provider_name: &str, request: &AuctionRequest) -> HashSet { request .slots @@ -950,10 +1088,36 @@ impl AuctionOrchestrator { None } + #[cfg(test)] fn normalize_provider_responses( &self, request: &AuctionRequest, responses: &mut [AuctionResponse], + ) -> NormalizedProviderResponses { + let eligible_slots_by_provider = self + .config + .providers + .keys() + .map(super::plan::ProviderId::as_str) + .map(|provider| { + ( + provider.to_string(), + self.eligible_slot_ids(provider, request), + ) + }) + .collect(); + self.normalize_provider_responses_with_eligibility( + request, + responses, + &eligible_slots_by_provider, + ) + } + + fn normalize_provider_responses_with_eligibility( + &self, + request: &AuctionRequest, + responses: &mut [AuctionResponse], + eligible_slots_by_provider: &EligibleSlotsByProvider, ) -> NormalizedProviderResponses { let requested_slots: HashMap<&str, &super::types::AdSlot> = request .slots @@ -965,7 +1129,10 @@ impl AuctionOrchestrator { let mut outcomes = Vec::new(); for response in responses { - let eligible_slots = self.eligible_slot_ids(&response.provider, request); + let eligible_slots = eligible_slots_by_provider + .get(&response.provider) + .cloned() + .unwrap_or_default(); let response_failure = Self::response_failure_reason(response); let mut upstream_counts = HashMap::::new(); for bid in &response.bids { @@ -1005,12 +1172,7 @@ impl AuctionOrchestrator { slot.formats.iter().any(|format| { format.width == bid.width && format.height == bid.height - && self - .providers - .get(&response.provider) - .is_some_and(|provider| { - provider.supports_media_type(&format.media_type) - }) + && format.media_type == super::types::MediaType::Banner }) }); let upstream_id = bid.bid_id.as_deref(); @@ -1126,11 +1288,9 @@ impl AuctionOrchestrator { ); } - let eligible_provider_count = self - .config - .provider_names() + let eligible_provider_count = outcomes .iter() - .filter(|provider| self.provider_is_eligible_for_slot(provider, slot)) + .filter(|outcome| outcome.slot == slot.id) .count(); if eligible_provider_count == 0 { return SlotAuctionDecisionV1::Failed { @@ -1256,7 +1416,10 @@ impl AuctionOrchestrator { context: &AuctionContext<'_>, ) -> Result> { if !self.enabled { - return Ok(OrchestrationResult::no_bid()); + return Ok(OrchestrationResult::no_bid( + request, + AuctionSlotFailureReason::AuctionDisabled, + )); } #[cfg(not(test))] return self.run_planned_auction(request, context).await; @@ -1267,21 +1430,8 @@ impl AuctionOrchestrator { #[cfg(test)] let start_time = Instant::now(); - if !self.config.enabled { - return Ok(OrchestrationResult { - provider_responses: Vec::new(), - mediator_response: None, - winning_bids: HashMap::new(), - decision_set: AuctionDecisionSetV1::failed( - request, - AuctionSlotFailureReason::AuctionDisabled, - ), - total_time_ms: 0, - metadata: HashMap::new(), - }); - } - - // Auto-detect strategy based on mediator configuration + // Auto-detect strategy based on mediator configuration. + #[cfg(test)] let (strategy_name, result) = if self.config.has_mediator() { ( "parallel_mediation", @@ -1336,44 +1486,64 @@ impl AuctionOrchestrator { mediator.provider_name() ); let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); - if remaining_ms == 0 { + let logical_budget_ms = remaining_ms.min(mediator.timeout_ms()); + let transport_timeout_ms = context + .services + .backend() + .canonicalize_transport_timeout_ms(logical_budget_ms, mediator.timeout_ms()); + if logical_budget_ms == 0 || transport_timeout_ms == 0 { log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); mediation_failed = true; } else { let mediator_context = AuctionContext { settings: context.settings, request: context.request, - timeout_ms: context - .services - .backend() - .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()), + timeout_ms: logical_budget_ms, + transport_timeout_ms, provider_responses: Some(&provider_responses), services: context.services, }; let start_time = Instant::now(); let raw_response = match mediator.request_bids(request, &mediator_context).await { - Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), + Ok(ProviderRequestOutcome::Immediate(response)) => Some( + canonical_provider_response(mediator.provider_name(), response), + ), Ok(ProviderRequestOutcome::Pending { request: pending, parse_state, }) => match mediator_context.services.http_client().wait(pending).await { - Ok(platform_response) => mediator - .parse_response_with_context_and_state( - platform_response, - start_time.elapsed().as_millis() as u64, - request, - &mediator_context, - parse_state.as_deref(), - ) - .await - .inspect_err(|error| { - log::warn!( - "Mediator '{}' parse failed: {error:?}", - mediator.provider_name() - ); - }) - .ok(), + Ok(platform_response) => { + let response_time_ms = start_time.elapsed().as_millis() as u64; + if AuctionDeadlinePolicy::for_runtime(context.services) + .rejects_late_completion(mediation_start, context.timeout_ms) + { + None + } else { + mediator + .parse_response_with_context_and_state( + platform_response, + response_time_ms, + request, + &mediator_context, + parse_state.as_deref(), + ) + .await + .inspect_err(|error| { + log::warn!( + "Mediator '{}' parse failed: {error:?}", + mediator.provider_name() + ); + }) + .ok() + .map(|response| { + canonical_provider_response( + mediator.provider_name(), + response, + ) + }) + } + } Err(error) => { log::warn!( "Mediator '{}' request failed: {error:?}", @@ -1518,7 +1688,6 @@ impl AuctionOrchestrator { let mut backend_to_provider: HashMap = HashMap::new(); let mut pending_requests: Vec = Vec::new(); let mut responses = Vec::new(); - for provider_name in &provider_names { let provider = match self.providers.get(*provider_name) { Some(p) => p, @@ -1553,17 +1722,19 @@ impl AuctionOrchestrator { continue; } - // Immediate providers have no backend name and must remain eligible - // to return a synchronous result. Pending providers are still - // guarded before dispatch when their name can be predicted. - let predicted_backend_name = provider.backend_name(context.services, effective_timeout); - if let Some(backend_name) = predicted_backend_name.as_ref() - && backend_to_provider.contains_key(backend_name) + // Pre-launch guard: `request_bids` fires the outbound send, and + // discarding the returned pending handle afterwards does not retract + // it. If another provider this auction already claimed the predicted + // backend name, skip *before* dispatching so a duplicate never hits + // the wire. The post-launch check below stays as a defense for a + // provider that resolves to an unexpected name. + if let Some(predicted) = provider.backend_name(context.services, effective_timeout) + && backend_to_provider.contains_key(&predicted) { log::warn!( "Provider '{}' predicted backend name '{}' already belongs to another provider; skipping launch", provider.provider_name(), - backend_name, + predicted, ); responses.push(provider_launch_failed_response(provider.provider_name(), 0)); continue; @@ -1591,14 +1762,13 @@ impl AuctionOrchestrator { parse_state, }) => { let request_backend_name = pending.backend_name().map(str::to_string).or_else(|| { - if let Some(backend_name) = predicted_backend_name.as_ref() { + provider.backend_name(context.services, effective_timeout).inspect(|name| { log::warn!( "Provider '{}' pending request returned no backend name; using predicted name '{}'", provider.provider_name(), - backend_name, + name, ); - } - predicted_backend_name.clone() + }) }); let Some(request_backend_name) = request_backend_name else { log::warn!( @@ -1611,6 +1781,10 @@ impl AuctionOrchestrator { )); continue; }; + // Post-launch defense: a resolved backend name already + // claimed by another provider would misattribute that + // provider's response, so fail this launch attributably + // instead of overwriting the correlation entry. if backend_to_provider.contains_key(&request_backend_name) { log::warn!( "Provider '{}' resolved backend name '{}' already belongs to another provider; skipping launch", @@ -1682,7 +1856,7 @@ impl AuctionOrchestrator { // some adapters, buffers the selected response body before returning. // Backend first-byte and between-bytes timeouts are capped to the // remaining auction budget in Phase 1. They are transport timers, not - // absolute wall-clock limits, so connection setup and byte-trickling + // absolute wall-clock limits, so connection setup and byte trickling // remain bounded operational risks rather than strict deadline proof. let mut remaining = pending_requests; @@ -1934,6 +2108,231 @@ impl AuctionOrchestrator { .collect() } + async fn dispatch_planned_auction( + &self, + request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> DispatchAuctionOutcome { + let plan = &self.plan; + if self.planned_providers.is_empty() { + return DispatchAuctionOutcome::NotStarted; + } + if self.planned_providers.len() > 1 + && !context.services.http_client().supports_concurrent_fanout() + { + log::warn!( + "{} planned auction providers configured on a runtime without concurrent fanout", + self.planned_providers.len() + ); + return DispatchAuctionOutcome::NotStarted; + } + + let auction_start = Instant::now(); + let routed = route_auction( + request.clone(), + context.request, + plan, + context.services.client_info().client_ip, + ); + let eligible_slots_by_provider = routed_eligible_slots(&routed); + let planned_unused_bidder_params = routed + .inputs() + .iter() + .map(|input| { + ( + input.provider_id().as_str().to_string(), + unused_bidder_params_count( + &plan + .provider(input.provider_id()) + .expect("should find routed provider in compiled plan") + .profile, + input, + ), + ) + }) + .collect::>(); + let planned_unroutable_bidder_count = routed.diagnostics().unroutable_bidder_count(); + // A zero request budget is terminal before signing admission. In the + // split path, signer initialization would otherwise read config and + // secret stores even though every provider is materialized as timeout. + let signer_result = if context.timeout_ms == 0 { + Ok(None) + } else { + plan.signing_enabled() + .then(|| RequestSigner::from_services(context.services)) + .transpose() + }; + let signer = match signer_result { + Ok(signer) => signer, + Err(error) => { + log::warn!("Planned auction signer initialization failed: {error:?}"); + let mut provider_responses = routed + .skipped_no_eligible_provider_ids() + .iter() + .map(|id| provider_skipped_response(id.as_str())) + .chain(routed.inputs().iter().map(|input| { + materialize_planned_response( + provider_launch_failed_response(input.provider_id().as_str(), 0), + unused_bidder_params_count( + &plan + .provider(input.provider_id()) + .expect("should find routed provider in compiled plan") + .profile, + input, + ), + ) + })) + .collect::>(); + let provider_order = plan + .providers() + .iter() + .enumerate() + .map(|(index, provider)| (provider.id.as_str(), index)) + .collect::>(); + provider_responses.sort_by_key(|response| { + provider_order + .get(response.provider.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + return DispatchAuctionOutcome::DispatchFailed { + request: request.clone(), + provider_responses, + fatal_admission_error: Some(error), + metadata: routing_metadata(planned_unroutable_bidder_count), + elapsed_ms: auction_start.elapsed().as_millis() as u64, + eligible_slots_by_provider, + }; + } + }; + let mut completed_responses = routed + .skipped_no_eligible_provider_ids() + .iter() + .map(|id| provider_skipped_response(id.as_str())) + .collect::>(); + let planned_provider_order = plan + .providers() + .iter() + .enumerate() + .map(|(index, provider)| (provider.id.as_str().to_string(), index)) + .collect::>(); + let mut pending_requests = Vec::new(); + let mut planned_backend_to_provider = HashMap::new(); + let mut reserved_backend_names = HashSet::new(); + let mut immediate_response_count = 0usize; + + for input in routed.inputs() { + let Some(provider) = self + .planned_providers + .iter() + .find(|provider| provider.provider_name() == input.provider_id().as_str()) + .cloned() + else { + completed_responses.push(provider_launch_failed_response( + input.provider_id().as_str(), + 0, + )); + continue; + }; + let logical_budget_ms = + remaining_budget_ms(auction_start, context.timeout_ms).min(provider.timeout_ms()); + if logical_budget_ms == 0 { + completed_responses.push(provider_timeout_response(provider.provider_name(), 0)); + continue; + } + let transport_timeout_ms = context + .services + .backend() + .canonicalize_transport_timeout_ms(logical_budget_ms, provider.timeout_ms()); + if transport_timeout_ms == 0 { + completed_responses.push(provider_timeout_response(provider.provider_name(), 0)); + continue; + } + let started_at = Instant::now(); + match provider + .request_bids_routed( + input, + &routed, + logical_budget_ms, + transport_timeout_ms, + signer.as_ref(), + context.services, + &mut reserved_backend_names, + ) + .await + { + Ok(ProviderRequestOutcome::Pending { + request: pending, + parse_state, + }) => { + let Some(backend_name) = pending.backend_name().map(str::to_string) else { + completed_responses.push(provider_launch_failed_response( + provider.provider_name(), + started_at.elapsed().as_millis() as u64, + )); + continue; + }; + match planned_backend_to_provider.entry(backend_name.clone()) { + Entry::Vacant(entry) => { + entry.insert(PlannedLaunchState { + provider, + started_at, + parse_state, + }); + pending_requests.push(pending.with_backend_name(backend_name)); + } + Entry::Occupied(_) => { + completed_responses.push(provider_launch_failed_response( + provider.provider_name(), + started_at.elapsed().as_millis() as u64, + )) + } + } + } + Ok(ProviderRequestOutcome::Immediate(response)) => { + immediate_response_count += 1; + completed_responses.push(canonical_provider_response( + provider.provider_name(), + response, + )); + } + Err(error) => { + log::warn!( + "Planned provider '{}' failed to dispatch: {error:?}", + provider.provider_name() + ); + completed_responses.push(provider_launch_failed_response( + provider.provider_name(), + started_at.elapsed().as_millis() as u64, + )); + } + } + } + + if pending_requests.is_empty() + && immediate_response_count == 0 + && completed_responses.is_empty() + { + return DispatchAuctionOutcome::NotStarted; + } + + DispatchAuctionOutcome::Dispatched(DispatchedAuction { + pending_requests, + backend_to_provider: HashMap::new(), + planned_backend_to_provider, + completed_responses, + auction_start, + timeout_ms: context.timeout_ms, + floor_prices: self.floor_prices_by_slot(request), + provider_request_context: Box::new(snapshot_context_request(context.request)), + request: request.clone(), + planned_unused_bidder_params, + planned_unroutable_bidder_count, + planned_provider_order, + eligible_slots_by_provider, + }) + } + /// Dispatch SSP bid requests without blocking WASM. /// /// Calls each enabled provider's [`AuctionProvider::request_bids`] (which @@ -1950,22 +2349,45 @@ impl AuctionOrchestrator { &self, request: &AuctionRequest, context: &AuctionContext<'_>, - ) -> DispatchedAuction { - let provider_names = self.config.provider_names(); - let auction_start = Instant::now(); - let mut backend_to_provider: HashMap = HashMap::new(); - let mut pending_requests: Vec = Vec::new(); - let mut completed_responses: Vec = Vec::new(); - let mut immediate_response_count = 0usize; + ) -> DispatchAuctionOutcome { + if !self.enabled { + return DispatchAuctionOutcome::NotStarted; + } + if !cfg!(test) || self.plan_backed { + return self.dispatch_planned_auction(request, context).await; + } + #[cfg(test)] + let provider_names = self + .config + .providers + .keys() + .map(super::plan::ProviderId::as_str) + .collect::>(); + #[cfg(test)] + if provider_names.is_empty() { + return DispatchAuctionOutcome::NotStarted; + } + #[cfg(test)] + let eligible_slots_by_provider = provider_names + .iter() + .map(|provider| { + ( + (*provider).to_string(), + self.eligible_slot_ids(provider, request), + ) + }) + .collect::(); + #[cfg(not(test))] + let eligible_slots_by_provider = EligibleSlotsByProvider::new(); // Mirror run_providers_parallel: reject multi-provider fan-out before // any request launches when the platform executes `send_async` eagerly // (e.g. Cloudflare Workers, Spin). Sequential execution would accrue // the sum of provider latencies before the origin fetch and then fail // collection with empty bids. - let fanout_supported = provider_names.len() <= 1 - || context.services.http_client().supports_concurrent_fanout(); - if !fanout_supported { + #[cfg(test)] + if provider_names.len() > 1 && !context.services.http_client().supports_concurrent_fanout() + { log::warn!( "{} auction providers configured, but this platform's HTTP client \ executes requests sequentially — skipping initial-page auction \ @@ -1973,20 +2395,46 @@ impl AuctionOrchestrator { concurrent fan-out support", provider_names.len(), ); - completed_responses.extend( - provider_names + return DispatchAuctionOutcome::DispatchFailed { + request: request.clone(), + provider_responses: provider_names .iter() - .map(|provider| provider_launch_failed_response(provider, 0)), - ); + .map(|provider| provider_launch_failed_response(provider, 0)) + .collect(), + fatal_admission_error: None, + metadata: HashMap::new(), + elapsed_ms: 0, + eligible_slots_by_provider, + }; } - for provider_name in provider_names.iter().filter(|_| fanout_supported) { - let provider = match self.providers.get(provider_name) { + let auction_start = Instant::now(); + #[cfg(test)] + let mut backend_to_provider: HashMap = HashMap::new(); + #[cfg(not(test))] + let backend_to_provider: HashMap = HashMap::new(); + #[cfg(test)] + let mut pending_requests: Vec = Vec::new(); + #[cfg(not(test))] + let pending_requests: Vec = Vec::new(); + #[cfg(test)] + let mut completed_responses: Vec = Vec::new(); + #[cfg(not(test))] + let completed_responses: Vec = Vec::new(); + #[cfg(test)] + let mut immediate_response_count = 0usize; + #[cfg(not(test))] + let immediate_response_count = 0usize; + + #[cfg(test)] + for provider_name in &provider_names { + let provider = match self.providers.get(*provider_name) { Some(p) => p, None => { // lgtm[rust/cleartext-logging] // The provider name is a static config identifier (e.g. "prebid"), not a secret. log::warn!("Provider '{}' not registered, skipping", provider_name); + completed_responses.push(provider_launch_failed_response(provider_name, 0)); continue; } }; @@ -2020,17 +2468,17 @@ impl AuctionOrchestrator { continue; } - // Do not require a backend name before dispatch: an immediate - // provider intentionally has none. Guard predicted names when - // available; pending requests without either name fail below. - let predicted_backend_name = provider.backend_name(context.services, effective_timeout); - if let Some(backend_name) = predicted_backend_name.as_ref() - && backend_to_provider.contains_key(backend_name) + // Pre-launch guard: skip before `request_bids` fires the outbound + // send when another provider this auction already claimed the + // predicted backend name (see the parallel path). Dropping the + // pending handle afterwards would not retract the request. + if let Some(predicted) = provider.backend_name(context.services, effective_timeout) + && backend_to_provider.contains_key(&predicted) { log::warn!( "Provider '{}' predicted backend name '{}' already belongs to another provider; skipping dispatch", provider.provider_name(), - backend_name, + predicted, ); completed_responses .push(provider_launch_failed_response(provider.provider_name(), 0)); @@ -2052,16 +2500,10 @@ impl AuctionOrchestrator { request: pending, parse_state, }) => { - let backend_name = pending.backend_name().map(str::to_string).or_else(|| { - if let Some(backend_name) = predicted_backend_name.as_ref() { - log::warn!( - "Provider '{}' pending request returned no backend name; using predicted name '{}'", - provider.provider_name(), - backend_name, - ); - } - predicted_backend_name.clone() - }); + let backend_name = pending + .backend_name() + .map(str::to_string) + .or_else(|| provider.backend_name(context.services, effective_timeout)); let Some(backend_name) = backend_name else { log::warn!( "Provider '{}' pending request has no backend name; response cannot be correlated", @@ -2073,9 +2515,14 @@ impl AuctionOrchestrator { )); continue; }; + // Post-launch defense: a resolved backend name already + // claimed by another provider would misattribute that + // provider's response, so fail this dispatch attributably + // instead of overwriting the correlation entry. if backend_to_provider.contains_key(&backend_name) { log::warn!( - "Provider '{}' resolved backend name '{}' already belongs to another provider; skipping dispatch", + "Provider '{}' resolved to backend name '{}' already claimed by another \ + provider this auction; skipping launch to avoid response misattribution", provider.provider_name(), backend_name, ); @@ -2125,6 +2572,21 @@ impl AuctionOrchestrator { } } + if pending_requests.is_empty() && immediate_response_count == 0 { + return if completed_responses.is_empty() { + DispatchAuctionOutcome::NotStarted + } else { + DispatchAuctionOutcome::DispatchFailed { + request: request.clone(), + provider_responses: completed_responses, + fatal_admission_error: None, + metadata: HashMap::new(), + elapsed_ms: auction_start.elapsed().as_millis() as u64, + eligible_slots_by_provider, + } + }; + } + log::info!( "Dispatched {} SSP request(s) with {} immediate response(s) (timeout: {}ms)", pending_requests.len(), @@ -2132,7 +2594,7 @@ impl AuctionOrchestrator { context.timeout_ms ); - DispatchedAuction { + DispatchAuctionOutcome::Dispatched(DispatchedAuction { pending_requests, backend_to_provider, planned_backend_to_provider: HashMap::new(), @@ -2142,7 +2604,11 @@ impl AuctionOrchestrator { floor_prices: self.floor_prices_by_slot(request), provider_request_context: Box::new(snapshot_context_request(context.request)), request: request.clone(), - } + planned_unused_bidder_params: HashMap::new(), + planned_unroutable_bidder_count: 0, + planned_provider_order: HashMap::new(), + eligible_slots_by_provider, + }) } /// Collect bid responses from a previously-dispatched auction. @@ -2174,6 +2640,7 @@ impl AuctionOrchestrator { planned_unused_bidder_params, planned_unroutable_bidder_count, planned_provider_order, + eligible_slots_by_provider, } = dispatched; log::info!( @@ -2265,31 +2732,45 @@ impl AuctionOrchestrator { ) .await { - Ok(auction_response) => { - log::info!( - "Provider '{}' returned {} bids ({}ms)", - auction_response.provider, - auction_response.bids.len(), - auction_response.response_time_ms - ); - responses.push(canonical_provider_response( - &state.provider_name, - auction_response, - )); - } - Err(e) => { - log::warn!( - "Provider '{}' parse failed: {:?}", - state.provider_name, - e - ); - responses.push(provider_error_response( - &state.provider_name, - response_time_ms, - ERROR_TYPE_PARSE_RESPONSE, - &e, - )); - } + Ok(auction_response) => responses.push(canonical_provider_response( + &state.provider_name, + auction_response, + )), + Err(error) => responses.push(provider_error_response( + &state.provider_name, + response_time_ms, + ERROR_TYPE_PARSE_RESPONSE, + &error, + )), + } + } else if let Some(state) = planned_backend_to_provider.remove(&backend_name) { + let response_time_ms = state.started_at.elapsed().as_millis() as u64; + if deadline_policy.rejects_late_completion(auction_start, timeout_ms) { + responses.push(provider_timeout_response( + state.provider.provider_name(), + response_time_ms, + )); + continue; + } + match state + .provider + .parse_response_with_state( + platform_response, + response_time_ms, + state.parse_state.as_deref(), + ) + .await + { + Ok(response) => responses.push(canonical_provider_response( + state.provider.provider_name(), + response, + )), + Err(error) => responses.push(provider_error_response( + state.provider.provider_name(), + response_time_ms, + ERROR_TYPE_PARSE_RESPONSE, + &error, + )), } } else { log::warn!( @@ -2363,24 +2844,65 @@ impl AuctionOrchestrator { )); } backend_to_provider.clear(); - let normalized = self.normalize_provider_responses(&request, &mut responses); + for state in planned_backend_to_provider.into_values() { + responses.push(provider_timeout_response( + state.provider.provider_name(), + state.started_at.elapsed().as_millis() as u64, + )); + } + for response in &mut responses { + if let Some(&count) = planned_unused_bidder_params.get(response.provider.as_str()) { + *response = materialize_planned_response(response.clone(), count); + } + } + if !planned_provider_order.is_empty() { + responses.sort_by_key(|response| { + planned_provider_order + .get(response.provider.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + } + + let normalized = self.normalize_provider_responses_with_eligibility( + &request, + &mut responses, + &eligible_slots_by_provider, + ); + + #[cfg(not(test))] + let mediator = self.mediator.as_ref(); + #[cfg(test)] + let mediator = self.mediator.as_ref().or_else(|| { + self.config + .mediator + .as_ref() + .and_then(|name| self.providers.get(name)) + }); let mut mediation_failed = false; let mut mediator_response = None; let mut mediated_winners = None; - if let Some(mediator_name) = &self.config.mediator { - if let Some(mediator) = self.providers.get(mediator_name.as_str()) { - let remaining = remaining_budget_ms(auction_start, timeout_ms); - if remaining == 0 { + if let Some(mediator) = mediator { + let remaining = remaining_budget_ms(auction_start, timeout_ms); + let logical_budget_ms = remaining.min(mediator.timeout_ms()); + if logical_budget_ms == 0 { + log::warn!( + "A_deadline exhausted before mediator '{}' — using direct fallback", + mediator.provider_name(), + ); + mediation_failed = true; + } else { + let transport_timeout_ms = services + .backend() + .canonicalize_transport_timeout_ms(logical_budget_ms, mediator.timeout_ms()); + if transport_timeout_ms == 0 { log::warn!( - "A_deadline exhausted before mediator '{}' — using direct fallback", + "Mediator '{}' transport budget canonicalized to zero — using direct fallback", mediator.provider_name(), ); mediation_failed = true; } else { - let mediator_timeout = services - .backend() - .canonicalize_transport_timeout_ms(remaining, mediator.timeout_ms()); let mediator_start = Instant::now(); let placeholder = http::Request::builder() .uri(crate::auction::types::MEDIATOR_PLACEHOLDER_URL) @@ -2389,46 +2911,75 @@ impl AuctionOrchestrator { let mediator_context = AuctionContext { settings: context.settings, request: &placeholder, - timeout_ms: mediator_timeout, + timeout_ms: logical_budget_ms, + transport_timeout_ms, provider_responses: Some(&responses), services: context.services, }; - let raw_response = - match mediator.request_bids(&request, &mediator_context).await { - Ok(ProviderRequestOutcome::Immediate(response)) => Some(response), - Ok(ProviderRequestOutcome::Pending { - request: pending, - parse_state, - }) => match services.http_client().wait(pending).await { - Ok(platform_response) => mediator - .parse_response_with_context_and_state( - platform_response, - mediator_start.elapsed().as_millis() as u64, - &request, - &mediator_context, - parse_state.as_deref(), - ) - .await - .inspect_err(|error| { - log::warn!( - "Mediator '{}' parse failed: {error:?}", - mediator.provider_name() - ); - }) - .ok(), - Err(error) => { - log::warn!("Mediator request failed: {error:?}"); + let raw_response = match mediator + .request_bids(&request, &mediator_context) + .await + { + Ok(ProviderRequestOutcome::Immediate(response)) => Some( + canonical_provider_response(mediator.provider_name(), response), + ), + Ok(ProviderRequestOutcome::Pending { + request: pending, + parse_state, + }) => match services.http_client().wait(pending).await { + Ok(platform_response) => { + let response_time_ms = mediator_start.elapsed().as_millis() as u64; + if deadline_policy + .rejects_late_completion(auction_start, timeout_ms) + { + log::warn!( + "Mediator '{}' completed after the hard auction deadline; using direct fallback ({}ms)", + mediator.provider_name(), + response_time_ms, + ); None + } else { + mediator + .parse_response_with_context_and_state( + platform_response, + response_time_ms, + &request, + &mediator_context, + parse_state.as_deref(), + ) + .await + .inspect_err(|error| { + log::warn!( + "Mediator '{}' parse failed: {error:?}", + mediator.provider_name(), + ); + }) + .ok() + .map(|response| { + canonical_provider_response( + mediator.provider_name(), + response, + ) + }) } - }, + } Err(error) => { log::warn!( - "Mediator '{}' failed to dispatch: {error:?}", - mediator.provider_name() + "Mediator '{}' request failed: {error:?}", + mediator.provider_name(), ); None } - }; + }, + Err(error) => { + log::warn!( + "Mediator '{}' failed to dispatch: {error:?}", + mediator.provider_name(), + ); + None + } + }; + if let Some(raw_response) = raw_response { mediator_response = Some(raw_response.clone()); match Self::resolve_mediator_candidates( @@ -2445,15 +2996,18 @@ impl AuctionOrchestrator { Some(self.apply_floor_prices(selected, &floor_prices)); mediator_response = Some(resolved); } - Err(()) => mediation_failed = true, + Err(()) => { + log::warn!( + "Mediator '{}' returned invalid candidate provenance", + mediator.provider_name(), + ); + mediation_failed = true; + } } } else { mediation_failed = true; } } - } else { - log::warn!("Mediator '{}' not registered", mediator_name); - mediation_failed = true; } } @@ -2501,11 +3055,12 @@ pub struct OrchestrationResult { } impl OrchestrationResult { - fn no_bid() -> Self { + pub(crate) fn no_bid(request: &AuctionRequest, reason: AuctionSlotFailureReason) -> Self { Self { provider_responses: Vec::new(), mediator_response: None, winning_bids: HashMap::new(), + decision_set: AuctionDecisionSetV1::failed(request, reason), total_time_ms: 0, metadata: HashMap::new(), } @@ -2543,9 +3098,14 @@ mod tests { use web_time::Instant; use crate::auction::config::AuctionConfig; + use crate::auction::orchestrator::DispatchAuctionOutcome; + use crate::auction::plan::{ + AuctionPlan, AuctionPlanConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, + }; use crate::auction::provider::{ - AuctionProvider, ProviderRequestOutcome, ProviderSlotDisposition, + AuctionProvider, GenericOpenRtbProvider, ProviderRequestOutcome, ProviderSlotDisposition, }; + use crate::auction::routing::{RoutingDiagnostics, route_auction}; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionDropReason, @@ -2566,254 +3126,600 @@ mod tests { }; use crate::test_support::tests::crate_test_settings_str; use error_stack::{Report, ResultExt}; - use std::collections::{HashMap, HashSet}; + use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; - use super::{AuctionIdentityGenerator, AuctionOrchestrator}; - - // --------------------------------------------------------------------------- - // Minimal test double for AuctionProvider - // --------------------------------------------------------------------------- + use super::{ + AuctionIdentityGenerator, AuctionOrchestrator, AuctionOrchestratorHarness, + DispatchedAuction, ERROR_TYPE_TIMEOUT, OrchestrationResult, + }; - struct StubAuctionProvider { - name: &'static str, - backend: &'static str, + fn expect_dispatched(outcome: DispatchAuctionOutcome) -> DispatchedAuction { + match outcome { + DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, + DispatchAuctionOutcome::NotStarted => panic!("auction should dispatch"), + DispatchAuctionOutcome::DispatchFailed { .. } => { + panic!("auction dispatch should not fail") + } + } } - #[async_trait::async_trait(?Send)] - impl AuctionProvider for StubAuctionProvider { - fn provider_name(&self) -> &str { - self.name + fn planned_config(providers: &[(&str, RoutingMode)], signing: bool) -> AuctionPlanConfig { + AuctionPlanConfig { + timeout_ms: 777, + providers: providers + .iter() + .map(|(id, routing)| { + ( + ProviderId::from_str(id).expect("should parse fictional provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "standard".to_string(), + endpoint: "https://example.test/openrtb".to_string(), + timeout_ms: Some(1_000), + routing: *routing, + notifications: Default::default(), + profile_config: serde_json::json!({}), + }, + ) + }) + .collect(), + bidders: BTreeMap::new(), + mediator: None, + request_signing: signing.then(|| crate::settings::RequestSigning { + enabled: true, + config_store_id: "fictional-config-store".to_string(), + secret_store_id: "fictional-secret-store".to_string(), + }), } + } - async fn request_bids( - &self, - _request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - let req = PlatformHttpRequest::new( - http::Request::builder() - .method("POST") - .uri("https://example.com/bid") - .body(edgezero_core::body::Body::empty()) - .expect("should build stub bid request"), - self.backend, - ); - context - .services - .http_client() - .send_async(req) - .await - .change_context(TrustedServerError::Auction { - message: "stub launch failed".to_string(), + fn planned_prebid_config( + providers: &[(&str, serde_json::Value, NotificationConfig)], + ) -> AuctionPlanConfig { + AuctionPlanConfig { + timeout_ms: 777, + providers: providers + .iter() + .map(|(id, profile_config, notifications)| { + ( + ProviderId::from_str(id).expect("should parse fictional provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "prebid-server".to_string(), + endpoint: format!("https://{id}.example.test/openrtb"), + timeout_ms: Some(1_000), + routing: RoutingMode::AllEligible, + notifications: notifications.clone(), + profile_config: profile_config.clone(), + }, + ) }) - .map(ProviderRequestOutcome::pending) + .collect(), + bidders: BTreeMap::new(), + mediator: None, + request_signing: None, } + } - async fn parse_response( - &self, - _response: PlatformResponse, - response_time_ms: u64, - ) -> Result> { - Ok(AuctionResponse::success( - self.name, - vec![], - response_time_ms, - )) - } + fn planned_aps_config() -> AuctionPlanConfig { + planned_aps_instances_config(&[( + "aps-instance", + serde_json::json!({"account_id": "example-account"}), + NotificationConfig::default(), + )]) + } - async fn parse_response_with_context( - &self, - response: PlatformResponse, - response_time_ms: u64, - _request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - let referer = context - .request - .headers() - .get(http::header::REFERER) - .and_then(|value| value.to_str().ok()); - Ok(self - .parse_response(response, response_time_ms) - .await? - .with_metadata("context_referer", serde_json::json!(referer)) - .with_metadata("context_timeout_ms", serde_json::json!(context.timeout_ms))) + fn planned_aps_instances_config( + providers: &[(&str, serde_json::Value, NotificationConfig)], + ) -> AuctionPlanConfig { + AuctionPlanConfig { + timeout_ms: 777, + providers: providers + .iter() + .map(|(id, profile_config, notifications)| { + ( + ProviderId::from_str(id).expect("should parse fictional provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/e/pb/bid".to_string(), + timeout_ms: Some(1_000), + routing: RoutingMode::AllEligible, + notifications: notifications.clone(), + profile_config: profile_config.clone(), + }, + ) + }) + .collect(), + bidders: BTreeMap::new(), + mediator: None, + request_signing: None, } + } - fn timeout_ms(&self) -> u32 { - 125 + fn planned_request() -> AuctionRequest { + AuctionRequest { + id: "fictional-auction".to_string(), + slots: vec![AdSlot { + id: "fictional-slot".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: Some(1.0), + targeting: HashMap::new(), + bidders: HashMap::new(), + }], + publisher: PublisherInfo { + domain: "publisher.example".to_string(), + page_url: Some("https://publisher.example/article".to_string()), + }, + user: UserInfo { + id: None, + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), } + } - fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { - Some(self.backend.to_string()) - } + #[tokio::test] + async fn disabled_from_plan_is_a_no_work_kill_switch_for_sync_and_split_paths() { + let plan = Arc::new( + AuctionPlan::compile(planned_config( + &[("provider-a", RoutingMode::AllEligible)], + false, + )) + .expect("should compile plan") + .with_enabled(false), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let http = Arc::new(StubHttpClient::new()); + let services = build_services_with_http_client(Arc::clone(&http) as Arc<_>); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + let request = planned_request(); + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("disabled auction should complete as no-bid"); + assert!(result.provider_responses.is_empty()); + assert!(result.winning_bids.is_empty()); + assert!(matches!( + orchestrator.dispatch_auction(&request, &context).await, + DispatchAuctionOutcome::NotStarted + )); + assert!(http.recorded_backend_names().is_empty()); } - struct DeadlineBidProvider { - name: &'static str, - backend: &'static str, + #[tokio::test] + async fn enabled_empty_plan_is_successful_no_bid_without_dispatch() { + let plan = Arc::new( + AuctionPlan::compile(planned_config(&[], false)).expect("should compile empty plan"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let http = Arc::new(StubHttpClient::new()); + let services = build_services_with_http_client(Arc::clone(&http) as Arc<_>); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + let request = planned_request(); + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("empty auction should complete as no-bid"); + assert!(result.provider_responses.is_empty()); + assert!(result.winning_bids.is_empty()); + assert!(matches!( + orchestrator.dispatch_auction(&request, &context).await, + DispatchAuctionOutcome::NotStarted + )); + assert!(http.recorded_backend_names().is_empty()); } - #[async_trait::async_trait(?Send)] - impl AuctionProvider for DeadlineBidProvider { - fn provider_name(&self) -> &str { - self.name - } + #[tokio::test] + async fn all_skipped_from_plan_completes_with_routing_metadata_in_sync_and_split_paths() { + for split in [false, true] { + let plan = Arc::new( + AuctionPlan::compile(planned_config(&[("skipped", RoutingMode::Explicit)], false)) + .expect("should compile all-skipped plan"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); + let http = Arc::new(StubHttpClient::new()); + let services = build_services_with_http_client(Arc::clone(&http) as Arc<_>); + let settings = create_test_settings(); + let inbound = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &inbound, + timeout_ms: 777, + transport_timeout_ms: 777, + provider_responses: None, + services: &services, + }; + let request = planned_request(); - async fn request_bids( - &self, - _request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - let request = PlatformHttpRequest::new( - http::Request::builder() - .method("POST") - .uri("https://example.com/bid") - .body(edgezero_core::body::Body::empty()) - .expect("should build deadline test request"), - self.backend, + let result = if split { + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("all-skipped auction should produce a completed dispatch token"); + }; + orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await + } else { + orchestrator + .run_auction(&request, &context) + .await + .expect("all-skipped auction should complete") + }; + + assert_eq!(result.provider_responses.len(), 1); + assert_eq!( + result.provider_responses[0].metadata["routing"]["skipped_no_eligible_slots"], + true ); - context - .services - .http_client() - .send_async(request) - .await - .change_context(TrustedServerError::Auction { - message: "deadline test provider launch failed".to_string(), - }) - .map(ProviderRequestOutcome::pending) + assert!(result.metadata.contains_key("routing")); + assert!(http.recorded_backend_names().is_empty()); } + } - async fn parse_response( - &self, - _response: PlatformResponse, - response_time_ms: u64, - ) -> Result> { - Ok(AuctionResponse::success( - self.name, - vec![auction_bid(self.name, 3.0)], - response_time_ms, - )) + struct NamingBackend { + policy: BackendNamingPolicy, + predicted: AtomicUsize, + ensured: AtomicUsize, + specs: Mutex>, + fail_ensure_for: Mutex>, + } + + impl NamingBackend { + fn new(policy: BackendNamingPolicy) -> Self { + Self { + policy, + predicted: AtomicUsize::new(0), + ensured: AtomicUsize::new(0), + specs: Mutex::new(Vec::new()), + fail_ensure_for: Mutex::new(HashSet::new()), + } } - fn timeout_ms(&self) -> u32 { - 1_000 + fn fail_ensure_for(&self, provider_id: &str) { + self.fail_ensure_for + .lock() + .expect("should lock failing provider IDs") + .insert(provider_id.to_string()); } - fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { - Some(self.backend.to_string()) + fn name(&self, spec: &PlatformBackendSpec) -> Result> { + self.policy + .predict(spec) + .map(|prediction| prediction.name) + .change_context(PlatformError::Backend) } } - type RecordedMediatorBudgets = Arc>>; + impl PlatformBackend for NamingBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + self.policy + } - struct DeadlineRecordingMediator { - launches: Arc, - budgets: Option, + fn predict_name( + &self, + spec: &PlatformBackendSpec, + ) -> Result> { + self.predicted.fetch_add(1, Ordering::Relaxed); + self.name(spec) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.ensured.fetch_add(1, Ordering::Relaxed); + if spec.discriminator.as_deref().is_some_and(|provider_id| { + self.fail_ensure_for + .lock() + .expect("should lock failing provider IDs") + .contains(provider_id) + }) { + return Err(Report::new(PlatformError::Backend)); + } + self.specs + .lock() + .expect("should lock planned backend specs") + .push(spec.clone()); + self.name(spec) + } } - struct PendingDeadlineMediator; + struct ZeroCanonicalBackend { + predicted: AtomicUsize, + ensured: AtomicUsize, + } - #[async_trait::async_trait(?Send)] - impl AuctionProvider for PendingDeadlineMediator { - fn provider_name(&self) -> &str { - "pending-deadline-mediator" + impl ZeroCanonicalBackend { + fn new() -> Self { + Self { + predicted: AtomicUsize::new(0), + ensured: AtomicUsize::new(0), + } } + } - async fn request_bids( - &self, - _request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - let request = PlatformHttpRequest::new( - http::Request::builder() - .method("POST") - .uri("https://example.com/mediate") - .body(edgezero_core::body::Body::empty()) - .expect("should build pending mediator request"), - "pending-mediator-backend", - ); - context - .services - .http_client() - .send_async(request) - .await - .change_context(TrustedServerError::Auction { - message: "pending mediator launch failed".to_string(), - }) - .map(ProviderRequestOutcome::pending) + impl PlatformBackend for ZeroCanonicalBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + BackendNamingPolicy::Fastly } - async fn parse_response( + fn predict_name( &self, - _response: PlatformResponse, - response_time_ms: u64, - ) -> Result> { - Ok(AuctionResponse::success( - self.provider_name(), - vec![auction_bid("mediated", 9.0)], - response_time_ms, - )) + _spec: &PlatformBackendSpec, + ) -> Result> { + self.predicted.fetch_add(1, Ordering::Relaxed); + Ok("zero-canonical-backend".to_string()) } - fn timeout_ms(&self) -> u32 { - 1_000 + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + self.ensured.fetch_add(1, Ordering::Relaxed); + Ok("zero-canonical-backend".to_string()) } - fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { - Some("pending-mediator-backend".to_string()) + fn canonicalize_transport_timeout_ms( + &self, + _remaining_ms: u32, + _configured_ms: u32, + ) -> u32 { + 0 } } - #[async_trait::async_trait(?Send)] - impl AuctionProvider for DeadlineRecordingMediator { - fn provider_name(&self) -> &str { - "deadline-mediator" + struct CollidingBackend; + + impl PlatformBackend for CollidingBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + BackendNamingPolicy::Axum } - async fn request_bids( + fn predict_name( &self, - _request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - self.launches.fetch_add(1, Ordering::Relaxed); - if let Some(budgets) = &self.budgets { - budgets - .lock() - .expect("should lock mediator budgets") - .push((context.timeout_ms, context.transport_timeout_ms)); + _spec: &PlatformBackendSpec, + ) -> Result> { + Ok("colliding-backend".to_string()) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Ok("colliding-backend".to_string()) + } + } + + struct FailingCountingConfigStore { + reads: AtomicUsize, + } + + impl PlatformConfigStore for FailingCountingConfigStore { + fn get( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result> { + self.reads.fetch_add(1, Ordering::Relaxed); + Err(Report::new(PlatformError::ConfigStore)) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + struct CountingConfigStore { + reads: AtomicUsize, + current_kid: String, + delay: Duration, + } + + impl PlatformConfigStore for CountingConfigStore { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + self.reads.fetch_add(1, Ordering::Relaxed); + if !self.delay.is_zero() { + std::thread::sleep(self.delay); } - Ok(ProviderRequestOutcome::Immediate(AuctionResponse::no_bid( - self.provider_name(), - 0, - ))) + (key == "current-kid") + .then(|| self.current_kid.clone()) + .ok_or_else(|| Report::new(PlatformError::ConfigStore)) } - async fn parse_response( + fn put( &self, - _response: PlatformResponse, - _response_time_ms: u64, - ) -> Result> { - panic!("immediate mediator response should not be parsed"); + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) } - fn timeout_ms(&self) -> u32 { - 1_000 + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + struct CountingSecretStore { + reads: AtomicUsize, + key: Vec, + } + + impl PlatformSecretStore for CountingSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result, Report> { + self.reads.fetch_add(1, Ordering::Relaxed); + Ok(self.key.clone()) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + struct OuterSelectErrorHttpClient { + inner: StubHttpClient, + selected_pending: AtomicUsize, + } + + impl OuterSelectErrorHttpClient { + fn new() -> Self { + Self { + inner: StubHttpClient::new(), + selected_pending: AtomicUsize::new(0), + } + } + + fn push_response(&self, status: u16, body: Vec) { + self.inner.push_response(status, body); + } + } + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for OuterSelectErrorHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.inner.send(request).await + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.inner.send_async(request).await + } + + async fn select( + &self, + pending_requests: Vec, + ) -> Result> { + self.selected_pending + .store(pending_requests.len(), Ordering::Relaxed); + Err(Report::new(PlatformError::HttpClient)) + } + } + + struct UnusedSecretStore; + + impl PlatformSecretStore for UnusedSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result, Report> { + panic!("signing key should not be read after current-kid failure") + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) } } - struct RecordingTimeoutProvider { + // --------------------------------------------------------------------------- + // Minimal test double for AuctionProvider + // --------------------------------------------------------------------------- + + struct StubAuctionProvider { name: &'static str, backend: &'static str, configured_timeout_ms: u32, - predicted: Arc>>, - requested: Arc>>, + predicted_timeouts: Option>>>, + request_timeouts: Option>>>, + } + + impl StubAuctionProvider { + fn new(name: &'static str, backend: &'static str) -> Self { + Self { + name, + backend, + configured_timeout_ms: 125, + predicted_timeouts: None, + request_timeouts: None, + } + } + + fn recording( + name: &'static str, + backend: &'static str, + configured_timeout_ms: u32, + predicted_timeouts: Arc>>, + request_timeouts: Arc>>, + ) -> Self { + Self { + name, + backend, + configured_timeout_ms, + predicted_timeouts: Some(predicted_timeouts), + request_timeouts: Some(request_timeouts), + } + } + + fn record(slot: &Option>>>, timeout_ms: u32) { + if let Some(observed) = slot { + observed + .lock() + .expect("should lock observed timeouts") + .push(timeout_ms); + } + } } #[async_trait::async_trait(?Send)] - impl AuctionProvider for RecordingTimeoutProvider { + impl AuctionProvider for StubAuctionProvider { fn provider_name(&self) -> &str { self.name } @@ -2823,25 +3729,22 @@ mod tests { _request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { - self.requested - .lock() - .expect("should lock requested timeouts") - .push(context.transport_timeout_ms); - let request = PlatformHttpRequest::new( + Self::record(&self.request_timeouts, context.transport_timeout_ms); + let req = PlatformHttpRequest::new( http::Request::builder() .method("POST") .uri("https://example.com/bid") .body(edgezero_core::body::Body::empty()) - .expect("should build recording request"), + .expect("should build stub bid request"), self.backend, ); context .services .http_client() - .send_async(request) + .send_async(req) .await .change_context(TrustedServerError::Auction { - message: "recording launch failed".to_string(), + message: "stub launch failed".to_string(), }) .map(ProviderRequestOutcome::pending) } @@ -2858,27 +3761,42 @@ mod tests { )) } + async fn parse_response_with_context( + &self, + response: PlatformResponse, + response_time_ms: u64, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let referer = context + .request + .headers() + .get(http::header::REFERER) + .and_then(|value| value.to_str().ok()); + Ok(self + .parse_response(response, response_time_ms) + .await? + .with_metadata("context_referer", serde_json::json!(referer)) + .with_metadata("context_timeout_ms", serde_json::json!(context.timeout_ms))) + } + fn timeout_ms(&self) -> u32 { self.configured_timeout_ms } fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option { - self.predicted - .lock() - .expect("should lock predicted timeouts") - .push(timeout_ms); + Self::record(&self.predicted_timeouts, timeout_ms); Some(self.backend.to_string()) } } - struct DivergentBackendProvider { + struct DeadlineBidProvider { name: &'static str, - predicted: &'static str, - resolved: &'static str, + backend: &'static str, } #[async_trait::async_trait(?Send)] - impl AuctionProvider for DivergentBackendProvider { + impl AuctionProvider for DeadlineBidProvider { fn provider_name(&self) -> &str { self.name } @@ -2893,8 +3811,8 @@ mod tests { .method("POST") .uri("https://example.com/bid") .body(edgezero_core::body::Body::empty()) - .expect("should build divergent request"), - self.resolved, + .expect("should build deadline test request"), + self.backend, ); context .services @@ -2902,7 +3820,7 @@ mod tests { .send_async(request) .await .change_context(TrustedServerError::Auction { - message: "divergent launch failed".to_string(), + message: "deadline test provider launch failed".to_string(), }) .map(ProviderRequestOutcome::pending) } @@ -2912,90 +3830,263 @@ mod tests { _response: PlatformResponse, response_time_ms: u64, ) -> Result> { + let mut bid = auction_bid(self.name, 3.0); + bid.bid_id = Some(format!("{}-bid", self.name)); Ok(AuctionResponse::success( self.name, - vec![], + vec![bid], response_time_ms, )) } fn timeout_ms(&self) -> u32 { - 2000 + 1_000 } fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { - Some(self.predicted.to_string()) + Some(self.backend.to_string()) } } - struct CanonicalTimeoutBackend { - canonical_ms: u32, - calls: Arc>>, + type RecordedMediatorBudgets = Arc>>; + + struct DeadlineRecordingMediator { + launches: Arc, + budgets: Option, } - impl PlatformBackend for CanonicalTimeoutBackend { - fn naming_policy(&self) -> crate::platform::BackendNamingPolicy { - crate::platform::BackendNamingPolicy::Axum + struct PendingDeadlineMediator; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for PendingDeadlineMediator { + fn provider_name(&self) -> &str { + "pending-deadline-mediator" } - fn predict_name( + async fn request_bids( &self, - _spec: &PlatformBackendSpec, - ) -> Result> { - Ok("stub-backend".to_string()) + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let req = PlatformHttpRequest::new( + http::Request::builder() + .method("POST") + .uri("https://example.com/mediate") + .body(edgezero_core::body::Body::empty()) + .expect("should build pending mediator request"), + "pending-mediator-backend", + ); + context + .services + .http_client() + .send_async(req) + .await + .change_context(TrustedServerError::Auction { + message: "pending mediator launch failed".to_string(), + }) + .map(ProviderRequestOutcome::pending) } - fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { - Ok("stub-backend".to_string()) + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("pending deadline mediator should parse with provider context") } - fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { - self.calls - .lock() - .expect("should lock canonicalization calls") - .push((remaining_ms, configured_ms)); - self.canonical_ms + async fn parse_response_with_context( + &self, + _response: PlatformResponse, + response_time_ms: u64, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let mut selection = context + .provider_responses + .and_then(|responses| responses.first()) + .and_then(|response| response.bids.first()) + .cloned() + .expect("pending deadline mediator should receive one candidate"); + selection.price = Some(9.0); + Ok(AuctionResponse::success( + self.provider_name(), + vec![selection], + response_time_ms, + )) } - } - fn recording_provider( - name: &'static str, - backend: &'static str, - configured_timeout_ms: u32, - predicted: &Arc>>, - requested: &Arc>>, - ) -> RecordingTimeoutProvider { - RecordingTimeoutProvider { - name, - backend, - configured_timeout_ms, - predicted: Arc::clone(predicted), - requested: Arc::clone(requested), + fn timeout_ms(&self) -> u32 { + 1_000 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some("pending-mediator-backend".to_string()) } } - /// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring - /// `adserver_mock`), while its context-free parse does not. Lets a test prove - /// the synchronous mediation path calls `parse_response_with_context`. - struct CacheRestoringMediator; + #[async_trait::async_trait(?Send)] + impl AuctionProvider for DeadlineRecordingMediator { + fn provider_name(&self) -> &str { + "deadline-mediator" + } - fn auction_bid(bidder: &str, price: f64) -> Bid { - let renderer = (bidder == "aps").then(|| { - BidRenderSourceV1::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "aps-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 { - slot_id: "slot-1".to_string(), - candidate_id: None, + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + self.launches.fetch_add(1, Ordering::Relaxed); + if let Some(budgets) = &self.budgets { + budgets + .lock() + .expect("should lock mediator budgets") + .push((context.timeout_ms, context.transport_timeout_ms)); + } + Ok(ProviderRequestOutcome::Immediate(AuctionResponse::no_bid( + self.provider_name(), + 0, + ))) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("immediate mediator response should not be parsed"); + } + + fn timeout_ms(&self) -> u32 { + 1_000 + } + } + + struct DivergentBackendProvider { + name: &'static str, + predicted: &'static str, + resolved: &'static str, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for DivergentBackendProvider { + fn provider_name(&self) -> &str { + self.name + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let request = PlatformHttpRequest::new( + http::Request::builder() + .method("POST") + .uri("https://example.com/bid") + .body(edgezero_core::body::Body::empty()) + .expect("should build divergent request"), + self.resolved, + ); + context + .services + .http_client() + .send_async(request) + .await + .change_context(TrustedServerError::Auction { + message: "divergent launch failed".to_string(), + }) + .map(ProviderRequestOutcome::pending) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success( + self.name, + vec![], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + Some(self.predicted.to_string()) + } + } + + struct CanonicalTimeoutBackend { + canonical_ms: u32, + calls: Arc>>, + } + + impl PlatformBackend for CanonicalTimeoutBackend { + fn naming_policy(&self) -> crate::platform::BackendNamingPolicy { + crate::platform::BackendNamingPolicy::Axum + } + + fn predict_name( + &self, + _spec: &PlatformBackendSpec, + ) -> Result> { + Ok("stub-backend".to_string()) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Ok("stub-backend".to_string()) + } + + fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { + self.calls + .lock() + .expect("should lock canonicalization calls") + .push((remaining_ms, configured_ms)); + self.canonical_ms + } + } + + fn recording_provider( + name: &'static str, + backend: &'static str, + configured_timeout_ms: u32, + predicted: &Arc>>, + requested: &Arc>>, + ) -> StubAuctionProvider { + StubAuctionProvider::recording( + name, + backend, + configured_timeout_ms, + Arc::clone(predicted), + Arc::clone(requested), + ) + } + + /// Mediator whose context-aware parse restores `nurl`/`ad_id` (mirroring + /// `adserver_mock`), while its context-free parse does not. Lets a test prove + /// the synchronous mediation path calls `parse_response_with_context`. + struct CacheRestoringMediator; + + fn auction_bid(bidder: &str, price: f64) -> Bid { + let renderer = (bidder == "aps").then(|| { + BidRenderSourceV1::Aps(ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "aps-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 { + slot_id: "slot-1".to_string(), + candidate_id: None, candidate_provider: None, renderer_reservation_id: None, price: Some(price), @@ -3063,33 +4154,6 @@ mod tests { } } - fn mediated_bid(nurl: Option) -> Bid { - Bid { - slot_id: "header-banner".to_string(), - candidate_id: None, - candidate_provider: None, - renderer_reservation_id: None, - price: Some(2.5), - currency: "USD".to_string(), - creative: Some("
ad
".to_string()), - adomain: None, - bidder: "mediator".to_string(), - returned_seat: None, - width: 728, - height: 90, - nurl: nurl.clone(), - burl: nurl, - bid_id: None, - ad_id: Some("creative-123".to_string()), - creative_id: None, - renderer: None, - cache_id: Some("cache-abc".to_string()), - cache_host: None, - cache_path: None, - metadata: HashMap::new(), - } - } - struct SourceBidProvider { nurl: &'static str, } @@ -3148,6 +4212,33 @@ mod tests { } } + fn mediated_bid(nurl: Option) -> Bid { + Bid { + slot_id: "header-banner".to_string(), + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, + price: Some(2.5), + currency: "USD".to_string(), + creative: Some("
ad
".to_string()), + adomain: None, + bidder: "mediator".to_string(), + returned_seat: None, + width: 728, + height: 90, + nurl: nurl.clone(), + burl: nurl, + bid_id: None, + ad_id: Some("creative-123".to_string()), + creative_id: None, + renderer: None, + cache_id: Some("cache-abc".to_string()), + cache_host: None, + cache_path: None, + metadata: HashMap::new(), + } + } + #[async_trait::async_trait(?Send)] impl AuctionProvider for CacheRestoringMediator { fn provider_name(&self) -> &str { @@ -3229,387 +4320,174 @@ mod tests { "immediate-mediator" } - async fn request_bids( - &self, - _request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - let mut selection = context - .provider_responses - .and_then(|responses| responses.first()) - .and_then(|response| response.bids.first()) - .cloned() - .expect("should provide one source candidate to immediate mediator"); - selection.price = Some(2.5); - Ok(ProviderRequestOutcome::Immediate(AuctionResponse::success( - self.provider_name(), - vec![selection], - 0, - ))) - } - - async fn parse_response( - &self, - _response: PlatformResponse, - _response_time_ms: u64, - ) -> Result> { - panic!("immediate mediator response should not be parsed"); - } - - fn timeout_ms(&self) -> u32 { - 2000 - } - } - - #[tokio::test] - async fn mediated_bid_preserves_restored_fields_through_run_auction() { - // 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. - 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. - let services: &'static RuntimeServices = Box::leak(Box::new(services)); - - let config = AuctionConfig { - enabled: true, - providers: AuctionConfig::legacy_provider_map(&["bidder"]), - mediator: Some("mediator".to_string()), - timeout_ms: 2000, - ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(SourceBidProvider { - nurl: "https://nurl.example/win", - })); - orchestrator.register_provider(Arc::new(CacheRestoringMediator)); - - 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, - transport_timeout_ms: 2000, - provider_responses: None, - services, - }; - - let result = orchestrator - .run_auction(&request, &context) - .await - .expect("mediated auction should complete"); - - let bid = result - .winning_bids - .get("header-banner") - .expect("mediator should produce a winning bid for the slot"); - assert_eq!( - bid.nurl.as_deref(), - Some("https://nurl.example/win"), - "synchronous mediation must restore nurl via parse_response_with_context" - ); - assert_eq!( - bid.ad_id.as_deref(), - Some("creative-123"), - "mediated bid must keep its restored ad_id" - ); - } - - #[tokio::test] - async fn immediate_mediator_completes_in_sync_and_split_paths() { - for split in [false, true] { - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); - let services = build_services_with_http_client(stub); - let config = AuctionConfig { - enabled: true, - providers: AuctionConfig::legacy_provider_map(&["bidder"]), - mediator: Some("immediate-mediator".to_string()), - timeout_ms: 2000, - ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(SourceBidProvider { - nurl: "https://nurl.example/immediate", - })); - orchestrator.register_provider(Arc::new(ImmediateMediator)); - let request = create_test_auction_request(); - let settings = create_test_settings(); - let downstream = http::Request::new(edgezero_core::body::Body::empty()); - let context = AuctionContext { - settings: &settings, - request: &downstream, - timeout_ms: 2000, - transport_timeout_ms: 2000, - provider_responses: None, - services: &services, - }; - - let result = if split { - let dispatched = orchestrator.dispatch_auction(&request, &context).await; - orchestrator - .collect_dispatched_auction(dispatched, &services, &context) - .await - } else { - orchestrator - .run_auction(&request, &context) - .await - .expect("auction with immediate mediator should complete") - }; - - assert_eq!( - result - .mediator_response - .as_ref() - .map(|response| response.provider.as_str()), - Some("immediate-mediator") - ); - assert_eq!( - result - .winning_bids - .get("header-banner") - .and_then(|bid| bid.nurl.as_deref()), - Some("https://nurl.example/immediate") - ); - } - } - - async fn collect_deadline_test_result( - split: bool, - enforceable_total_request_deadline: bool, - ) -> OrchestrationResult { - let stub = Arc::new(StubHttpClient::new()); - stub.set_enforceable_total_request_deadline(enforceable_total_request_deadline); - stub.push_response(200, b"{}".to_vec()); - stub.push_response(200, b"{}".to_vec()); - stub.push_select_delay(Duration::from_millis(50)); - let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); - let config = AuctionConfig { - enabled: true, - providers: AuctionConfig::legacy_provider_map(&["late-one", "late-two"]), - timeout_ms: 10, - ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(DeadlineBidProvider { - name: "late-one", - backend: "late-one-backend", - })); - orchestrator.register_provider(Arc::new(DeadlineBidProvider { - name: "late-two", - backend: "late-two-backend", - })); - let request = create_test_auction_request(); - let settings = create_test_settings(); - let downstream = http::Request::new(edgezero_core::body::Body::empty()); - let context = AuctionContext { - settings: &settings, - request: &downstream, - timeout_ms: 10, - transport_timeout_ms: 10, - provider_responses: None, - services: &services, - }; - - if split { - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("deadline test providers should dispatch"); - }; - orchestrator - .collect_dispatched_auction(dispatched, &services, &context) - .await - } else { - orchestrator - .run_auction(&request, &context) - .await - .expect("deadline test auction should complete") - } - } - - #[tokio::test] - async fn current_adapter_deadline_drains_late_responses_in_both_paths() { - for split in [false, true] { - let result = collect_deadline_test_result(split, false).await; - assert_eq!(result.provider_responses.len(), 2); - assert_eq!(result.provider_responses[0].provider, "late-one"); - assert_eq!(result.provider_responses[0].status, BidStatus::Success); - assert_eq!(result.provider_responses[1].provider, "late-two"); - assert_eq!(result.provider_responses[1].status, BidStatus::Success); - assert!( - result - .provider_responses - .iter() - .all(|response| response.response_time_ms >= 50), - "late response times should retain actual elapsed duration" - ); - assert_eq!( - result.winning_bids["slot-1"].bidder, "late-one", - "a completed response remains eligible after the logical deadline" - ); - } - } - - #[tokio::test] - async fn synthetic_hard_deadline_classifies_late_responses_in_both_paths() { - for split in [false, true] { - let result = collect_deadline_test_result(split, true).await; - assert_eq!(result.provider_responses.len(), 2); - assert!(result.provider_responses.iter().all(|response| { - response.status == BidStatus::Error - && response.metadata["error_type"] == ERROR_TYPE_TIMEOUT - && response.response_time_ms >= 50 - })); - assert!(result.winning_bids.is_empty()); - } - } - - async fn pending_mediator_deadline_test_result( - split: bool, - enforceable_total_request_deadline: bool, - ) -> OrchestrationResult { - let stub = Arc::new(StubHttpClient::new()); - stub.set_enforceable_total_request_deadline(enforceable_total_request_deadline); - stub.push_response(200, b"{}".to_vec()); - stub.push_response(200, b"{}".to_vec()); - stub.push_select_delay(Duration::ZERO); - stub.push_select_delay(Duration::from_millis(50)); - let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); - let config = AuctionConfig { - enabled: true, - providers: AuctionConfig::legacy_provider_map(&["local"]), - mediator: Some("pending-deadline-mediator".to_string()), - timeout_ms: 20, - ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(DeadlineBidProvider { - name: "local", - backend: "local-backend", - })); - orchestrator.register_provider(Arc::new(PendingDeadlineMediator)); - let request = create_test_auction_request(); - let settings = create_test_settings(); - let downstream = http::Request::new(edgezero_core::body::Body::empty()); - let context = AuctionContext { - settings: &settings, - request: &downstream, - timeout_ms: 20, - transport_timeout_ms: 20, - provider_responses: None, - services: &services, - }; - - if split { - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("deadline test provider should dispatch"); - }; - orchestrator - .collect_dispatched_auction(dispatched, &services, &context) - .await - } else { - orchestrator - .run_auction(&request, &context) - .await - .expect("deadline test auction should complete") + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let mut selection = context + .provider_responses + .and_then(|responses| responses.first()) + .and_then(|response| response.bids.first()) + .cloned() + .expect("should provide one source candidate to immediate mediator"); + selection.price = Some(2.5); + Ok(ProviderRequestOutcome::Immediate(AuctionResponse::success( + self.provider_name(), + vec![selection], + 0, + ))) } - } - #[tokio::test] - async fn pending_mediator_late_completion_policy_is_equivalent_in_sync_and_split_paths() { - for split in [false, true] { - let current = pending_mediator_deadline_test_result(split, false).await; - let current_mediator = current - .mediator_response - .as_ref() - .expect("current adapters should accept completed late mediator responses"); - assert!( - current_mediator.response_time_ms >= 50, - "mediator timing should preserve actual elapsed duration" - ); - assert_eq!(current.winning_bids["slot-1"].bidder, "mediated"); + async fn parse_response( + &self, + _response: PlatformResponse, + _response_time_ms: u64, + ) -> Result> { + panic!("immediate mediator response should not be parsed"); + } - let hard = pending_mediator_deadline_test_result(split, true).await; - assert!(hard.mediator_response.is_none()); - assert_eq!(hard.winning_bids["slot-1"].bidder, "local"); - assert!( - hard.total_time_ms >= 50, - "discarding a late mediator must retain actual total elapsed time" - ); + fn timeout_ms(&self) -> u32 { + 2000 } } #[tokio::test] - async fn split_deadline_skips_mediator_and_falls_back_to_provider_winner() { + async fn mediated_bid_preserves_restored_fields_through_run_auction() { + // 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. let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); - stub.push_select_delay(Duration::from_millis(50)); - let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); - let launches = Arc::new(AtomicUsize::new(0)); + 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. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["late-one"]), - mediator: Some("deadline-mediator".to_string()), - timeout_ms: 10, + providers: AuctionConfig::legacy_provider_map(&["bidder"]), + mediator: Some("mediator".to_string()), + timeout_ms: 2000, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(DeadlineBidProvider { - name: "late-one", - backend: "late-one-backend", - })); - orchestrator.register_provider(Arc::new(DeadlineRecordingMediator { - launches: Arc::clone(&launches), - budgets: None, + orchestrator.register_provider(Arc::new(SourceBidProvider { + nurl: "https://nurl.example/win", })); + orchestrator.register_provider(Arc::new(CacheRestoringMediator)); + let request = create_test_auction_request(); let settings = create_test_settings(); - let downstream = http::Request::new(edgezero_core::body::Body::empty()); + 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: &downstream, - timeout_ms: 10, - transport_timeout_ms: 10, + request: &req, + timeout_ms: 2000, + transport_timeout_ms: 2000, provider_responses: None, - services: &services, - }; - let DispatchAuctionOutcome::Dispatched(dispatched) = - orchestrator.dispatch_auction(&request, &context).await - else { - panic!("deadline test provider should dispatch"); + services, }; + let result = orchestrator - .collect_dispatched_auction(dispatched, &services, &context) - .await; + .run_auction(&request, &context) + .await + .expect("mediated auction should complete"); - assert_eq!(launches.load(Ordering::Relaxed), 0); - assert!(result.mediator_response.is_none()); - assert_eq!(result.winning_bids["slot-1"].bidder, "late-one"); + let bid = result + .winning_bids + .get("header-banner") + .expect("mediator should produce a winning bid for the slot"); + assert_eq!( + bid.nurl.as_deref(), + Some("https://nurl.example/win"), + "synchronous mediation must restore nurl via parse_response_with_context" + ); + assert_eq!( + bid.ad_id.as_deref(), + Some("creative-123"), + "mediated bid must keep its restored ad_id" + ); } #[tokio::test] - async fn synchronous_deadline_skips_mediator_and_falls_back_to_provider_winner() { + async fn immediate_mediator_completes_in_sync_and_split_paths() { + for split in [false, true] { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + let services = build_services_with_http_client(stub); + let config = AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["bidder"]), + mediator: Some("immediate-mediator".to_string()), + timeout_ms: 2000, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(SourceBidProvider { + nurl: "https://nurl.example/immediate", + })); + orchestrator.register_provider(Arc::new(ImmediateMediator)); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 2000, + transport_timeout_ms: 2000, + provider_responses: None, + services: &services, + }; + + let result = if split { + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); + orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await + } else { + orchestrator + .run_auction(&request, &context) + .await + .expect("auction with immediate mediator should complete") + }; + + assert_eq!( + result + .mediator_response + .as_ref() + .map(|response| response.provider.as_str()), + Some("immediate-mediator") + ); + assert_eq!( + result + .winning_bids + .get("header-banner") + .and_then(|bid| bid.nurl.as_deref()), + Some("https://nurl.example/immediate") + ); + } + } + + async fn collect_deadline_test_result( + split: bool, + enforceable_total_request_deadline: bool, + ) -> OrchestrationResult { let stub = Arc::new(StubHttpClient::new()); + stub.set_enforceable_total_request_deadline(enforceable_total_request_deadline); + stub.push_response(200, b"{}".to_vec()); stub.push_response(200, b"{}".to_vec()); stub.push_select_delay(Duration::from_millis(50)); let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); - let launches = Arc::new(AtomicUsize::new(0)); let config = AuctionConfig { enabled: true, - providers: AuctionConfig::legacy_provider_map(&["late-one"]), - mediator: Some("deadline-mediator".to_string()), + providers: AuctionConfig::legacy_provider_map(&["late-one", "late-two"]), timeout_ms: 10, ..Default::default() }; @@ -3618,70 +4496,36 @@ mod tests { name: "late-one", backend: "late-one-backend", })); - orchestrator.register_provider(Arc::new(DeadlineRecordingMediator { - launches: Arc::clone(&launches), - budgets: None, + orchestrator.register_provider(Arc::new(DeadlineBidProvider { + name: "late-two", + backend: "late-two-backend", })); - let request = create_test_auction_request(); - let settings = create_test_settings(); - let downstream = http::Request::new(edgezero_core::body::Body::empty()); - let context = AuctionContext { - settings: &settings, - request: &downstream, - timeout_ms: 10, - transport_timeout_ms: 10, - provider_responses: None, - services: &services, - }; - let result = orchestrator - .run_auction(&request, &context) - .await - .expect("synchronous deadline test should complete"); - - assert_eq!(launches.load(Ordering::Relaxed), 0); - assert!(result.mediator_response.is_none()); - assert_eq!(result.winning_bids["slot-1"].bidder, "late-one"); - } - - fn create_test_auction_request() -> AuctionRequest { - AuctionRequest { - id: "test-auction-123".to_string(), - slots: vec![ - AdSlot { - id: "header-banner".to_string(), - formats: vec![AdFormat { - media_type: MediaType::Banner, - width: 728, - height: 90, - }], - floor_price: Some(1.50), - targeting: HashMap::new(), - bidders: HashMap::new(), - }, - AdSlot { - id: "sidebar".to_string(), - formats: vec![AdFormat { - media_type: MediaType::Banner, - width: 300, - height: 250, - }], - floor_price: Some(1.00), - targeting: HashMap::new(), - bidders: HashMap::new(), - }, - ], - publisher: PublisherInfo { - domain: "test.com".to_string(), - page_url: Some("https://test.com/article".to_string()), - }, - user: UserInfo { - id: Some("user-123".to_string()), - consent: None, - eids: None, - }, - device: None, - site: None, - context: HashMap::new(), + let request = one_slot_request(); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 10, + transport_timeout_ms: 10, + provider_responses: None, + services: &services, + }; + + if split { + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("deadline test providers should dispatch"); + }; + orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await + } else { + orchestrator + .run_auction(&request, &context) + .await + .expect("deadline test auction should complete") } } @@ -3704,10 +4548,7 @@ mod tests { fn enabled_config(providers: &[&str]) -> AuctionConfig { AuctionConfig { enabled: true, - providers: providers - .iter() - .map(|provider| (*provider).to_string()) - .collect(), + providers: AuctionConfig::legacy_provider_map(providers), ..AuctionConfig::default() } } @@ -3984,78 +4825,328 @@ mod tests { let left = AuctionResponse::success("alpha", vec![alpha], 1); let right = AuctionResponse::success("zeta", vec![zeta], 1); - for responses in [vec![left.clone(), right.clone()], vec![right, left]] { - let winners = orchestrator.select_winning_bids(&responses, &HashMap::new()); - assert_eq!( - winners["slot-1"].candidate_provider.as_deref(), - Some("alpha") - ); - } + for responses in [vec![left.clone(), right.clone()], vec![right, left]] { + let winners = orchestrator.select_winning_bids(&responses, &HashMap::new()); + assert_eq!( + winners["slot-1"].candidate_provider.as_deref(), + Some("alpha") + ); + } + } + + #[test] + fn mediator_can_select_only_known_candidate_provenance() { + let mut source = auction_bid("aps", 1.0); + source.candidate_id = Some("AAAAAAAAAAAA".to_string()); + source.candidate_provider = Some("aps".to_string()); + source.nurl = Some("https://source.example/win".to_string()); + let candidates = HashMap::from([("AAAAAAAAAAAA".to_string(), source.clone())]); + let mut selection = source.clone(); + selection.price = Some(9.0); + + let resolved = AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![selection], 2), + &candidates, + ) + .expect("known candidate should resolve"); + assert_eq!(resolved.bids[0].price, Some(9.0)); + assert_eq!(resolved.bids[0].width, source.width); + assert_eq!(resolved.bids[0].height, source.height); + assert_eq!(resolved.bids[0].renderer, source.renderer); + assert_eq!(resolved.bids[0].nurl, source.nurl); + + let mut substituted = source.clone(); + substituted.price = Some(9.0); + substituted.width = 1; + assert!( + AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![substituted], 2), + &candidates, + ) + .is_err(), + "mediator source-field substitutions should fail provenance validation" + ); + + let mut second_source = source.clone(); + second_source.candidate_id = Some("BBBBBBBBBBBB".to_string()); + second_source.bid_id = Some("upstream-2".to_string()); + let same_slot_candidates = HashMap::from([ + ("AAAAAAAAAAAA".to_string(), source.clone()), + ("BBBBBBBBBBBB".to_string(), second_source.clone()), + ]); + assert!( + AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![source.clone(), second_source], 2), + &same_slot_candidates, + ) + .is_err(), + "a mediator may select at most one candidate for a slot" + ); + + let mut unknown = source; + unknown.candidate_id = Some("BBBBBBBBBBBB".to_string()); + assert!( + AuctionOrchestrator::resolve_mediator_candidates( + AuctionResponse::success("mediator", vec![unknown], 2), + &candidates, + ) + .is_err() + ); + } + + fn create_test_settings() -> crate::settings::Settings { + let settings_str = crate_test_settings_str(); + crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings") + } + + #[tokio::test] + async fn current_adapter_deadline_drains_late_responses_in_both_paths() { + for split in [false, true] { + let result = collect_deadline_test_result(split, false).await; + assert_eq!(result.provider_responses.len(), 2); + assert_eq!(result.provider_responses[0].provider, "late-one"); + assert_eq!(result.provider_responses[0].status, BidStatus::Success); + assert_eq!(result.provider_responses[1].provider, "late-two"); + assert_eq!(result.provider_responses[1].status, BidStatus::Success); + assert!( + result + .provider_responses + .iter() + .all(|response| response.response_time_ms >= 50), + "late response times should retain actual elapsed duration" + ); + assert_eq!( + result.winning_bids["slot-1"].bidder, "late-one", + "a completed response remains eligible after the logical deadline" + ); + } + } + + #[tokio::test] + async fn synthetic_hard_deadline_classifies_late_responses_in_both_paths() { + for split in [false, true] { + let result = collect_deadline_test_result(split, true).await; + assert_eq!(result.provider_responses.len(), 2); + assert!(result.provider_responses.iter().all(|response| { + response.status == BidStatus::Error + && response.metadata["error_type"] == ERROR_TYPE_TIMEOUT + && response.response_time_ms >= 50 + })); + assert!(result.winning_bids.is_empty()); + } + } + + async fn pending_mediator_deadline_test_result( + split: bool, + enforceable_total_request_deadline: bool, + ) -> OrchestrationResult { + let stub = Arc::new(StubHttpClient::new()); + stub.set_enforceable_total_request_deadline(enforceable_total_request_deadline); + stub.push_response(200, b"{}".to_vec()); + stub.push_response(200, b"{}".to_vec()); + stub.push_select_delay(Duration::ZERO); + stub.push_select_delay(Duration::from_millis(50)); + let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); + let config = AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["local"]), + mediator: Some("pending-deadline-mediator".to_string()), + timeout_ms: 20, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(DeadlineBidProvider { + name: "local", + backend: "local-backend", + })); + orchestrator.register_provider(Arc::new(PendingDeadlineMediator)); + let request = one_slot_request(); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 20, + transport_timeout_ms: 20, + provider_responses: None, + services: &services, + }; + + if split { + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("deadline test provider should dispatch"); + }; + orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await + } else { + orchestrator + .run_auction(&request, &context) + .await + .expect("deadline test auction should complete") + } + } + + #[tokio::test] + async fn pending_mediator_late_completion_policy_is_equivalent_in_sync_and_split_paths() { + for split in [false, true] { + let current = pending_mediator_deadline_test_result(split, false).await; + let current_mediator = current + .mediator_response + .as_ref() + .expect("current adapters should accept completed late mediator responses"); + assert!( + current_mediator.response_time_ms >= 50, + "mediator timing should preserve actual elapsed duration" + ); + assert_eq!(current.winning_bids["slot-1"].bidder, "local"); + assert_eq!(current.winning_bids["slot-1"].price, Some(9.0)); + + let hard = pending_mediator_deadline_test_result(split, true).await; + assert!(hard.mediator_response.is_none()); + assert_eq!(hard.winning_bids["slot-1"].bidder, "local"); + assert_eq!(hard.winning_bids["slot-1"].price, Some(3.0)); + assert!( + hard.total_time_ms >= 50, + "discarding a late mediator must retain actual total elapsed time" + ); + } + } + + #[tokio::test] + async fn split_deadline_skips_mediator_and_falls_back_to_provider_winner() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + stub.push_select_delay(Duration::from_millis(50)); + let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); + let launches = Arc::new(AtomicUsize::new(0)); + let config = AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["late-one"]), + mediator: Some("deadline-mediator".to_string()), + timeout_ms: 10, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(DeadlineBidProvider { + name: "late-one", + backend: "late-one-backend", + })); + orchestrator.register_provider(Arc::new(DeadlineRecordingMediator { + launches: Arc::clone(&launches), + budgets: None, + })); + let request = one_slot_request(); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 10, + transport_timeout_ms: 10, + provider_responses: None, + services: &services, + }; + let DispatchAuctionOutcome::Dispatched(dispatched) = + orchestrator.dispatch_auction(&request, &context).await + else { + panic!("deadline test provider should dispatch"); + }; + let result = orchestrator + .collect_dispatched_auction(dispatched, &services, &context) + .await; + + assert_eq!(launches.load(Ordering::Relaxed), 0); + assert!(result.mediator_response.is_none()); + assert_eq!(result.winning_bids["slot-1"].bidder, "late-one"); } - #[test] - fn mediator_can_select_only_known_candidate_provenance() { - let mut source = auction_bid("aps", 1.0); - source.candidate_id = Some("AAAAAAAAAAAA".to_string()); - source.candidate_provider = Some("aps".to_string()); - source.nurl = Some("https://source.example/win".to_string()); - let candidates = HashMap::from([("AAAAAAAAAAAA".to_string(), source.clone())]); - let mut selection = source.clone(); - selection.price = Some(9.0); - - let resolved = AuctionOrchestrator::resolve_mediator_candidates( - AuctionResponse::success("mediator", vec![selection], 2), - &candidates, - ) - .expect("known candidate should resolve"); - assert_eq!(resolved.bids[0].price, Some(9.0)); - assert_eq!(resolved.bids[0].width, source.width); - assert_eq!(resolved.bids[0].height, source.height); - assert_eq!(resolved.bids[0].renderer, source.renderer); - assert_eq!(resolved.bids[0].nurl, source.nurl); - - let mut substituted = source.clone(); - substituted.price = Some(9.0); - substituted.width = 1; - assert!( - AuctionOrchestrator::resolve_mediator_candidates( - AuctionResponse::success("mediator", vec![substituted], 2), - &candidates, - ) - .is_err(), - "mediator source-field substitutions should fail provenance validation" - ); - - let mut second_source = source.clone(); - second_source.candidate_id = Some("BBBBBBBBBBBB".to_string()); - second_source.bid_id = Some("upstream-2".to_string()); - let same_slot_candidates = HashMap::from([ - ("AAAAAAAAAAAA".to_string(), source.clone()), - ("BBBBBBBBBBBB".to_string(), second_source.clone()), - ]); - assert!( - AuctionOrchestrator::resolve_mediator_candidates( - AuctionResponse::success("mediator", vec![source.clone(), second_source], 2), - &same_slot_candidates, - ) - .is_err(), - "a mediator may select at most one candidate for a slot" - ); + #[tokio::test] + async fn synchronous_deadline_skips_mediator_and_falls_back_to_provider_winner() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + stub.push_select_delay(Duration::from_millis(50)); + let services = build_services_with_http_client(Arc::clone(&stub) as Arc<_>); + let launches = Arc::new(AtomicUsize::new(0)); + let config = AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["late-one"]), + mediator: Some("deadline-mediator".to_string()), + timeout_ms: 10, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(DeadlineBidProvider { + name: "late-one", + backend: "late-one-backend", + })); + orchestrator.register_provider(Arc::new(DeadlineRecordingMediator { + launches: Arc::clone(&launches), + budgets: None, + })); + let request = one_slot_request(); + let settings = create_test_settings(); + let downstream = http::Request::new(edgezero_core::body::Body::empty()); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 10, + transport_timeout_ms: 10, + provider_responses: None, + services: &services, + }; + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("synchronous deadline test should complete"); - let mut unknown = source; - unknown.candidate_id = Some("BBBBBBBBBBBB".to_string()); - assert!( - AuctionOrchestrator::resolve_mediator_candidates( - AuctionResponse::success("mediator", vec![unknown], 2), - &candidates, - ) - .is_err() - ); + assert_eq!(launches.load(Ordering::Relaxed), 0); + assert!(result.mediator_response.is_none()); + assert_eq!(result.winning_bids["slot-1"].bidder, "late-one"); } - fn create_test_settings() -> crate::settings::Settings { - let settings_str = crate_test_settings_str(); - crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings") + fn create_test_auction_request() -> AuctionRequest { + AuctionRequest { + id: "test-auction-123".to_string(), + slots: vec![ + AdSlot { + id: "header-banner".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 728, + height: 90, + }], + floor_price: Some(1.50), + targeting: HashMap::new(), + bidders: HashMap::new(), + }, + AdSlot { + id: "sidebar".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: Some(1.00), + targeting: HashMap::new(), + bidders: HashMap::new(), + }, + ], + publisher: PublisherInfo { + domain: "test.com".to_string(), + page_url: Some("https://test.com/article".to_string()), + }, + user: UserInfo { + id: Some("user-123".to_string()), + consent: None, + eids: None, + }, + device: None, + site: None, + context: HashMap::new(), + } } struct ImmediateNoBidProvider; @@ -4193,9 +5284,11 @@ mod tests { let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); - let dispatched = orchestrator - .dispatch_auction(&create_test_auction_request(), &context) - .await; + let dispatched = expect_dispatched( + orchestrator + .dispatch_auction(&create_test_auction_request(), &context) + .await, + ); let result = orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; @@ -4227,10 +5320,10 @@ mod tests { }; let mut orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(ImmediateNoBidProvider)); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "pending", - backend: "pending-backend", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "pending", + "pending-backend", + ))); let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); let services = build_services_with_http_client(stub); @@ -4240,7 +5333,8 @@ mod tests { let request = create_test_auction_request(); let result = if split { - let dispatched = orchestrator.dispatch_auction(&request, &context).await; + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await @@ -4510,11 +5604,7 @@ mod tests { .expect("should build request"); let context = create_test_auction_context(&settings, &req, 2000); - let error = orchestrator - .run_auction(&request, &context) - .await - .expect_err("should fail when every provider launch fails"); - + let result = orchestrator.run_auction(&request, &context).await; let result = result.expect("should preserve launch failures as slot decisions"); assert_eq!( result.decision_set.results, @@ -4542,14 +5632,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "shared-backend", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "shared-backend", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "shared-backend", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "shared-backend", + ))); let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); let services = build_services_with_http_client(stub); @@ -4559,7 +5649,8 @@ mod tests { let request = create_test_auction_request(); let result = if split { - let dispatched = orchestrator.dispatch_auction(&request, &context).await; + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await @@ -4690,7 +5781,7 @@ mod tests { canonical_ms: 0, calls, }), - Arc::new(StubHttpClient::new()), + stub, ); let predicted = Arc::new(Mutex::new(Vec::new())); let requested = Arc::new(Mutex::new(Vec::new())); @@ -4710,6 +5801,7 @@ mod tests { let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); + let request = create_test_auction_request(); let result = orchestrator .run_auction(&request, &context) @@ -4747,7 +5839,7 @@ mod tests { let requested = Arc::new(Mutex::new(Vec::new())); let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { enabled: true, - providers: vec!["bidder".to_string()], + providers: AuctionConfig::legacy_provider_map(&["bidder"]), timeout_ms: 2000, ..Default::default() }); @@ -4763,10 +5855,18 @@ mod tests { let context = immediate_test_context(&settings, &downstream, &services); let request = create_test_auction_request(); - let dispatched = orchestrator.dispatch_auction(&request, &context).await; - let result = orchestrator - .collect_dispatched_auction(dispatched, &services, &context) - .await; + let outcome = orchestrator.dispatch_auction(&request, &context).await; + let result = match orchestrator + .resolve_dispatch_outcome(&request, outcome) + .expect("zero canonical timeout should resolve attributably") + { + super::ResolvedDispatchOutcome::InFlight(dispatched) => { + orchestrator + .collect_dispatched_auction(*dispatched, &services, &context) + .await + } + super::ResolvedDispatchOutcome::Complete(result) => *result, + }; assert!(predicted.lock().expect("should lock predicted").is_empty()); assert!(requested.lock().expect("should lock requested").is_empty()); @@ -4809,10 +5909,10 @@ mod tests { timeout_ms: 2000, ..Default::default() }); - 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(recording_provider( "mediator", "mediator-backend", @@ -4878,7 +5978,8 @@ mod tests { let context = immediate_test_context(&settings, &downstream, &services); let request = create_test_auction_request(); - let dispatched = orchestrator.dispatch_auction(&request, &context).await; + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; @@ -4984,14 +6085,22 @@ mod tests { let context = immediate_test_context(&settings, &downstream, &services); let request = create_test_auction_request(); - let dispatched = orchestrator.dispatch_auction(&request, &context).await; + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); let result = orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; - assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-a" && response.status == BidStatus::Success - })); + let provider_a = result + .provider_responses + .iter() + .find(|response| response.provider == "provider-a") + .expect("should have provider-a response"); + assert_eq!( + provider_a.status, + BidStatus::Success, + "response should correlate by the resolved backend name, not the prediction" + ); }); } @@ -5023,17 +6132,24 @@ mod tests { let context = immediate_test_context(&settings, &downstream, &services); let request = create_test_auction_request(); - let dispatched = orchestrator.dispatch_auction(&request, &context).await; + let dispatched = + expect_dispatched(orchestrator.dispatch_auction(&request, &context).await); let result = orchestrator .collect_dispatched_auction(dispatched, &services, &context) .await; - assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-a" && response.status == BidStatus::Success - })); - assert!(result.provider_responses.iter().any(|response| { - response.provider == "provider-b" && response.status == BidStatus::Error - })); + let provider_a = result + .provider_responses + .iter() + .find(|response| response.provider == "provider-a") + .expect("should have provider-a response"); + let provider_b = result + .provider_responses + .iter() + .find(|response| response.provider == "provider-b") + .expect("should have provider-b response"); + assert_eq!(provider_a.status, BidStatus::Success); + assert_eq!(provider_b.status, BidStatus::Error); }); } @@ -5061,14 +6177,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(); @@ -5200,10 +6316,9 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); + let mut provider = StubAuctionProvider::new("provider-a", "backend-a"); + provider.configured_timeout_ms = 125; + orchestrator.register_provider(Arc::new(provider)); let request = create_test_auction_request(); let settings = create_test_settings(); let downstream = http::Request::builder() @@ -5219,9 +6334,11 @@ mod tests { provider_responses: None, services: &services, }; - let dispatched = orchestrator - .dispatch_auction(&request, &dispatch_context) - .await; + let dispatched = expect_dispatched( + orchestrator + .dispatch_auction(&request, &dispatch_context) + .await, + ); let placeholder = http::Request::builder() .uri("https://placeholder.invalid/") .body(edgezero_core::body::Body::empty()) @@ -5276,14 +6393,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(); @@ -5352,14 +6469,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(); @@ -5378,7 +6495,14 @@ mod tests { }; // Act - let dispatched = orchestrator.dispatch_auction(&request, &context).await; + let DispatchAuctionOutcome::DispatchFailed { + mut provider_responses, + eligible_slots_by_provider, + .. + } = orchestrator.dispatch_auction(&request, &context).await + else { + panic!("sequential-platform rejection should preserve dispatch failures"); + }; // Assert: no network request launches, but every configured provider // remains attributable through the normal terminal decision path. @@ -5386,9 +6510,26 @@ mod tests { stub_for_assertion.recorded_backend_names().is_empty(), "should not launch any provider request on a sequential platform" ); - let result = orchestrator - .collect_dispatched_auction(dispatched, services, &context) - .await; + let normalized = orchestrator.normalize_provider_responses_with_eligibility( + &request, + &mut provider_responses, + &eligible_slots_by_provider, + ); + let winning_bids = HashMap::new(); + let decision_set = orchestrator.build_decision_set( + &request, + &normalized.outcomes, + &winning_bids, + false, + ); + let result = OrchestrationResult { + provider_responses, + mediator_response: None, + winning_bids, + decision_set, + total_time_ms: 0, + metadata: HashMap::new(), + }; assert!(result.winning_bids.is_empty()); assert!(result.provider_responses.iter().all(|response| { response.status == BidStatus::Error @@ -5540,14 +6681,10 @@ mod tests { .map(|response| response.provider.as_str()), Some("immediate-mediator") ); - assert_eq!( - result.winning_bids["header-banner"].nurl.as_deref(), - Some("https://nurl.example/immediate") - ); - assert!( - !result.winning_bids.contains_key("fictional-slot"), - "mediator output owns final selection" - ); + let winner = &result.winning_bids["fictional-slot"]; + assert_eq!(winner.bid_id.as_deref(), Some("provider")); + assert_eq!(winner.price, Some(2.5)); + assert!(!result.winning_bids.contains_key("header-banner")); } async fn planned_pending_mediator_deadline_result( @@ -5606,7 +6743,11 @@ mod tests { .as_ref() .expect("current adapters should accept completed late mediator responses"); assert!(current_mediator.response_time_ms >= 50); - assert_eq!(current.winning_bids["slot-1"].bidder, "mediated"); + assert_eq!( + current.winning_bids["fictional-slot"].bid_id.as_deref(), + Some("provider") + ); + assert_eq!(current.winning_bids["fictional-slot"].price, Some(9.0)); let hard = planned_pending_mediator_deadline_result(true).await; assert!(hard.mediator_response.is_none()); @@ -5832,7 +6973,8 @@ mod tests { serde_json::to_vec(&serde_json::json!({ "seatbid": [{"seat": "aps-instance", "bid": [{ "id": "mediated-aps", "impid": "fictional-slot", "price": 2.0, - "adm": "ignored", "w": 300, "h": 250, "crid": "aps-creative" + "w": 300, "h": 250, + "ext": {"trusted_server": {"candidate_id": "AAAAAAAAAAAA"}} }]}] })) .expect("should serialize mediator response"), @@ -5850,7 +6992,8 @@ mod tests { timeout_ms: 500, ..AdServerMockConfig::default() }); - let orchestrator = AuctionOrchestratorHarness::new(plan, Some(Arc::new(mediator))); + let orchestrator = AuctionOrchestratorHarness::new(plan, Some(Arc::new(mediator))) + .with_identity_generator(Arc::new(FixedIdentityGenerator::new())); let request = planned_request(); let settings = create_test_settings(); let inbound = http::Request::new(edgezero_core::body::Body::empty()); @@ -6028,7 +7171,7 @@ mod tests { let renderer = bid .renderer .as_ref() - .and_then(BidRenderer::as_aps) + .and_then(BidRenderSourceV1::as_aps) .expect("should construct typed APS renderer"); assert_eq!(renderer.account_id, "example-account"); let decoded = base64::engine::general_purpose::STANDARD @@ -6048,13 +7191,13 @@ mod tests { for reason in [ "lost_to_higher_bid", "script_rendering_disabled", - "unknown_impid", + "unknown_impression", "invalid_dimensions", "invalid_price", "unsupported_media_type", - "unsupported_tagtype", + "invalid_tag_type", "creative_id_too_large", - "missing_render_source", + "missing_upstream_bid_id", "empty_seatbid_bids", ] { assert_eq!(response.metadata["drop_reasons"][reason], 1, "{reason}"); @@ -6210,28 +7353,28 @@ mod tests { 200, b"not-json".to_vec(), BidStatus::Error, - Some("unexpected_response_shape"), + Some("invalid_provider_response"), Some("parse_response"), ), ( 200, b"[]".to_vec(), BidStatus::Error, - Some("unexpected_response_shape"), + Some("invalid_provider_response"), Some("parse_response"), ), ( 200, br#"{"contextual":true}"#.to_vec(), BidStatus::Error, - Some("unexpected_response_shape"), - Some("parse_response"), + Some("invalid_provider_response"), + None, ), ( 200, br#"{"cur":"EUR","seatbid":[]}"#.to_vec(), - BidStatus::NoBid, - Some("unsupported_currency"), + BidStatus::Error, + Some("invalid_provider_response"), None, ), ]; @@ -6383,7 +7526,7 @@ mod tests { let renderer = parsed.bids[0] .renderer .as_ref() - .and_then(BidRenderer::as_aps) + .and_then(BidRenderSourceV1::as_aps) .expect("should construct APS renderer"); let decoded = base64::engine::general_purpose::STANDARD .decode(&renderer.aax_response) diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index 1098f49a5..01380a070 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -18,6 +18,13 @@ use crate::platform::{ }; use crate::request_signing::{RequestSigner, SigningParams}; +use super::openrtb::{ + OpenRtbBuildOutcome, RequestFinalization, apply_notification_policy, build_request, + extract_standard_response, unused_bidder_params_count, +}; +use super::plan::ProviderPlan; +use super::profile::CompiledOpenRtbProfile; +use super::routing::{ProviderAuctionInput, RoutedAuction}; use super::types::{ AuctionContext, AuctionRequest, AuctionResponse, AuctionSlotFailureReason, Bid, }; diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index d3b96dd7b..4c7d981d7 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -1123,6 +1123,10 @@ mod tests { provider_responses: vec![provider], mediator_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), aps_bid.clone())]), + decision_set: crate::auction::types::AuctionDecisionSetV1::failed( + &request, + crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + ), total_time_ms: 12, metadata: HashMap::new(), }; @@ -1159,6 +1163,10 @@ mod tests { )], mediator_response: None, winning_bids: HashMap::from([("slot-1".to_owned(), fallback_bid)]), + decision_set: crate::auction::types::AuctionDecisionSetV1::failed( + &request, + crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + ), total_time_ms: 12, metadata: HashMap::new(), }; @@ -1191,6 +1199,10 @@ mod tests { provider_responses: vec![provider], mediator_response: Some(mediator), winning_bids: HashMap::from([("slot-1".to_owned(), aps_bid.clone())]), + decision_set: crate::auction::types::AuctionDecisionSetV1::failed( + &request, + crate::auction::types::AuctionSlotFailureReason::WinnerNotRenderable, + ), total_time_ms: 15, metadata: HashMap::new(), }; diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 55b69534d..c5dbfc441 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -210,6 +210,8 @@ pub struct AuctionContext<'a> { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AuctionDropReason { + /// Configured processing rejected an ordinary creative's only render source. + CreativeProcessingRejected, /// Optional creative ID is present with an invalid type or value. InvalidCreativeId, /// Optional creative ID exceeds its UTF-8 byte bound. @@ -272,6 +274,7 @@ impl AuctionDropReason { #[must_use] pub const fn as_str(self) -> &'static str { match self { + Self::CreativeProcessingRejected => "creative_processing_rejected", Self::InvalidCreativeId => "invalid_creative_id", Self::CreativeIdTooLarge => "creative_id_too_large", Self::DimensionsOutOfRange => "dimensions_out_of_range", @@ -979,6 +982,28 @@ pub struct Bid { pub metadata: HashMap, } +/// Length of the hex-encoded creative trace hash. +const ADM_TRACE_HASH_LEN: usize = 16; + +/// Compute the trace hash for delivered creative markup. +#[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. + #[must_use] + pub fn creative_trace_hash(&self) -> Option { + self.creative.as_deref().map(adm_trace_hash) + } +} + /// Per-provider summary included in the auction response. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderSummary { @@ -1119,6 +1144,7 @@ mod tests { #[test] fn typed_drop_reasons_use_exact_literals_in_provider_summary_metadata() { let reasons = [ + AuctionDropReason::CreativeProcessingRejected, AuctionDropReason::InvalidCreativeId, AuctionDropReason::CreativeIdTooLarge, AuctionDropReason::DimensionsOutOfRange, diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 7b46e2ca3..2033f32b2 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -275,7 +275,15 @@ fn validate_enabled_integrations( validate_integration::(settings, "sourcepoint")?; validate_integration::(settings, "osano")?; validate_integration::(settings, "google_tag_manager")?; - validate_integration::(settings, "datadome")?; + if let Some(config) = settings.integration_config::("datadome")? { + if resolved_secrets { + crate::integrations::datadome::DataDomeIntegration::validate_config_for_startup( + config, + )?; + } else { + crate::integrations::datadome::DataDomeIntegration::validate_config_for_deploy(config)?; + } + } validate_integration::(settings, "gpt")?; validate_integration::(settings, "gpt_diagnostics")?; @@ -445,20 +453,6 @@ mod tests { use crate::test_support::tests::crate_test_settings_str; use edgezero_core::app_config::AppConfigMeta; - #[derive(Debug, Deserialize)] - #[serde(deny_unknown_fields)] - #[allow(dead_code)] - struct LegacyCreativeOpportunitiesConfig { - enabled: bool, - gam_network_id: String, - #[serde(default)] - auction_timeout_ms: Option, - #[serde(default)] - price_granularity: serde_json::Value, - #[serde(default)] - slot: Vec, - } - fn app_config_with_creative_opportunities( gam_unit_path: Option<&str>, ) -> TrustedServerAppConfig { @@ -819,23 +813,53 @@ formats = [{ width = 300, height = 250 }] } #[test] - fn static_gam_unit_template_is_accepted_by_legacy_schema() { - let creative_opportunities = serialized_creative_opportunities(Some("/99999/example/home")); + fn app_config_new_rejects_empty_secret_key_reference() { + let mut settings = valid_settings(); + settings.publisher.proxy_secret = Redacted::new(String::new()); - serde_json::from_value::(creative_opportunities) - .expect("should accept static GAM unit template"); + let err = TrustedServerAppConfig::new(settings) + .expect_err("should reject an empty secret key reference"); + + assert!( + err.to_string().contains("publisher.proxy_secret"), + "error should identify the empty secret reference: {err:?}" + ); + } + + #[test] + fn app_config_new_rejects_invalid_non_secret_settings() { + let mut settings = valid_settings(); + settings.publisher.domain = "invalid/domain".to_owned(); + + let err = TrustedServerAppConfig::new(settings) + .expect_err("should reject invalid publisher domain before creating an app config"); + + assert!( + err.to_string().contains("invalid_publisher_domain"), + "error should identify the structural validation failure: {err:?}" + ); } #[test] - fn absent_gam_unit_template_is_accepted_by_legacy_schema() { - let creative_opportunities = serialized_creative_opportunities(None); + fn runtime_validation_rejects_short_proxy_secret() { + let mut settings = valid_settings(); + settings.publisher.proxy_secret = Redacted::new("short".to_owned()); - serde_json::from_value::(creative_opportunities) - .expect("should accept absent GAM unit template"); + let err = validate_settings_for_runtime(&settings) + .expect_err("should reject a short resolved proxy secret"); + + assert!( + err.to_string().contains("at least 32 bytes"), + "error should identify the required proxy-secret strength: {err:?}" + ); + assert!( + !err.to_string().contains("short"), + "error should not expose the resolved secret" + ); } #[test] - fn deploy_validation_rejects_placeholders() { + fn runtime_validation_rejects_placeholders() { let settings = Settings::from_toml( r#" [publisher] @@ -1126,6 +1150,38 @@ password = "production-admin-password-32-bytes" ); } + #[test] + fn deploy_validation_rejects_invalid_datadome_test_bypass() { + for (enable_protection, name, expected_message) in [ + (false, "datadome_test_bypass", "requires enable_protection"), + (true, "", "credential_secret_name"), + ] { + let mut settings = valid_settings(); + settings + .integrations + .insert_config( + "datadome", + &serde_json::json!({ + "enabled": true, + "enable_protection": enable_protection, + "server_side_key_secret_name": "datadome_server_side_key", + "protection_test_bypass": { + "enabled": true, + "credential_secret_name": name, + }, + }), + ) + .expect("should insert DataDome config"); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject invalid DataDome test bypass"); + assert!( + format!("{err:?}").contains(expected_message), + "error should mention the invalid bypass setting: {err:?}" + ); + } + } + #[test] fn validate_trait_reports_deploy_errors() { let mut settings = valid_settings(); diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index e1152b1e7..828311f12 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -5,6 +5,7 @@ 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"; +pub const COOKIE_TS_TRACE: &str = "ts-trace"; 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 f0c1cc882..00f8c508a 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -8,11 +8,12 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use lol_html::{ - EndTagHandler, Settings as RewriterSettings, doc_comments, element, end, + EndTagHandler, Settings as RewriterSettings, element, end, html_content::{ContentType, EndTag}, text, }; +use crate::integrations::datadome::{DATADOME_INTEGRATION_ID, DataDomeClientTagSuppressed}; use crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision; use crate::integrations::{ AttributeRewriteOutcome, IntegrationAttributeContext, IntegrationDocumentState, @@ -52,6 +53,7 @@ struct HtmlWithPostProcessing { origin_host: String, request_host: String, request_scheme: String, + csp_nonce: Option, document_state: IntegrationDocumentState, } @@ -106,6 +108,7 @@ impl StreamProcessor for HtmlWithPostProcessing { request_host: &self.request_host, request_scheme: &self.request_scheme, origin_host: &self.origin_host, + csp_nonce: self.csp_nonce.as_deref(), document_state: &self.document_state, }; @@ -198,6 +201,12 @@ pub struct HtmlProcessorConfig { pub body_close: BodyCloseInjection, /// 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, + /// Set when publisher markup contains a response-bound CSP nonce. + pub csp_nonce_observed: Option>, + /// Unique nonce admitted by the response's enforcing CSP headers, if any. + pub csp_nonce: Option, } impl HtmlProcessorConfig { @@ -221,6 +230,9 @@ impl HtmlProcessorConfig { gpt_diagnostics: None, body_close: BodyCloseInjection::None, render_trace_overlay: false, + suppress_datadome_client_side_tag: false, + csp_nonce_observed: None, + csp_nonce: None, } } @@ -260,12 +272,33 @@ impl HtmlProcessorConfig { self } + /// Watch publisher markup for response-bound CSP nonces before template storage. + #[must_use] + pub fn with_csp_nonce_observer(mut self, observed: Option>) -> Self { + self.csp_nonce_observed = observed; + self + } + + /// Attach the exact response-header CSP nonce to Trusted Server script tags. + #[must_use] + pub fn with_csp_nonce(mut self, nonce: Option) -> Self { + self.csp_nonce = nonce; + 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 { + self.suppress_datadome_client_side_tag = suppress; + self + } } /// Create an HTML processor with URL replacement and integration hooks. @@ -278,6 +311,9 @@ impl HtmlProcessorConfig { pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcessor { let post_processors = config.integrations.html_post_processors(); let document_state = IntegrationDocumentState::default(); + if config.suppress_datadome_client_side_tag { + document_state.get_or_insert_with(DATADOME_INTEGRATION_ID, || DataDomeClientTagSuppressed); + } // Simplified URL patterns structure - stores only core data and generates variants on-demand struct UrlPatterns { @@ -350,6 +386,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let gpt_diagnostics = config.gpt_diagnostics.clone(); let body_close = config.body_close.clone(); let render_trace_overlay = config.render_trace_overlay; + let csp_nonce = config.csp_nonce.clone(); // No source-comment neutralization here: rewriting a publisher comment that happens // to match the reserved marker would change publisher content bytes. Collisions are @@ -381,6 +418,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let ad_bids_state = ad_bids_state.clone(); let gpt_diagnostics = gpt_diagnostics.clone(); let body_close = body_close.clone(); + let csp_nonce = csp_nonce.clone(); move |el| { if !injected_tsjs.get() { // A shared C2 template must contain no request-scoped boot @@ -389,20 +427,21 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso if matches!(body_close, BodyCloseInjection::Marker(_)) { return Ok(()); } - let mut snippet = String::new(); + let mut interstitial = String::new(); // The server has already interpreted and removed the reserved // directive. This request-scoped inline cleanup 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) + .and_then(|decision| decision.url_cleanup_script_tag(csp_nonce.as_deref())) { - snippet.push_str(&cleanup_tag); + interstitial.push_str(&cleanup_tag); } let ctx = IntegrationHtmlContext { request_host: &patterns.request_host, request_scheme: &patterns.request_scheme, origin_host: &patterns.origin_host, + csp_nonce: csp_nonce.as_deref(), document_state: &document_state, }; let diagnostics_requested = gpt_diagnostics @@ -442,15 +481,15 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso None => (None, tsjs::EMPTY_AUCTION_PROJECTION_JSON_V1), }; if let Some(debug_comment) = debug_comment { - snippet.push_str(debug_comment); - snippet.push('\n'); + interstitial.push_str(debug_comment); + interstitial.push('\n'); } // Non-TSJS vendor tags remain ordinary integration head inserts. for insert in integrations.head_inserts(&ctx) { - snippet.push_str(&insert); + interstitial.push_str(&insert); } let publisher_origin = patterns.replacement_url(); - let boot = tsjs::tsjs_bootstrap_fragment_v1( + let boot = tsjs::tsjs_bootstrap_fragment_with_nonce_and_interstitial_v1( tsjs::TsjsBootScriptConfigV1 { module_ids: &manifest_ids, integration_configs: &integration_configs, @@ -460,10 +499,12 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso gpt_diagnostics_active: diagnostics_active, }, &publisher_origin, + csp_nonce.as_deref(), + &interstitial, ) .or_else(|error| { log::error!("invalid TSJS document boot projection: {error:?}"); - tsjs::tsjs_bootstrap_fragment_v1( + tsjs::tsjs_bootstrap_fragment_with_nonce_and_interstitial_v1( tsjs::TsjsBootScriptConfigV1 { module_ids: &manifest_ids, integration_configs: &integration_configs, @@ -473,11 +514,12 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso gpt_diagnostics_active: diagnostics_active, }, &publisher_origin, + csp_nonce.as_deref(), + &interstitial, ) }) .unwrap_or_default(); - snippet.push_str(&boot); - el.prepend(&snippet, ContentType::Html); + el.prepend(&boot, ContentType::Html); injected_tsjs.set(true); } Ok(()) @@ -793,6 +835,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso origin_host: config.origin_host, request_host: config.request_host, request_scheme: config.request_scheme, + csp_nonce: config.csp_nonce, document_state, } } @@ -818,6 +861,7 @@ mod tests { fn create_test_config() -> HtmlProcessorConfig { HtmlProcessorConfig { csp_nonce_observed: None, + csp_nonce: None, body_close: BodyCloseInjection::None, origin_host: "origin.example.com".to_owned(), request_host: "test.example.com".to_owned(), @@ -828,6 +872,7 @@ mod tests { max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, render_trace_overlay: false, + suppress_datadome_client_side_tag: false, } } @@ -886,7 +931,7 @@ mod tests { } #[test] - fn integration_head_injector_prepends_after_tsjs_once() { + fn controller_precedes_live_head_inserts_which_precede_selected_runtime() { struct TestHeadInjector; impl IntegrationHeadInjector for TestHeadInjector { @@ -894,14 +939,18 @@ mod tests { "test" } - fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - vec!["".to_owned()] + fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec { + let nonce = ctx.csp_nonce.expect("should receive response CSP nonce"); + vec![format!( + "" + )] } } let html = "Test"; let mut config = create_test_config(); + config.csp_nonce = Some("response-nonce_123=".to_owned()); config.integrations = IntegrationRegistry::from_rewriters_with_head_injectors( Vec::new(), Vec::new(), @@ -923,7 +972,9 @@ mod tests { let processed = String::from_utf8(output).expect("output should be valid UTF-8"); let tsjs_marker = "id=\"trustedserver-js\""; + let controller_marker = "const __TSJS_SERVER_BOOT_TRANSPORT_V1__="; let head_marker = "window.__testHeadInjector=true"; + let nonce_marker = "" + "window.ddjskey={key};window.ddoptions={options};" )] } } @@ -853,13 +960,21 @@ fn build( return Ok(None); }; + let integration = DataDomeIntegration::try_new(config)?; + let protection_test_bypass_active = integration.active_protection_test_bypass().is_some(); log::info!( - "[datadome] Registering integration (sdk_origin: {}, rewrite_sdk: {})", - config.sdk_origin, - config.rewrite_sdk + "[datadome] Registering integration (sdk_origin: {}, rewrite_sdk: {}, enable_protection: {}, protection_test_bypass: {})", + integration.config.sdk_origin, + integration.config.rewrite_sdk, + integration.config.enable_protection, + if protection_test_bypass_active { + "active" + } else { + "disabled" + }, ); - Ok(Some(DataDomeIntegration::try_new(config)?)) + Ok(Some(integration)) } /// Register the `DataDome` integration with Trusted Server. @@ -1086,6 +1201,7 @@ mod tests { request_host: "publisher.example.com", request_scheme: "https", origin_host: "origin.example.com", + csp_nonce: None, document_state, } } @@ -1094,11 +1210,79 @@ mod tests { fn protection_secrets_are_absent_by_default() { let config = DataDomeConfig::default(); - assert_eq!(config.server_side_key_secret_store, "ts_secrets"); + assert!(config.server_side_key_secret_store.is_none()); + assert!(config.server_side_key_secret_name.is_none()); + assert!( + config.protection_test_bypass.is_none(), + "the temporary test bypass should be disabled by default" + ); + } + + #[test] + fn protection_test_bypass_deserializes_nested_configuration() { + let config: DataDomeConfig = toml::from_str( + r#" + enabled = true + enable_protection = true + + [protection_test_bypass] + enabled = true + credential_secret_store = "ts_secrets" + credential_secret_name = "datadome_test_bypass" + "#, + ) + .expect("should deserialize DataDome test bypass configuration"); + let bypass = config + .protection_test_bypass + .expect("should deserialize the nested test bypass configuration"); + + assert!(bypass.enabled, "should retain the enabled flag"); assert_eq!( - config.server_side_key_secret_name, - "datadome_server_side_key" + bypass.credential_secret_store.as_deref(), + Some("ts_secrets"), + "should accept the deprecated credential Secret Store" ); + assert_eq!( + bypass + .credential_secret_name + .as_ref() + .map(Redacted::expose) + .map(String::as_str), + Some("datadome_test_bypass"), + "should retain the configured credential secret reference" + ); + } + + #[test] + fn protection_test_bypass_requires_protection_and_resolved_credential() { + for (enable_protection, credential, expected_message) in [ + ( + false, + Some("test-bypass-credential-at-least-32-bytes"), + "requires enable_protection", + ), + (true, None, "credential_secret_name"), + (true, Some("short"), "at least 32 bytes"), + ] { + let mut config = test_config(); + config.enable_protection = enable_protection; + config.server_side_key_secret_name = + Some(Redacted::new("resolved-server-key".to_string())); + config.protection_test_bypass = Some(ProtectionTestBypassConfig { + enabled: true, + credential_secret_store: None, + credential_secret_name: credential.map(|value| Redacted::new(value.to_string())), + }); + + let err = match DataDomeIntegration::try_new(config) { + Ok(_) => panic!("should reject invalid protection test bypass configuration"), + Err(err) => err, + }; + assert!( + format!("{err:?}").contains(expected_message), + "should explain the invalid protection test bypass configuration" + ); + } } #[test] @@ -1226,7 +1410,10 @@ mod tests { config.client_side_configuration = serde_json::json!({ "ajaxListenerPath": true }); let integration = DataDomeIntegration::new(config); let document_state = crate::integrations::IntegrationDocumentState::default(); - let ctx = html_context_for_tests(&document_state); + let ctx = IntegrationHtmlContext { + csp_nonce: Some("response-nonce_123="), + ..html_context_for_tests(&document_state) + }; let inserts = integration.head_inserts(&ctx); @@ -1240,13 +1427,33 @@ mod tests { "should serialize DataDome client-side options" ); assert!( - inserts[0].contains(""), + inserts[0].contains( + "" + ), "should load the configured DataDome tag URL" ); + assert!( + inserts[0].starts_with("" + "try{{history.replaceState(history.state,'',{clean_path}+location.hash)}}catch(_error){{}}" )) } } @@ -143,7 +154,7 @@ mod head_seam_invariant_tests { // If a future change makes a script emit without also requiring the stamp, // this fails here rather than silently in a cached template. for decision in all_decisions() { - let injects = decision.url_cleanup_script_tag().is_some(); + let injects = decision.url_cleanup_script_tag(None).is_some(); if injects { assert!( decision.requires_private_no_store(), @@ -157,7 +168,7 @@ mod head_seam_invariant_tests { #[test] fn a_default_decision_injects_nothing() { let decision = GptDiagnosticsRequestDecision::default(); - assert_eq!(decision.url_cleanup_script_tag(), None); + assert_eq!(decision.url_cleanup_script_tag(None), None); assert!( !decision.requires_private_no_store(), "an inert decision should not force the response private" @@ -251,7 +262,8 @@ pub fn prepare_request( let mut decision = GptDiagnosticsRequestDecision { reserved_directive: had_reserved_query, - cleanup_browser_url: eligible_navigation && had_reserved_query, + clean_browser_path_and_query: (eligible_navigation && had_reserved_query) + .then_some(clean_path), ..GptDiagnosticsRequestDecision::default() }; if integration_enabled && eligible_navigation && had_reserved_query { @@ -504,12 +516,22 @@ mod tests { assert_eq!(request.headers()[header::COOKIE], "other=value"); assert_eq!(decision.boot_config_json(), r#"{"active":true}"#); let cleanup = decision - .url_cleanup_script_tag() + .url_cleanup_script_tag(None) .expect("a server-consumed directive should authorize inline cleanup"); assert!(cleanup.starts_with("")); assert!(cleanup.contains("history.replaceState")); assert!(!cleanup.contains(" src=")); + + let cleanup = decision + .url_cleanup_script_tag(Some("response-nonce_123=")) + .expect("a valid response nonce should authorize inline cleanup"); + assert!(cleanup.starts_with(r#"") + format!("") } #[cfg(test)] @@ -1142,13 +1224,19 @@ pub fn register_for_plan( config.excluded_gam_ad_unit_path_suffixes = canonical; validate_browser_config_for_startup(&config, &settings.proxy.allowed_domains)?; validate_browser_bidder_ownership(&config, plan)?; - let integration = PrebidIntegration::for_browser_plan(&config, plan); + let integration = PrebidIntegration::for_browser_plan(&config); + let browser_config = integration.browser_config_v1(if plan.enabled() { + plan.browser_bidder_codes().map(str::to_string).collect() + } else { + Vec::new() + }); Ok(Some( IntegrationRegistration::builder(PREBID_INTEGRATION_ID) .with_proxy(integration.clone()) .with_attribute_rewriter(integration.clone()) .with_head_injector(integration) .with_deferred_js() + .with_browser_config_v1(&browser_config)? .build(), )) } @@ -1162,7 +1250,12 @@ pub fn register( return Ok(None); }; - let browser_config = integration.browser_config_v1(); + let legacy_config = integration.legacy_config.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::Prebid { + message: "legacy test registration requires legacy config".to_string(), + }) + })?; + let browser_config = integration.browser_config_v1(legacy_config.bidders.clone()); Ok(Some( IntegrationRegistration::builder(PREBID_INTEGRATION_ID) @@ -1247,29 +1340,13 @@ impl IntegrationAttributeRewriter for PrebidIntegration { } } -fn serialize_injected_prebid_config(payload: &impl Serialize) -> String { - // Escape ` String { - format!( - r#""# - ) -} - impl IntegrationHeadInjector for PrebidIntegration { fn integration_id(&self) -> &'static str { PREBID_INTEGRATION_ID } - fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - vec![self.external_bundle_script_tag()] + fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec { + vec![self.external_bundle_script_tag(ctx.csp_nonce)] } } @@ -2054,6 +2131,9 @@ fn parse_planned_prebid_bid( Ok(AuctionBid { slot_id, + candidate_id: None, + candidate_provider: None, + renderer_reservation_id: None, price: Some(price), currency: DEFAULT_CURRENCY.to_string(), creative, @@ -3362,7 +3442,13 @@ mod tests { } fn browser_config_json(integration: &PrebidIntegration) -> String { - serde_json::to_string(&integration.browser_config_v1()) + let bidders = integration + .legacy_config + .as_ref() + .expect("should retain legacy config for browser projection tests") + .bidders + .clone(); + serde_json::to_string(&integration.browser_config_v1(bidders)) .expect("browser-safe Prebid config should serialize") } @@ -4660,8 +4746,7 @@ external_bundle_sri = "sha384-AAAA" #[test] #[allow(clippy::field_reassign_with_default)] - fn prepared_browser_injection_uses_only_plan_routes_and_browser_timeout_debug() { - let integration = PrebidIntegration::new(base_config()); + fn browser_projection_uses_only_plan_routes_and_browser_timeout_debug() { let mut browser_config = PrebidIntegrationConfig::default(); browser_config.account_id = Some("browser-account".to_string()); browser_config.timeout_ms = 1750; @@ -4715,25 +4800,38 @@ external_bundle_sri = "sha384-AAAA" }) .expect("should compile plan while browser integration is not part of compilation"); - let inserts = integration.head_inserts_for_plan(&browser_config, &plan); - let script = &inserts[0]; + let integration = PrebidIntegration::for_browser_plan(&browser_config); + let product = serde_json::to_string( + &integration + .browser_config_v1(plan.browser_bidder_codes().map(str::to_string).collect()), + ) + .expect("should serialize planned Prebid browser config"); - assert!(script.contains(r#""timeout":1750,"debug":false"#)); + assert!(product.contains(r#""timeout":1750,"debug":false"#)); assert!( - script.contains(r#""serverSideBidders":["primaryRoute","secondaryRoute"]"#), - "should inject deterministic browser route codes: {script}" + product.contains(r#""bidders":["primaryRoute","secondaryRoute"]"#), + "should project deterministic browser route codes: {product}" ); - assert!(!script.contains("pbs-primary")); - assert!(!script.contains("pbs-secondary")); - assert!(!script.contains("3000")); - assert!(!script.contains("4000")); + assert!(!product.contains("pbs-primary")); + assert!(!product.contains("pbs-secondary")); + assert!(!product.contains("3000")); + assert!(!product.contains("4000")); let disabled_plan = plan.clone().with_enabled(false); - let disabled_inserts = integration.head_inserts_for_plan(&browser_config, &disabled_plan); + let disabled_product = + serde_json::to_string(&integration.browser_config_v1(if disabled_plan.enabled() { + disabled_plan + .browser_bidder_codes() + .map(str::to_string) + .collect() + } else { + Vec::new() + })) + .expect("should serialize disabled Prebid browser config"); assert!( - disabled_inserts[0].contains(r#""serverSideBidders":[]"#), + disabled_product.contains(r#""bidders":[]"#), "auction kill switch should suppress browser server-side bidders: {}", - disabled_inserts[0] + disabled_product ); } @@ -4790,6 +4888,7 @@ external_bundle_sri = "sha384-AAAA" request_host: "pub.example", request_scheme: "https", origin_host: "origin.example", + csp_nonce: Some("response-nonce_123="), document_state: &document_state, }; @@ -4810,6 +4909,11 @@ external_bundle_sri = "sha384-AAAA" "bundle script should include configured SRI: {}", inserts[0] ); + assert!( + inserts[0].contains("nonce=\"response-nonce_123=\""), + "bundle script should propagate the response CSP nonce: {}", + inserts[0] + ); assert!( !inserts[0].contains("crossorigin"), "same-origin bundle script should not include crossorigin: {}", @@ -4828,6 +4932,7 @@ external_bundle_sri = "sha384-AAAA" request_host: "pub.example", request_scheme: "https", origin_host: "origin.example", + csp_nonce: None, document_state: &document_state, }; diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 8e128ef0f..a7367c25b 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1,4 +1,4 @@ -use std::any::Any; +use std::any::{Any, TypeId}; use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex}; @@ -546,9 +546,28 @@ pub struct IntegrationHtmlContext<'a> { pub request_host: &'a str, pub request_scheme: &'a str, pub origin_host: &'a str, + /// Response-authenticated CSP nonce for server-generated executable tags. + pub csp_nonce: Option<&'a str>, pub document_state: &'a IntegrationDocumentState, } +/// Build a safe nonce attribute for a server-generated executable tag. +pub(crate) fn html_script_nonce_attribute(csp_nonce: Option<&str>) -> Option { + match csp_nonce { + Some(nonce) + if !nonce.is_empty() + && nonce.len() <= 256 + && nonce.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'_' | b'-' | b'=') + }) => + { + Some(format!(r#" nonce="{nonce}""#)) + } + Some(_) => None, + None => Some(String::new()), + } +} + /// Trait for integration-provided HTML post-processors. /// These run after streaming HTML processing to handle cases that require /// access to the complete HTML (e.g., cross-script RSC T-chunks). @@ -1061,6 +1080,7 @@ pub struct ProxyDispatchInput<'a> { pub struct IntegrationRegistry { inner: Arc, creative_boot: crate::tsjs::CreativeBootConfigV1, + plan: Option>, } impl Default for IntegrationRegistry { @@ -1071,6 +1091,7 @@ impl Default for IntegrationRegistry { Self { inner: Arc::new(inner), creative_boot, + plan: None, } } } @@ -1101,50 +1122,58 @@ impl IntegrationRegistry { plan: Arc, ) -> Result> { let mut inner = IntegrationRegistryInner::default(); + let mut registrations = Vec::new(); let mut integration_configs_v1 = Vec::new(); let creative_boot = crate::tsjs::creative_boot_config_v1(settings)?; let aps_proxy: Arc = - Arc::new(super::aps::ApsV1Integration::from_settings(settings)?); + Arc::new(super::aps::ApsV1Integration::from_plan(&plan)); inner .reserved_proxies .push(("/integrations/aps", aps_proxy)); + if let Some(registration) = crate::integrations::prebid::register_for_plan(settings, &plan)? + { + registrations.push(registration); + } + if let Some(registration) = crate::integrations::aps::register_for_plan(settings, &plan)? { + registrations.push(registration); + } for builder in crate::integrations::builders() { if let Some(registration) = (builder.build)(settings)? { debug_assert_eq!( registration.integration_id, builder.id, "integration builder ID should match registration ID" ); - inner - .enabled_integration_ids - .push(registration.integration_id); - match ( - crate::tsjs::is_integration_config_product_v1(registration.integration_id), - registration.browser_config_v1.clone(), - ) { - (true, Some(config)) => { - integration_configs_v1.push((registration.integration_id, config)); - } - (true, None) => { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Integration {} is missing its browser config projection", - registration.integration_id - ), - })); - } - (false, Some(_)) => { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Integration {} cannot emit a generic browser config", - registration.integration_id - ), - })); - } - (false, None) => {} - } + registrations.push(registration); + } + } for registration in registrations { + match ( + crate::tsjs::is_integration_config_product_v1(registration.integration_id), + registration.browser_config_v1.clone(), + ) { + (true, Some(config)) => { + integration_configs_v1.push((registration.integration_id, config)); + } + (true, None) => { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Integration {} is missing its browser config projection", + registration.integration_id + ), + })); + } + (false, Some(_)) => { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Integration {} cannot emit a generic browser config", + registration.integration_id + ), + })); + } + (false, None) => {} + } inner .enabled_integration_ids .push(registration.integration_id); @@ -1223,9 +1252,18 @@ impl IntegrationRegistry { Ok(Self { inner: Arc::new(inner), creative_boot, + plan: Some(plan), }) } + /// Return whether this registry and another consumer share the same plan allocation. + #[must_use] + pub fn shares_plan(&self, plan: &Arc) -> bool { + self.plan + .as_ref() + .is_some_and(|owned| Arc::ptr_eq(owned, plan)) + } + fn reserved_proxy(&self, path: &str) -> Option<&Arc> { self.inner .reserved_proxies @@ -1687,6 +1725,7 @@ impl IntegrationRegistry { Self { inner: Arc::new(inner), creative_boot, + plan: None, } } @@ -1722,6 +1761,7 @@ impl IntegrationRegistry { Self { inner: Arc::new(inner), creative_boot, + plan: None, } } @@ -1758,6 +1798,7 @@ impl IntegrationRegistry { Self { inner: Arc::new(inner), creative_boot, + plan: None, } } @@ -1790,6 +1831,7 @@ impl IntegrationRegistry { Self { inner: Arc::new(inner), creative_boot, + plan: None, } } @@ -1862,6 +1904,7 @@ impl IntegrationRegistry { Self { inner: Arc::new(inner), creative_boot, + plan: None, } } } @@ -1996,6 +2039,7 @@ mod tests { request_host: "proxy.example.com", request_scheme: "https", origin_host: "origin.example.com", + csp_nonce: None, document_state: &document_state, }; @@ -2055,22 +2099,111 @@ mod tests { assert!(registry.has_reserved_path("/integrations/aps/malformed/path")); assert!(!registry.has_reserved_path("/integrations/apsx/runner.js")); assert!(!registry.has_reserved_path("/integrations/aps-legacy")); + assert!( + !registry + .registered_integrations() + .iter() + .any(|integration| integration.id == "aps"), + "a plan without an APS profile must not register APS" + ); + + for path in [ + "/integrations/aps/renderer/v2", + "/integrations/aps/runner.js", + ] { + let request = Request::builder() + .method(Method::GET) + .uri(path) + .header(HEADER_X_TS_EC.clone(), "caller-controlled") + .body(EdgeBody::empty()) + .expect("should build reserved APS request"); + let response = futures::executor::block_on(registry.handle_reserved_proxy( + &settings, + &noop_services(), + request, + )) + .expect("reserved family should be handled") + .expect("disabled APS response should be local"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } + } + + #[test] + fn aps_profile_alone_registers_and_activates_reserved_routes() { + let settings = create_test_settings(); + assert!( + settings + .integration_config::("aps") + .expect("APS browser config lookup should succeed") + .is_none(), + "the activation proof must not depend on integrations.aps" + ); + let plan = crate::auction::AuctionPlan::compile(crate::auction::plan::AuctionPlanConfig { + timeout_ms: 1_000, + providers: BTreeMap::from([( + "aps-profile-only" + .parse() + .expect("should parse APS provider ID"), + crate::auction::plan::ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/bid".to_string(), + timeout_ms: None, + routing: crate::auction::plan::RoutingMode::AllEligible, + notifications: crate::auction::plan::NotificationConfig::default(), + profile_config: serde_json::json!({ + "account_id": "example-account" + }), + }, + )]), + ..crate::auction::plan::AuctionPlanConfig::default() + }) + .expect("APS-only plan should compile"); + let registry = IntegrationRegistry::with_plan(&settings, Arc::new(plan)) + .expect("APS-only registry should build"); - let request = Request::builder() + assert!( + registry + .registered_integrations() + .iter() + .any(|integration| integration.id == "aps"), + "planned APS registration must be retained" + ); + + let renderer_request = Request::builder() .method(Method::GET) .uri("/integrations/aps/renderer/v2") - .header(HEADER_X_TS_EC.clone(), "caller-controlled") .body(EdgeBody::empty()) - .expect("should build reserved APS request"); - let response = futures::executor::block_on(registry.handle_reserved_proxy( + .expect("should build APS renderer request"); + let renderer_response = futures::executor::block_on(registry.handle_reserved_proxy( + &settings, + &noop_services(), + renderer_request, + )) + .expect("reserved renderer should be handled") + .expect("profile-only APS renderer should respond locally"); + assert_eq!(renderer_response.status(), StatusCode::OK); + assert_eq!( + renderer_response.headers()[header::CACHE_CONTROL], + "public, max-age=31536000, immutable" + ); + + let runner_request = Request::builder() + .method(Method::POST) + .uri("/integrations/aps/runner.js") + .body(EdgeBody::empty()) + .expect("should build APS runner request"); + let runner_response = futures::executor::block_on(registry.handle_reserved_proxy( &settings, &noop_services(), - request, + runner_request, )) - .expect("reserved family should be handled") - .expect("disabled APS response should be local"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + .expect("reserved runner should be handled") + .expect("enabled APS runner should reject invalid methods locally"); + assert_eq!(runner_response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(runner_response.headers()[header::ALLOW], "GET"); + assert_eq!(runner_response.headers()[header::CACHE_CONTROL], "no-store"); } #[test] @@ -2717,14 +2850,27 @@ mod tests { .integrations .insert_config("gpt", &serde_json::json!({})) .expect("should enable GPT"); - settings - .integrations - .insert_config( - "aps", - &serde_json::json!({ "enabled": true, "account_id": "test-account" }), - ) - .expect("should enable APS"); - let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let plan = crate::auction::AuctionPlan::compile(crate::auction::plan::AuctionPlanConfig { + timeout_ms: 1_000, + providers: BTreeMap::from([( + "aps-mask".parse().expect("should parse APS provider ID"), + crate::auction::plan::ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/bid".to_string(), + timeout_ms: None, + routing: crate::auction::plan::RoutingMode::AllEligible, + notifications: crate::auction::plan::NotificationConfig::default(), + profile_config: serde_json::json!({ + "account_id": "test-account" + }), + }, + )]), + ..crate::auction::plan::AuctionPlanConfig::default() + }) + .expect("should compile APS mask plan"); + let registry = IntegrationRegistry::with_plan(&settings, Arc::new(plan)) + .expect("should create registry"); let masks = registry.tsjs_first_display_masks(); let catalog = trusted_server_js::all_first_display_ids(); @@ -2749,6 +2895,7 @@ mod tests { fixed | owner | aps | gpt, fixed | gpt | prebid, fixed | owner | gpt | prebid, + fixed | owner | aps | gpt | prebid, ], "registry should enumerate every configuration-reachable mask admitted by the fixed transfer ceiling" ); @@ -2992,9 +3139,7 @@ mod tests { ( "aps", serde_json::json!({ - "enabled": true, - "account_id": "private-aps-account", - "endpoint": "https://private-aps.example/bid" + "enabled": true }), ), ( @@ -3039,10 +3184,8 @@ mod tests { "prebid", serde_json::json!({ "enabled": true, - "server_url": "https://private-pbs.example/openrtb2/auction", "account_id": "browser-account", - "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", - "bidders": ["mocktioneer"] + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js" }), ), ( @@ -3066,7 +3209,27 @@ mod tests { .expect("test integration config should insert"); } - let registry = IntegrationRegistry::new(&settings).expect("registry should build"); + let plan = crate::auction::AuctionPlan::compile(crate::auction::plan::AuctionPlanConfig { + timeout_ms: 1_000, + providers: BTreeMap::from([( + "aps-private".parse().expect("should parse APS provider ID"), + crate::auction::plan::ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://private-aps.example/bid".to_string(), + timeout_ms: None, + routing: crate::auction::plan::RoutingMode::AllEligible, + notifications: crate::auction::plan::NotificationConfig::default(), + profile_config: serde_json::json!({ + "account_id": "private-aps-account" + }), + }, + )]), + ..crate::auction::plan::AuctionPlanConfig::default() + }) + .expect("should compile browser projection plan"); + let registry = IntegrationRegistry::with_plan(&settings, Arc::new(plan)) + .expect("registry should build"); let module_ids = registry.tsjs_catalog_module_ids(TsjsCatalogSelectionV1::default()); let carrier = registry .tsjs_integration_configs_v1(&module_ids) @@ -3090,7 +3253,6 @@ mod tests { "private-lockr-app", "private-org", "private-workspace", - "private-pbs.example", "private_sourcepoint_cookie", "private-testlight.example", ] { diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 76621baf7..437056e30 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -72,6 +72,7 @@ pub mod streaming_processor; pub mod streaming_replacer; pub mod test_support; pub mod tester_cookie; +pub mod trace_cookie; pub mod tsjs; #[cfg(test)] diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 42606d412..eec394cc8 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -878,6 +878,27 @@ pub(crate) fn build_services_with_config( .build() } +/// Build test services with caller-supplied configuration and secrets plus an +/// attested client IP. +pub(crate) fn build_services_with_config_and_secret_and_client_ip( + config_store: impl PlatformConfigStore + 'static, + secret_store: impl PlatformSecretStore + 'static, + client_ip: IpAddr, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(config_store)) + .secret_store(Arc::new(secret_store)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: Some(client_ip), + ..ClientInfo::default() + }) + .build() +} + pub(crate) fn noop_services() -> RuntimeServices { build_services_with_config(NoopConfigStore) } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 29a167fba..ec3042c7c 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -341,6 +341,8 @@ pub struct ProxyRequestConfig<'a> { pub copy_request_headers: bool, /// When true, stream the origin response without HTML/CSS rewrites. pub stream_passthrough: bool, + /// Whether every outbound hop must bypass the platform response cache. + pub bypass_cache: bool, /// Domains allowed for the initial request and any redirects. /// /// **Open mode** (`&[]`): every host is permitted. Most integration proxies pass @@ -369,6 +371,7 @@ impl<'a> ProxyRequestConfig<'a> { headers: Vec::new(), copy_request_headers: true, stream_passthrough: false, + bypass_cache: false, allowed_domains: &[], require_https: false, } @@ -402,6 +405,13 @@ impl<'a> ProxyRequestConfig<'a> { self } + /// Bypass the platform response cache for the initial request and redirects. + #[must_use] + pub fn with_cache_bypass(mut self) -> Self { + self.bypass_cache = true; + self + } + /// Disable EC ID query-param forwarding to the target URL. #[must_use] pub fn without_ec_id(mut self) -> Self { @@ -733,6 +743,7 @@ struct ProxyRequestHeaders<'a> { struct ProxyRedirectPolicy<'a> { follow_redirects: bool, stream_passthrough: bool, + bypass_cache: bool, allowed_domains: &'a [String], require_https: bool, } @@ -761,6 +772,7 @@ pub async fn proxy_request( headers, copy_request_headers, stream_passthrough, + bypass_cache, allowed_domains, require_https, } = config; @@ -788,6 +800,7 @@ pub async fn proxy_request( ProxyRedirectPolicy { follow_redirects, stream_passthrough, + bypass_cache, allowed_domains, require_https, }, @@ -1329,10 +1342,14 @@ async fn proxy_with_redirects( message: "failed to build proxy request".to_string(), })?; + let mut platform_request = PlatformHttpRequest::new(edge_req, backend_name); + if redirect_policy.bypass_cache { + platform_request = platform_request.with_cache_bypass(); + } let platform_resp = request_headers .services .http_client() - .send(PlatformHttpRequest::new(edge_req, backend_name)) + .send(platform_request) .await .change_context(TrustedServerError::Proxy { message: "Failed to proxy".to_string(), @@ -1490,6 +1507,7 @@ pub async fn handle_first_party_proxy( headers: Vec::new(), copy_request_headers: true, stream_passthrough: false, + bypass_cache: false, allowed_domains: &settings.proxy.allowed_domains, require_https: false, }, @@ -3671,6 +3689,7 @@ mod tests { headers: Vec::new(), copy_request_headers: false, stream_passthrough: false, + bypass_cache: false, allowed_domains: &[], require_https: false, }, @@ -3712,6 +3731,7 @@ mod tests { headers: Vec::new(), copy_request_headers: false, stream_passthrough: false, + bypass_cache: false, allowed_domains: &[], require_https: false, }, @@ -3758,6 +3778,7 @@ mod tests { headers: Vec::new(), copy_request_headers: false, stream_passthrough: false, + bypass_cache: false, allowed_domains: &[], require_https: false, }, @@ -3807,6 +3828,7 @@ mod tests { headers: Vec::new(), copy_request_headers: true, stream_passthrough: false, + bypass_cache: false, allowed_domains: &[], require_https: false, }, @@ -3872,6 +3894,7 @@ mod tests { headers: Vec::new(), copy_request_headers: false, stream_passthrough: false, + bypass_cache: false, allowed_domains: &[], require_https: false, }, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 03b3d41ce..29d84a12b 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -21,6 +21,7 @@ //! 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::atomic::{AtomicBool, Ordering}; @@ -39,7 +40,11 @@ use crate::auction::endpoints::{ use crate::auction::formats::{ coordinated_cutover_v1::CanonicalBrowserAuctionProjectionV1, sanitize_publisher_page_url, }; -use crate::auction::orchestrator::{AuctionOrchestrator, DispatchedAuction, OrchestrationResult}; +use crate::auction::orchestrator::{ + AuctionOrchestrator, DispatchedAuction, ERROR_TYPE_ALL, ERROR_TYPE_HTTP_STATUS, + ERROR_TYPE_LAUNCH_FAILED, ERROR_TYPE_PARSE_RESPONSE, ERROR_TYPE_TIMEOUT, ERROR_TYPE_TRANSPORT, + OrchestrationResult, ResolvedDispatchOutcome, +}; use crate::auction::telemetry::{ AuctionObservationContext, AuctionSource, AuctionTerminalOutcome, build_auction_events, emit_auction_events_best_effort_lazy, @@ -51,7 +56,9 @@ use crate::auction::types::{ DeviceInfo, PublisherInfo, SiteInfo, SlotAuctionDecisionV1, SystemAuctionIdentityGenerator, UserInfo, mint_response_unique_base64url_identity, }; -use crate::cache_policy::{EdgeCacheHeader, cache_control_headers_are_private_or_no_store}; +use crate::cache_policy::{ + CachePolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store, +}; use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; use crate::cookies::handle_request_cookies; @@ -63,7 +70,6 @@ use crate::error::TrustedServerError; use crate::html_processor::BodyCloseInjection; use crate::http_util::{RequestInfo, is_navigation_request, serve_static_with_etag}; use crate::integrations::IntegrationRegistry; -use crate::integrations::aps::ApsConfig; use crate::platform::{ GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, VarySpec, contains_publisher_esi_directive, @@ -569,7 +575,7 @@ pub fn handle_tsjs_dynamic( else { return Ok(tsjs_not_found_response()); }; - let mut resp = serve_precomputed_tsjs_artifact(artifact, req); + let mut resp = serve_precomputed_tsjs_artifact(artifact, req, edge_header); strip_tsjs_head_body(&mut resp, req.method()); apply_tsjs_success_headers(&mut resp); return Ok(resp); @@ -583,7 +589,7 @@ pub fn handle_tsjs_dynamic( let Some(artifact) = integration_registry.tsjs_static_artifact(requested_hash) else { return Ok(tsjs_not_found_response()); }; - let mut resp = serve_precomputed_tsjs_artifact(artifact, req); + let mut resp = serve_precomputed_tsjs_artifact(artifact, req, edge_header); strip_tsjs_head_body(&mut resp, req.method()); apply_tsjs_success_headers(&mut resp); return Ok(resp); @@ -663,6 +669,7 @@ fn apply_tsjs_success_headers(response: &mut Response) { fn serve_precomputed_tsjs_artifact( artifact: &crate::tsjs::TsjsStaticArtifactV1, req: &Request, + edge_header: EdgeCacheHeader, ) -> Response { let etag = format!("\"sha256-{}\"", artifact.hash()); let not_modified = req @@ -671,28 +678,26 @@ fn serve_precomputed_tsjs_artifact( .and_then(|value| value.to_str().ok()) .is_some_and(|value| value == etag); let builder = Response::builder() - .header( - header::CACHE_CONTROL, - "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", - ) - .header("surrogate-control", "max-age=300") .header(header::ETAG, etag) .header(header::VARY, "Accept-Encoding"); - if not_modified { - return builder + let mut response = if not_modified { + builder .status(StatusCode::NOT_MODIFIED) .body(EdgeBody::empty()) - .expect("should build 304 TSJS response"); - } - - builder - .status(StatusCode::OK) - .header( - header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - ) - .body(EdgeBody::from_bytes(artifact.body().clone())) - .expect("should build TSJS response") + .expect("should build 304 TSJS response") + } else { + builder + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .body(EdgeBody::from_bytes(artifact.body().clone())) + .expect("should build TSJS response") + }; + CachePolicy::public_immutable(Duration::from_secs(31_536_000)) + .apply_to_headers(response.headers_mut(), edge_header); + response } /// Extract a catalogued deferred module ID from its only admitted filename. @@ -727,10 +732,13 @@ struct ProcessResponseParams<'a> { integration_registry: &'a IntegrationRegistry, ad_slots_script: Option<&'a str>, ad_bids_state: &'a Arc>>, + suppress_datadome_client_side_tag: bool, gpt_diagnostics: Option<&'a crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision>, render_trace_overlay: bool, shared_template_authorized: bool, + csp_nonce_observed: Option<&'a Arc>, + csp_nonce: Option<&'a str>, } struct PublisherBodyProcessor { @@ -754,13 +762,16 @@ impl PublisherBodyProcessor { settings, integration_registry, ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), - ad_bids_state: Arc::clone(¶ms.ad_bids_state), + ad_bids_state: Arc::clone(params.ad_bids_state.script_cell()), + suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, gpt_diagnostics: params.gpt_diagnostics.clone(), render_trace_overlay: params.render_trace_overlay, assembly_mode: effective_assembly_mode( settings, params.template_cache_key.is_some(), ), + csp_nonce_observed: params.csp_nonce_observed.clone(), + csp_nonce: response_csp_nonce(¶ms.policy_headers), })?) } else if is_rsc_flight { Box::new(RscFlightUrlRewriter::new( @@ -838,12 +849,15 @@ fn process_response_streaming( integration_registry: params.integration_registry, ad_slots_script: params.ad_slots_script.map(str::to_string), ad_bids_state: params.ad_bids_state.clone(), + suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, gpt_diagnostics: params.gpt_diagnostics.cloned(), render_trace_overlay: params.render_trace_overlay, assembly_mode: effective_assembly_mode( params.settings, params.shared_template_authorized, ), + csp_nonce_observed: params.csp_nonce_observed.cloned(), + csp_nonce: params.csp_nonce.map(str::to_owned), })?; StreamingPipeline::new(config, processor) .with_max_pending_decoded_bytes(max_pending_decoded_bytes) @@ -1071,6 +1085,8 @@ impl Drop for DispatchedAuctionGuard { /// The single inert marker stored in a reader-neutral C2 template. pub const AD_ASSEMBLY_SEAM: &str = ""; +/// Transform-owned placeholder replaced only after collision and nonce checks. +pub(crate) const TEMPLATE_SEAM_PLACEHOLDER: &str = ""; fn configured_assembly_mode(settings: &Settings) -> AssemblyMode { settings @@ -1103,7 +1119,7 @@ fn effective_assembly_mode(settings: &Settings, shared_template_authorized: bool fn template_injection(mode: AssemblyMode) -> BodyCloseInjection { match mode { AssemblyMode::Inline => BodyCloseInjection::None, - AssemblyMode::Esi => BodyCloseInjection::Marker(AD_ASSEMBLY_SEAM.to_string()), + AssemblyMode::Esi => BodyCloseInjection::Marker(TEMPLATE_SEAM_PLACEHOLDER.to_string()), } } @@ -1127,9 +1143,12 @@ struct HtmlStreamProcessorParams<'a> { integration_registry: &'a IntegrationRegistry, ad_slots_script: Option, ad_bids_state: Arc>>, + suppress_datadome_client_side_tag: bool, gpt_diagnostics: Option, render_trace_overlay: bool, assembly_mode: AssemblyMode, + csp_nonce_observed: Option>, + csp_nonce: Option, } fn create_html_stream_processor( @@ -1148,6 +1167,12 @@ fn create_html_stream_processor( .with_gpt_diagnostics(params.gpt_diagnostics) .with_body_close(template_injection(params.assembly_mode)) .with_render_trace_overlay(params.render_trace_overlay) + .with_csp_nonce_observer( + matches!(params.assembly_mode, AssemblyMode::Esi) + .then_some(params.csp_nonce_observed) + .flatten(), + ) + .with_csp_nonce(params.csp_nonce) .with_datadome_client_tag_suppression(params.suppress_datadome_client_side_tag); Ok(create_html_processor(config)) @@ -1339,11 +1364,28 @@ pub struct OwnedProcessResponseParams { pub(crate) dispatched_auction: Option, /// Price granularity used to bucket bids in the browser auction projection. pub(crate) price_granularity: PriceGranularity, + /// Whether to omit Trusted Server's automatic `DataDome` client-side tag. + pub(crate) suppress_datadome_client_side_tag: bool, /// Request-scoped conditional diagnostics delivery decision. pub(crate) gpt_diagnostics: Option, /// Server-owned request-scoped render-trace overlay decision. pub(crate) render_trace_overlay: bool, + /// Set by the transform when publisher markup carries a response-bound CSP nonce. + pub(crate) csp_nonce_observed: Option>, +} + +/// Response-authorized template cache insert inputs. +pub(crate) struct AuthorizedTemplateStore { + reservation: crate::platform::TemplateCacheReservation, + expires_at: Instant, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TemplateStoreOutcome { + Stored, + Expired, + Error, } /// Buffers a [`PublisherResponse`] into a single [`Response`], collecting the @@ -1463,7 +1505,7 @@ pub async fn buffer_publisher_response_async( let payload: Cow<'_, str> = if shared_response_authorized { Cow::Borrowed(AD_ASSEMBLY_SEAM) } else { - Cow::Owned(seam_script_for(¶ms)) + Cow::Owned(seam_script_for(¶ms, settings, integration_registry)) }; match replace_seam_placeholder(bytes, payload.as_bytes()) { Ok(bytes) => bytes, @@ -1522,15 +1564,19 @@ pub async fn buffer_publisher_response_async( }, ); } - let (bytes, assembly_state) = assemble_if_shared( - was_authorized, - !contains_publisher_esi, - settings, - integration_registry, - ¶ms, - services, - bytes, - )?; + let (bytes, assembly_state) = if bypasses_shared_template { + (bytes, Some(AssemblyResponseState::ByteSeamFallback)) + } else { + assemble_if_shared( + shared_response_authorized, + shared_response_authorized, + settings, + integration_registry, + ¶ms, + services, + bytes, + )? + }; if let Some(state) = assembly_state { set_assembly_response_state(&mut response, state); } @@ -1702,17 +1748,6 @@ fn response_carries_a_seam_marker(was_authorized: bool, settings: &Settings) -> was_authorized && mode_emits_seam_marker(configured_assembly_mode(settings)) } -/// Whether bytes contain an ESI directive that came from publisher content. -/// -/// TS's stored seam is an inert HTML comment, so any ` bool { - bytes - .windows(b" bool { && status != StatusCode::NOT_MODIFIED } +/// Prevent shared caches from replaying tag-suppressed HTML to other clients. +fn apply_datadome_client_tag_cache_privacy( + response: &mut Response, + method: &Method, + suppress_datadome_client_side_tag: bool, + content_type: &str, +) { + if suppress_datadome_client_side_tag + && response_carries_body(method, response.status()) + && is_html_content_type(content_type) + { + enforce_synthesized_html_cache_privacy(response); + } +} + /// Drop a bodiless response's body and correct its framing headers. /// /// The response keeps no body, and its `Content-Length` is corrected where the @@ -2469,6 +2522,7 @@ pub fn stream_publisher_body( settings: &Settings, integration_registry: &IntegrationRegistry, ) -> Result<(), Report> { + let csp_nonce = response_csp_nonce(¶ms.policy_headers); let borrowed = ProcessResponseParams { content_encoding: ¶ms.content_encoding, origin_host: ¶ms.origin_host, @@ -2479,10 +2533,13 @@ pub fn stream_publisher_body( content_type: ¶ms.content_type, integration_registry, ad_slots_script: params.ad_slots_script.as_deref(), - ad_bids_state: ¶ms.ad_bids_state, + ad_bids_state: params.ad_bids_state.script_cell(), + suppress_datadome_client_side_tag: params.suppress_datadome_client_side_tag, gpt_diagnostics: params.gpt_diagnostics.as_ref(), render_trace_overlay: params.render_trace_overlay, shared_template_authorized: params.template_cache_key.is_some(), + csp_nonce_observed: params.csp_nonce_observed.as_ref(), + csp_nonce: csp_nonce.as_deref(), }; let input_compression = Compression::from_content_encoding(¶ms.content_encoding); let output_compression = if params.template_cache_key.is_some() { @@ -2647,6 +2704,16 @@ pub(crate) fn is_prefetch_request(req: &Request) -> bool { /// Returns true only when the publisher request should run the full /// server-side ad stack: auction dispatch plus initial ad-slot injection. +fn is_server_side_ad_eligible_navigation( + is_get: bool, + is_navigation: bool, + is_prefetch: bool, + is_bot: bool, + consent_allows_auction: bool, +) -> bool { + is_get && is_navigation && !is_prefetch && !is_bot && consent_allows_auction +} + pub(crate) fn should_run_server_side_ad_stack( is_get: bool, is_navigation: bool, @@ -2656,12 +2723,13 @@ pub(crate) fn should_run_server_side_ad_stack( consent_allows_auction: bool, auction_enabled: bool, ) -> bool { - is_get - && is_navigation - && !is_prefetch - && !is_bot - && has_active_matched_slots - && consent_allows_auction + is_server_side_ad_eligible_navigation( + is_get, + is_navigation, + is_prefetch, + is_bot, + consent_allows_auction, + ) && has_active_matched_slots && auction_enabled } /// Write winning bids from an auction result into the shared `ad_bids_state` lock. @@ -2717,6 +2785,17 @@ impl AdBidsState { } } +fn mint_browser_auction_id( + identity_generator: &dyn AuctionIdentityGenerator, +) -> Result> { + mint_response_unique_base64url_identity(identity_generator, &mut HashSet::new(), "a1_", 9, 8) + .ok_or_else(|| { + Report::new(TrustedServerError::Auction { + message: "Failed to mint browser auction identity".to_string(), + }) + }) +} + /// Write the one exact initial browser projection into the pre-core boot state. pub(crate) fn write_projection_to_state( result: &OrchestrationResult, @@ -3177,10 +3256,6 @@ async fn collect_non_html_auction( services: &RuntimeServices, settings: &Settings, ) { - let auction_id = telemetry - .auction_request - .as_ref() - .and_then(|_| diagnostics_auction_id(settings)); let placeholder = mediator_placeholder_request(); let result = orchestrator .collect_dispatched_auction( @@ -3315,9 +3390,7 @@ pub async fn handle_publisher_request( let gpt_diagnostics = crate::integrations::gpt_diagnostics::prepare_request(settings, &mut req)?; let render_trace_overlay = crate::trace_cookie::render_trace_overlay_active(&req); - let aps_enabled = settings - .integration_config::("aps")? - .is_some_and(|config| config.enabled); + let aps_enabled = auction.orchestrator.is_enabled() && auction.orchestrator.has_profile("aps"); // Prebid.js requests are not intercepted here anymore. The HTML processor removes // publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled. @@ -3463,6 +3536,14 @@ pub async fn handle_publisher_request( .as_ref() .map(|co| co.price_granularity) .unwrap_or_default(); + let browser_slots_json = should_run_ad_stack + .then(|| { + settings + .creative_opportunities + .as_ref() + .map(|config| build_browser_slots_json(&matched_slots, config, &request_path)) + }) + .flatten(); // Dispatch SSP bid requests while req still has the original client headers // (User-Agent, x-forwarded-for, cookies, etc.). The borrow ends when @@ -3524,13 +3605,80 @@ pub async fn handle_publisher_request( provider_responses: None, services, }; - let dispatched = auction + let dispatch_outcome = auction .orchestrator .dispatch_auction(&auction_request, &auction_context) .await; - auction_request_for_telemetry = Some(auction_request); - auction_observation = Some(observation); - Some(dispatched) + match auction + .orchestrator + .resolve_dispatch_outcome(&auction_request, dispatch_outcome) + { + Ok(ResolvedDispatchOutcome::InFlight(dispatched)) => { + auction_request_for_telemetry = Some(auction_request); + auction_observation = Some(observation); + Some(*dispatched) + } + Ok(ResolvedDispatchOutcome::Complete(result)) => { + let result = *result; + let delivered_winner_slots = write_projection_to_state( + &result, + price_granularity, + &ad_bids_state, + settings, + &request_origin(request_scheme, request_host), + browser_slots_json.as_deref(), + ); + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: &auction_request, + result: &result, + delivered_winner_slots: Some(&delivered_winner_slots), + }, + ) + }) + .await; + if settings.debug.auction_html_comment { + prepend_auction_debug_comment( + "dispatch", + &result, + &ad_bids_state, + &settings.debug.auction_html_comment_options, + ); + } + None + } + Err(error) => { + log::warn!("server-side auction dispatch failed: {error:?}"); + let elapsed_ms = observation.elapsed_ms(); + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::ExecutionFailed { + request: Some(&auction_request), + provider_responses: &[], + reason: "dispatch_failed", + elapsed_ms, + }, + ) + }) + .await; + let result = OrchestrationResult::no_bid( + &auction_request, + AuctionSlotFailureReason::InternalError, + ); + write_projection_to_state( + &result, + price_granularity, + &ad_bids_state, + settings, + &request_origin(request_scheme, request_host), + browser_slots_json.as_deref(), + ); + None + } + } } else { let skip_reason = if !auction.orchestrator.is_enabled() { "auction_disabled" @@ -3609,7 +3757,7 @@ pub async fn handle_publisher_request( let datadome_suppression_requires_origin = suppress_datadome_client_side_tag; let datadome_suppression_requires_full_body = suppress_datadome_client_side_tag && is_html_document_request(&req); - let request_requires_origin = request_bypasses_template_cache(req.headers()) + let request_requires_origin = request_bypasses_c2(req.headers()) || gpt_diagnostics.requires_private_no_store() || datadome_suppression_requires_origin; let reader_compression = negotiate_reader_compression(req.headers()); @@ -3700,14 +3848,6 @@ pub async fn handle_publisher_request( // Exact ordered placements are request state. Inline processing carries // them in the head boot; C2 carries the same JSON in its per-reader seam. - let browser_slots_json = should_run_ad_stack - .then(|| { - settings - .creative_opportunities - .as_ref() - .map(|config| build_browser_slots_json(&matched_slots, config, &request_path)) - }) - .flatten(); let seam_ad_slots = browser_slots_json.clone(); // Template-cache lookup happens before the origin fetch — the whole point is to skip it. @@ -3814,6 +3954,7 @@ pub async fn handle_publisher_request( // 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 request_method = req.method().clone(); let mut platform_request = PlatformHttpRequest::new(req, backend_name); if services.http_client().supports_streaming_responses() { platform_request = platform_request.with_stream_response(); @@ -3868,6 +4009,7 @@ pub async fn handle_publisher_request( })?; crate::integrations::gpt_diagnostics::finalize_response(&gpt_diagnostics, &mut response); append_aps_publisher_frame_policy(&mut response, aps_enabled); + enforce_terminal_private_cache_privacy(&mut response); return Ok(PublisherResponse::Buffered(response)); } @@ -3918,7 +4060,7 @@ pub async fn handle_publisher_request( } } } else { - Vec::new() + runtime_policy_headers(response.headers()) }; if template_cache_response_state == Some(TemplateCacheResponseState::MissReserved) && template_cache_key.is_none() @@ -3976,16 +4118,16 @@ pub async fn handle_publisher_request( if is_html_content_type(origin_content_type) || is_not_modified { if should_run_ad_stack || assembled_response_must_be_private { enforce_synthesized_html_cache_privacy(&mut response); - } else if is_get && response.status().is_success() { - // Server-side ad templates are inactive for this response, so the - // document carries no per-navigation auction state and stays - // cacheable — unless the origin already marked it private. - if !response_cache_control_is_private_or_no_store(&response) { - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static("max-age=60"), - ); - } + } else if is_server_side_ad_eligible_navigation( + is_get, + is_navigation, + is_prefetch, + is_bot, + consent_allows_auction, + ) && (response.status() == StatusCode::OK || is_not_modified) + && !cache_control_forbids_shared_storage(response.headers()) + { + apply_inactive_ad_stack_browser_cache_policy(&mut response); } } @@ -4126,6 +4268,7 @@ pub async fn handle_publisher_request( auction_request: auction_request_for_telemetry, dispatched_auction, price_granularity, + suppress_datadome_client_side_tag, gpt_diagnostics: Some(gpt_diagnostics), render_trace_overlay, }), @@ -4387,6 +4530,18 @@ pub(crate) mod coordinated_cutover_v1 { }); continue; }; + if upstream_bid_id.is_empty() + || upstream_bid_id.len() > 64 + || upstream_bid_id + .bytes() + .any(|byte| byte <= 0x1f || byte == 0x7f) + { + projected_results.push(SlotAuctionDecisionV1::Failed { + slot: slot.clone(), + reason: AuctionSlotFailureReason::WinnerNotRenderable, + }); + continue; + } let Some(render_source) = project_render_source(bid, settings, request_origin) else { projected_results.push(SlotAuctionDecisionV1::Failed { slot: slot.clone(), @@ -4433,7 +4588,9 @@ pub(crate) mod coordinated_cutover_v1 { version: 1, auction: crate::auction::types::AuctionDecisionSetV1 { version: 1, - auction_id: result.decision_set.auction_id.clone(), + auction_id: browser_auction_id + .unwrap_or(&result.decision_set.auction_id) + .to_string(), results: projected_results, }, slots: Vec::new(), @@ -4895,6 +5052,78 @@ fn replayable_policy_headers( Ok(captured) } +fn runtime_policy_headers(headers: &edgezero_core::http::HeaderMap) -> Vec<(String, String)> { + let mut captured = Vec::new(); + for name in crate::platform::REPLAYABLE_POLICY_HEADERS { + for value in headers.get_all(*name) { + let Ok(value) = value.to_str() else { + continue; + }; + captured.push(((*name).to_string(), value.to_string())); + } + } + captured +} + +fn valid_csp_nonce_value(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'_' | b'-' | b'=') + }) +} + +/// Select one nonce admitted by every nonce-bearing enforcing CSP policy. +/// Ambiguous or disjoint policy sets fail closed by returning no nonce. +fn response_csp_nonce(policy_headers: &[(String, String)]) -> Option { + let mut admitted: Option> = None; + for (name, policy) in policy_headers { + if !name.eq_ignore_ascii_case("content-security-policy") { + continue; + } + let mut script_src_elem: Option<&str> = None; + let mut script_src: Option<&str> = None; + let mut default_src: Option<&str> = None; + for directive in policy.split(';') { + let mut fields = directive.split_whitespace(); + let Some(name) = fields.next() else { + continue; + }; + let values = directive[name.len()..].trim(); + match name.to_ascii_lowercase().as_str() { + "script-src-elem" if script_src_elem.is_none() => script_src_elem = Some(values), + "script-src" if script_src.is_none() => script_src = Some(values), + "default-src" if default_src.is_none() => default_src = Some(values), + _ => {} + } + } + let Some(effective) = script_src_elem.or(script_src).or(default_src) else { + continue; + }; + let nonces = effective + .split_whitespace() + .filter_map(|token| { + let body = token.strip_prefix('\'')?.strip_suffix('\'')?; + let prefix = body.get(..6)?; + let value = body.get(6..)?; + (prefix.eq_ignore_ascii_case("nonce-") && valid_csp_nonce_value(value)) + .then(|| value.to_string()) + }) + .collect::>(); + if nonces.is_empty() { + continue; + } + admitted = Some(match admitted { + None => nonces, + Some(existing) => existing.intersection(&nonces).cloned().collect(), + }); + } + let admitted = admitted?; + (admitted.len() == 1) + .then(|| admitted.into_iter().next()) + .flatten() +} + fn c2_cache_ttl( mode: AssemblyMode, request_had_authorization: bool, @@ -5070,20 +5299,6 @@ fn normalize_page_bids_path(raw: &str) -> String { } } -/// Normalize the page path and query retained in the upstream auction context. -/// -/// Fragments never reach an HTTP server and are discarded if a client includes -/// one explicitly. Unlike [`normalize_page_bids_path`], the query is preserved -/// for the `OpenRTB` `site.page` URL and is not used for slot matching. -fn normalize_page_bids_path_and_query(raw: &str) -> String { - let path_and_query = raw.split('#').next().unwrap_or(""); - if path_and_query.starts_with('/') { - path_and_query.to_string() - } else { - format!("/{path_and_query}") - } -} - fn page_bids_terminal_projection_v1( matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], browser_slots: Vec, @@ -5307,6 +5522,8 @@ pub async fn handle_page_bids( .await { Ok(result) => { + let browser_auction_id = + mint_browser_auction_id(&SystemAuctionIdentityGenerator)?; let projection = coordinated_cutover_v1::build_browser_auction_projection_v1( &result, co_config.price_granularity, @@ -5431,6 +5648,7 @@ mod tests { use crate::auction::orchestrator::OrchestrationResult; use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::auction::types::{AuctionDecisionSetV1, AuctionDropReason, AuctionResponse}; + use crate::auction_config_types::{NotificationConfig, ProviderConfig, RoutingMode}; use crate::integrations::IntegrationRegistry; use crate::platform::test_support::{ NoopSecretStore, StubHttpClient, build_services_with_http_client, @@ -5444,6 +5662,63 @@ mod tests { use http::{Method, Request as HttpRequest, StatusCode, header}; use std::sync::Arc; + #[test] + fn enforcing_csp_selects_one_nonce_from_the_effective_script_directive() { + let headers = vec![( + "Content-Security-Policy".to_owned(), + "default-src 'nonce-default'; script-src 'nonce-script'; script-src-elem 'nonce-element' https:".to_owned(), + )]; + assert_eq!(response_csp_nonce(&headers).as_deref(), Some("element")); + } + + #[test] + fn enforcing_csp_nonce_selection_intersects_policies_and_fails_closed() { + let common = vec![ + ( + "content-security-policy".to_owned(), + "script-src 'nonce-shared' 'nonce-first'".to_owned(), + ), + ( + "CONTENT-SECURITY-POLICY".to_owned(), + "script-src-elem 'nonce-shared' 'nonce-second'".to_owned(), + ), + ]; + assert_eq!(response_csp_nonce(&common).as_deref(), Some("shared")); + + let ambiguous = vec![( + "content-security-policy".to_owned(), + "script-src 'nonce-one' 'nonce-two'".to_owned(), + )]; + assert_eq!(response_csp_nonce(&ambiguous), None); + + let disjoint = vec![ + ( + "content-security-policy".to_owned(), + "script-src 'nonce-one'".to_owned(), + ), + ( + "content-security-policy".to_owned(), + "script-src 'nonce-two'".to_owned(), + ), + ]; + assert_eq!(response_csp_nonce(&disjoint), None); + } + + #[test] + fn report_only_and_malformed_nonce_sources_do_not_authorize_emission() { + let headers = vec![ + ( + "content-security-policy-report-only".to_owned(), + "script-src 'nonce-report'".to_owned(), + ), + ( + "content-security-policy".to_owned(), + "script-src 'nonce-bad\"value'".to_owned(), + ), + ]; + assert_eq!(response_csp_nonce(&headers), None); + } + fn make_test_bid_with_creative(creative: &str) -> Bid { Bid { slot_id: "slot".to_string(), @@ -5471,6 +5746,22 @@ mod tests { } } + fn enable_aps_auction_plan(settings: &mut Settings) { + settings.auction.enabled = true; + settings.auction.providers = std::collections::BTreeMap::from([( + "aps-main".parse().expect("should parse APS provider ID"), + ProviderConfig { + protocol: "openrtb-2.6".to_string(), + profile: "aps".to_string(), + endpoint: "https://aps.example/e/pb/bid".to_string(), + timeout_ms: None, + routing: RoutingMode::AllEligible, + notifications: NotificationConfig::default(), + profile_config: serde_json::json!({"account_id":"test-account"}), + }, + )]); + } + fn boot_auction_id(html: &str) -> String { bootstrap_transport(html)["boot"]["auctionProjection"]["auction"]["auctionId"] .as_str() @@ -5538,6 +5829,7 @@ mod tests { creative: None, adomain: None, bidder: "example_bidder".to_string(), + returned_seat: None, width: 300, height: 250, nurl: None, @@ -5689,9 +5981,12 @@ mod tests { .expect("should store projection JSON"); let projection: serde_json::Value = serde_json::from_str(&stored).expect("should store the exact projection shape"); - assert_eq!( - projection["auction"]["auctionId"], - result.decision_set.auction_id + let auction_id = projection["auction"]["auctionId"] + .as_str() + .expect("browser auction id should be a string"); + assert!( + auction_id.starts_with("a1_") && auction_id != result.decision_set.auction_id, + "initial HTML should use a fresh browser auction identity" ); assert_eq!(projection["slots"][0]["slot"], "slot-1"); assert_eq!(projection["slots"][0]["gamUnitPath"], "/123/slot-1"); @@ -6920,6 +7215,7 @@ mod tests { dispatched_auction: None, price_granularity: Default::default(), gpt_diagnostics: None, + suppress_datadome_client_side_tag: false, render_trace_overlay: false, } } @@ -7160,7 +7456,11 @@ mod tests { services: &RuntimeServices, req: Request, ) -> PublisherResponse { - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let plan = Arc::new( + crate::auction::compile_auction_plan(settings) + .expect("should compile the publisher test auction plan"), + ); + let orchestrator = AuctionOrchestrator::from_plan(plan, None); let mut ec_context = EcContext::read_from_request(settings, &req, services).expect("should read EC context"); handle_publisher_request( @@ -7191,6 +7491,16 @@ mod tests { destination: &str, uri: &str, cookie: Option<&str>, + ) -> TsConsolePipelineResult { + run_ts_console_pipeline_with_origin_policies(method, destination, uri, cookie, &[]).await + } + + async fn run_ts_console_pipeline_with_origin_policies( + method: Method, + destination: &str, + uri: &str, + cookie: Option<&str>, + enforcing_policies: &[&str], ) -> TsConsolePipelineResult { let mut settings = create_test_settings(); settings @@ -7202,10 +7512,19 @@ mod tests { .insert_config("gpt", &serde_json::json!({})) .expect("should enable the GPT event provider"); let stub = Arc::new(StubHttpClient::new()); + let mut origin_headers = vec![( + "content-type".to_owned(), + "text/html; charset=utf-8".to_owned(), + )]; + origin_headers.extend( + enforcing_policies + .iter() + .map(|policy| ("content-security-policy".to_owned(), (*policy).to_owned())), + ); stub.push_response_with_headers( 200, b"origin".to_vec(), - vec![("content-type", "text/html; charset=utf-8")], + origin_headers, ); let services = build_services_with_http_client( Arc::clone(&stub) as Arc @@ -7251,6 +7570,76 @@ mod tests { } } + #[tokio::test] + async fn response_csp_nonce_flows_through_the_real_publisher_html_pipeline() { + let nonce = "response-nonce_123="; + let result = run_ts_console_pipeline_with_origin_policies( + Method::GET, + "document", + "https://publisher.example/article?ts_console=1", + None, + &["default-src 'none'; script-src 'nonce-response-nonce_123='"], + ) + .await; + let html = response_body_string(result.response); + + assert!( + html.contains(&format!( + r#"" + "const __TSJS_SERVER_BOOT_TRANSPORT_V1__={transport_literal};{bootstrap}" ); let selected_src = agent .as_ref() .map_or(runtime_src.as_str(), |artifact| artifact.src()); Ok(format!( - r#"{controller}"# + r#"{controller}{interstitial_html}"# )) } @@ -1503,6 +1549,41 @@ mod tests { ); } + #[test] + fn production_bootstrap_applies_one_validated_nonce_to_both_script_tags() { + let configs = IntegrationConfigsV1::new(vec![("aps", serde_json::json!({}))]) + .expect("APS browser projection should be admitted"); + let config = || TsjsBootScriptConfigV1 { + module_ids: &["render_runtime", "aps"], + integration_configs: &configs, + auction_projection_json: VALID_BROWSER_AUCTION_PROJECTION_JSON, + creative: CreativeBootConfigV1 { + enabled: false, + click_guard: false, + render_guard: false, + }, + render_trace_overlay: false, + gpt_diagnostics_active: false, + }; + + let script = tsjs_bootstrap_fragment_with_nonce_v1( + config(), + PERFORMANCE_ORIGIN, + Some("response-nonce_123="), + ) + .expect("a bounded CSP base64-value should be admitted"); + assert_eq!(script.matches(r#" nonce="response-nonce_123=""#).count(), 2); + assert_eq!(script.matches(" { emit("slotRenderEnded", slot, { isEmpty: false, responseIdentifier: "fictional-response-1", + size: [300, 250], }); } } diff --git a/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts b/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts index 10dd1595c..09bad1e1c 100644 --- a/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts +++ b/crates/trusted-server-integration-tests/browser/helpers/tsjs-fixture.ts @@ -45,6 +45,11 @@ export interface FirstDisplayTsjsFixture { firstDisplaySrc: string; runtimeBody: string; runtimeSrc: string; + deferred: readonly Readonly<{ + id: string; + body: string; + src: string; + }>[]; boot: Readonly>; outline: Readonly>; } @@ -56,6 +61,7 @@ interface FirstDisplayTsjsFixtureInput { readonly diagnostics?: Readonly>; readonly firstDisplayIds: readonly string[]; readonly takeoverIds: readonly string[]; + readonly deferredIds?: readonly string[]; } const FIRST_DISPLAY_IDS = Object.freeze([ @@ -212,12 +218,34 @@ export function firstDisplayTsjsFixture( .toString(16) .padStart(4, "0")}&v=${firstDisplayHash}`; const runtime = runtimeTsjsFixture(input.takeoverIds); + const deferred = (input.deferredIds ?? []).map((id) => { + const artifact = exactArtifact(release, id); + if (artifact.phase !== "deferred") { + throw new Error(`TSJS fixture module ${artifact.id} is not deferred`); + } + const body = readFileSync(resolve(dist, artifact.file), "utf8"); + const hash = createHash("sha256").update(body, "utf8").digest("hex"); + return Object.freeze({ + id, + body, + src: `/static/tsjs=tsjs-${id}.min.js?v=${hash}`, + }); + }); const manifest = { ...runtime.manifest, firstDisplay: { src: firstDisplaySrc, slices: [...input.firstDisplayIds], }, + integrations: [ + ...runtime.manifest.integrations, + ...deferred.map(({ id, src }) => ({ + id, + phase: "deferred" as const, + trigger: "first_display_or_idle" as const, + src, + })), + ], }; const creative = input.creative ?? { version: 1, @@ -272,6 +300,7 @@ export function firstDisplayTsjsFixture( firstDisplaySrc, runtimeBody: runtime.runtimeBody, runtimeSrc: runtime.runtimeSrc, + deferred, boot, outline, }; diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-policy.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-policy.spec.ts new file mode 100644 index 000000000..bd8805e1f --- /dev/null +++ b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-policy.spec.ts @@ -0,0 +1,418 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + firstDisplayTsjsFixture, + runtimeTsjsFixture, + serverBootTransportLiteralV1, +} from "../../helpers/tsjs-fixture.js"; + +const NONCE = "tsjs-policy-nonce"; +const POLICY_SLOT = "policy-slot"; +const DIRECT = runtimeTsjsFixture(["render_runtime"]); +const DEFERRED = firstDisplayTsjsFixture({ + firstDisplayIds: ["first_display"], + takeoverIds: ["render_runtime"], + deferredIds: ["diagnostics_presentation"], + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: "policy", + results: [{ slot: POLICY_SLOT, outcome: "no_bid" }], + }, + slots: [ + { + slot: POLICY_SLOT, + gamUnitPath: `/123/${POLICY_SLOT}`, + divId: POLICY_SLOT, + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [], + }, + integrations: { version: 1, entries: [] }, + diagnostics: { + version: 1, + renderTraceOverlay: true, + gpt: { active: false }, + }, +}); + +function directBoot() { + return { + abi: 1, + releaseId: DIRECT.releaseId, + manifest: DIRECT.manifest, + auctionProjection: { + version: 1, + auction: { + version: 1, + auctionId: "policy-direct", + results: [{ slot: POLICY_SLOT, outcome: "no_bid" }], + }, + slots: [ + { + slot: POLICY_SLOT, + gamUnitPath: `/123/${POLICY_SLOT}`, + divId: POLICY_SLOT, + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: [], + }, + integrations: { version: 1, entries: [] }, + creative: { + version: 1, + enabled: false, + clickGuard: false, + renderGuard: false, + }, + diagnostics: { + version: 1, + renderTraceOverlay: false, + gpt: { active: false }, + }, + }; +} + +function deferredBoot() { + return { + ...DEFERRED.boot, + manifest: { + ...(DEFERRED.boot.manifest as Record), + firstDisplay: null, + }, + }; +} + +interface PolicyPageOptions { + readonly path: string; + readonly csp: string; + readonly nonce?: string; + readonly deferred?: boolean; + readonly takeover?: boolean; + readonly prelude?: string; + readonly tail?: string; + readonly removeDeferred?: boolean; +} + +async function servePolicyPage(page: Page, options: PolicyPageOptions) { + const fixture = options.deferred ? DEFERRED : DIRECT; + const boot = options.takeover + ? DEFERRED.boot + : options.deferred + ? deferredBoot() + : directBoot(); + const runtimeUrl = new URL( + fixture.runtimeSrc, + "https://runtime.test", + ).toString(); + const firstDisplayUrl = options.takeover + ? new URL(DEFERRED.firstDisplaySrc, "https://runtime.test").toString() + : undefined; + const deferredResource = options.deferred ? DEFERRED.deferred[0] : undefined; + const deferredUrl = deferredResource + ? new URL(deferredResource.src, "https://runtime.test").toString() + : undefined; + const runtimeRequests: string[] = []; + const deferredRequests: string[] = []; + const routeHits = { deferred: 0, firstDisplay: 0, runtime: 0 }; + page.on("request", (request) => { + if (request.url() === runtimeUrl) runtimeRequests.push(request.url()); + if (request.url() === deferredUrl) deferredRequests.push(request.url()); + }); + await page.route(runtimeUrl, (route) => { + routeHits.runtime += 1; + return route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: fixture.runtimeBody, + }); + }); + if (firstDisplayUrl) { + await page.route(firstDisplayUrl, (route) => { + routeHits.firstDisplay += 1; + return route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: DEFERRED.firstDisplayBody, + }); + }); + } + if (deferredResource && deferredUrl) { + await page.route(deferredUrl, (route) => { + routeHits.deferred += 1; + return route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: deferredResource.body, + }); + }); + } + const nonce = options.nonce ? ` nonce="${options.nonce}"` : ""; + const probe = options.deferred + ? `window.__policyProbe={insertions:0,removals:0};const __policyObserver=new MutationObserver(records=>{for(const record of records){for(const node of record.addedNodes){if(!(node instanceof HTMLScriptElement)||!node.src.includes("tsjs-diagnostics_presentation.min.js"))continue;window.__policyProbe.insertions+=1;${options.removeDeferred ? 'node.replaceWith(document.createElement("script"));window.__policyProbe.removals+=1;' : ""}}}});__policyObserver.observe(document,{childList:true,subtree:true});` + : ""; + const controllerBody = `window.tsjs={que:[]};${probe}${options.prelude ?? ""}const __TSJS_SERVER_BOOT_TRANSPORT_V1__=${serverBootTransportLiteralV1(boot, options.takeover ? DEFERRED.outline : null)};${fixture.bootstrapBody}`; + await page.route(`https://runtime.test${options.path}`, (route) => + route.fulfill({ + status: 200, + contentType: "text/html; charset=utf-8", + headers: { "content-security-policy": options.csp }, + body: `${controllerBody}
${options.tail ? `${options.tail}` : ""}`, + }), + ); + await page.goto(`https://runtime.test${options.path}`); + return { + deferredRequests, + routeHits, + runtimeRequests, + }; +} + +async function runtimeState(page: Page): Promise { + return page.evaluate( + () => + ( + window as unknown as { + tsjs?: { _internal?: { state?: string } }; + } + ).tsjs?._internal?.state, + ); +} + +async function releaseDeferredGate(page: Page): Promise { + await page.evaluate(async (slot) => { + const target = ( + window as unknown as { + tsjs: { + requestAds(options: { + slots: readonly string[]; + timeoutMs: number; + }): Promise; + }; + } + ).tsjs; + await target.requestAds({ slots: [slot], timeoutMs: 100 }); + }, POLICY_SLOT); +} + +for (const fixture of [ + { + name: "same-origin allowlisting", + path: "/policy-same-origin", + csp: "default-src 'none'; script-src 'self' 'unsafe-inline'", + }, + { + name: "matching nonce", + path: "/policy-matching-nonce", + csp: `default-src 'none'; script-src 'self' 'nonce-${NONCE}'`, + nonce: NONCE, + }, + { + name: "nonce-only", + path: "/policy-nonce-only", + csp: `default-src 'none'; script-src 'nonce-${NONCE}'`, + nonce: NONCE, + }, + { + name: "strict-dynamic", + path: "/policy-strict-dynamic", + csp: `default-src 'none'; script-src 'nonce-${NONCE}' 'strict-dynamic'`, + nonce: NONCE, + }, +] as const) { + test(`boots the authenticated direct runtime under ${fixture.name}`, async ({ + page, + }) => { + const requests = await servePolicyPage(page, fixture); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + expect(requests.runtimeRequests).toHaveLength(1); + }); +} + +test("a full script policy block starts no TSJS execution or request", async ({ + page, +}) => { + const requests = await servePolicyPage(page, { + path: "/policy-blocked", + csp: "default-src 'none'; script-src 'none'", + }); + expect(await runtimeState(page)).toBeUndefined(); + expect(requests.routeHits.runtime).toBe(0); +}); + +for (const fixture of [ + { + name: "the allowed fixed Trusted Types policy", + path: "/policy-tt-named", + csp: `default-src 'none'; script-src 'nonce-${NONCE}'; require-trusted-types-for 'script'; trusted-types trusted-server#tsjs-v1`, + }, + { + name: "a rejected fixed name and exact-preserving publisher default", + path: "/policy-tt-default", + csp: `default-src 'none'; script-src 'nonce-${NONCE}'; require-trusted-types-for 'script'; trusted-types default`, + prelude: + 'trustedTypes.createPolicy("default",{createScriptURL:value=>value});', + }, +] as const) { + test(`loads a deferred module with ${fixture.name}`, async ({ + browserName, + page, + }) => { + test.skip( + browserName !== "chromium", + "Trusted Types enforcement is Chromium-only", + ); + const requests = await servePolicyPage(page, { + ...fixture, + nonce: NONCE, + deferred: true, + }); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + await releaseDeferredGate(page); + await expect.poll(() => requests.deferredRequests.length).toBe(1); + await expect + .poll(() => page.locator("#ts-render-trace-panel").count()) + .toBe(1); + }); +} + +test("the first-display owner creates the fixed Trusted Types policy once and leases it to deferred loading", async ({ + browserName, + page, +}) => { + test.skip( + browserName !== "chromium", + "Trusted Types enforcement is Chromium-only", + ); + const requests = await servePolicyPage(page, { + path: "/policy-tt-takeover", + csp: `default-src 'none'; script-src 'nonce-${NONCE}' 'strict-dynamic'; require-trusted-types-for 'script'; trusted-types trusted-server#tsjs-v1`, + nonce: NONCE, + deferred: true, + takeover: true, + prelude: + 'window.__policyCreates=[];const __nativeCreatePolicy=trustedTypes.createPolicy.bind(trustedTypes);Object.defineProperty(trustedTypes,"createPolicy",{value:(name,rules)=>{if(window.__policyCreates.includes(name))throw new TypeError("duplicate TSJS policy");window.__policyCreates.push(name);return __nativeCreatePolicy(name,rules);}});', + }); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + await releaseDeferredGate(page); + await expect.poll(() => requests.deferredRequests.length).toBe(1); + await expect + .poll(() => page.locator("#ts-render-trace-panel").count()) + .toBe(1); + expect(requests.routeHits.firstDisplay).toBe(1); + expect(requests.routeHits.runtime).toBe(1); + expect( + await page.evaluate( + () => + ( + window as unknown as { + __policyCreates: string[]; + } + ).__policyCreates, + ), + ).toEqual(["trusted-server#tsjs-v1"]); +}); + +for (const fixture of [ + { + name: "mutates the URL", + path: "/policy-tt-mutate", + prelude: + 'trustedTypes.createPolicy("default",{createScriptURL:value=>value+"&publisher=mutated"});', + }, + { + name: "throws synchronously", + path: "/policy-tt-throw", + prelude: + 'trustedTypes.createPolicy("default",{createScriptURL:()=>{throw new TypeError("publisher policy");}});', + }, +] as const) { + test(`a publisher default policy that ${fixture.name} blocks before insertion`, async ({ + browserName, + page, + }) => { + test.skip( + browserName !== "chromium", + "Trusted Types enforcement is Chromium-only", + ); + const requests = await servePolicyPage(page, { + ...fixture, + csp: `default-src 'none'; script-src 'nonce-${NONCE}'; require-trusted-types-for 'script'; trusted-types default`, + nonce: NONCE, + deferred: true, + }); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + await releaseDeferredGate(page); + await page.waitForTimeout(150); + const probe = await page.evaluate( + () => + ( + window as unknown as { + __policyProbe: { insertions: number; removals: number }; + } + ).__policyProbe, + ); + expect(probe.insertions).toBe(0); + expect(requests.deferredRequests).toEqual([]); + expect(await page.locator("#ts-render-trace-panel").count()).toBe(0); + }); +} + +test("a missing propagated nonce blocks after insertion without replacing the kernel", async ({ + page, +}) => { + const requests = await servePolicyPage(page, { + path: "/policy-missing-deferred-nonce", + csp: `default-src 'none'; script-src 'nonce-${NONCE}'`, + nonce: NONCE, + deferred: true, + tail: 'document.querySelector("script#trustedserver-js").nonce="";', + }); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + await releaseDeferredGate(page); + await page.waitForTimeout(150); + const insertions = await page.evaluate( + () => + ( + window as unknown as { + __policyProbe: { insertions: number }; + } + ).__policyProbe.insertions, + ); + expect(insertions).toBe(1); + expect(requests.routeHits.deferred).toBe(0); + expect(await page.locator("#ts-render-trace-panel").count()).toBe(0); + expect(await runtimeState(page)).toBe("kernel"); +}); + +test("removing the authenticated deferred node cannot register or replace the kernel", async ({ + page, +}) => { + const requests = await servePolicyPage(page, { + path: "/policy-deferred-replaced", + csp: "default-src 'none'; script-src 'self' 'unsafe-inline'", + deferred: true, + removeDeferred: true, + }); + await expect.poll(() => runtimeState(page)).toBe("kernel"); + await releaseDeferredGate(page); + await page.waitForTimeout(150); + const probe = await page.evaluate( + () => + ( + window as unknown as { + __policyProbe: { insertions: number; removals: number }; + } + ).__policyProbe, + ); + expect(probe).toEqual({ insertions: 1, removals: 1 }); + expect(requests.deferredRequests.length).toBeLessThanOrEqual(1); + expect(await page.locator("#ts-render-trace-panel").count()).toBe(0); + expect(await runtimeState(page)).toBe("kernel"); +}); 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 index b7a117f11..899336478 100644 --- 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 @@ -13,7 +13,8 @@ const FALLBACK_FIXTURE = runtimeTsjsFixture([]); const FIRST_DISPLAY_SLOT = "first-display-owner-slot"; const FIRST_DISPLAY_FIXTURE = firstDisplayTsjsFixture({ firstDisplayIds: ["first_display", "render_owner_initial", "gpt_initial"], - takeoverIds: ["render_runtime", "gpt"], + takeoverIds: ["render_runtime", "gpt", "gpt_diagnostics"], + deferredIds: ["diagnostics_presentation"], auctionProjection: { version: 1, auction: { @@ -68,6 +69,11 @@ const FIRST_DISPLAY_FIXTURE = firstDisplayTsjsFixture({ }, ], }, + diagnostics: { + version: 1, + renderTraceOverlay: true, + gpt: { active: true }, + }, }); function boot(fixture: RuntimeTsjsFixture) { @@ -200,10 +206,14 @@ test.describe("TSJS hard-cutover runtime", () => { FIRST_DISPLAY_FIXTURE.runtimeSrc, "https://runtime.test", ).toString(); + const deferredUrls = FIRST_DISPLAY_FIXTURE.deferred.map((resource) => + new URL(resource.src, "https://runtime.test").toString(), + ); const firstDisplayRequests: string[] = []; const allRequests: string[] = []; const pageErrors: string[] = []; const consoleMessages: string[] = []; + const deferredRegistrationAttack = `window.__deferredRegistrationAttack={attempted:false,prepareCalled:false,registrationAccepted:false};const __deferredRegistrationObserver=new MutationObserver(records=>{for(const record of records){for(const node of record.addedNodes){if(!(node instanceof HTMLScriptElement)||!node.src.includes("tsjs-diagnostics_presentation.min.js"))continue;__deferredRegistrationObserver.disconnect();window.__deferredRegistrationAttack.attempted=true;try{Object.defineProperty(document,"currentScript",{configurable:true,value:node});}catch{}const forged=Object.freeze({abi:1,id:"diagnostics_presentation",phase:"deferred",releaseId:${JSON.stringify(FIRST_DISPLAY_FIXTURE.releaseId)},prepare:()=>{window.__deferredRegistrationAttack.prepareCalled=true;return Object.freeze({activate:()=>undefined});}});window.__deferredRegistrationAttack.registrationAccepted=window.tsjs._registerIntegration(forged);}}});__deferredRegistrationObserver.observe(document,{childList:true,subtree:true});`; page.on("request", (request) => allRequests.push(request.url())); page.on("pageerror", (error) => pageErrors.push(error.message)); page.on("console", (message) => @@ -226,11 +236,23 @@ test.describe("TSJS hard-cutover runtime", () => { body: FIRST_DISPLAY_FIXTURE.runtimeBody, }), ); + for (const resource of FIRST_DISPLAY_FIXTURE.deferred) { + await page.route( + new URL(resource.src, "https://runtime.test").toString(), + (route) => + route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + headers: { "x-content-type-options": "nosniff" }, + body: resource.body, + }), + ); + } await page.route("https://runtime.test/first-display-owner", (route) => route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", - body: `
`, + body: `
`, }), ); @@ -295,6 +317,25 @@ test.describe("TSJS hard-cutover runtime", () => { }>; }; return { + deferredRegistrationAttack: ( + browserWindow as unknown as { + __deferredRegistrationAttack: Readonly<{ + attempted: boolean; + prepareCalled: boolean; + registrationAccepted: boolean; + }>; + } + ).__deferredRegistrationAttack, + takeoverAttack: ( + browserWindow as unknown as { + __takeoverAttack: Readonly<{ + claimExposed: boolean; + currentScriptShadowed: boolean; + namespaceReplacementBlocked: boolean; + replacementBlocked: boolean; + }>; + } + ).__takeoverAttack, parser: browserWindow.__firstDisplayParserObservation, loaderCallsBeforePaint: browserWindow .__firstDisplayResourceProbe() @@ -310,6 +351,25 @@ test.describe("TSJS hard-cutover runtime", () => { creativeFrames: document.querySelectorAll( `#${slotId} > iframe[title="Ad content"]`, ).length, + tracePanels: document.querySelectorAll("#ts-render-trace-panel").length, + diagnosticRequests: ( + window as unknown as { + tsjs: { + diagnostics: { + gpt: { + snapshot(): { + slots: Array<{ + slotElementId?: string; + requests: Array>; + }>; + }; + }; + }; + }; + } + ).tsjs.diagnostics.gpt + .snapshot() + .slots.find((slot) => slot.slotElementId === slotId)?.requests, }; }, FIRST_DISPLAY_SLOT); @@ -318,6 +378,7 @@ test.describe("TSJS hard-cutover runtime", () => { "https://runtime.test/first-display-owner", firstDisplayUrl, runtimeUrl, + ...deferredUrls, ]); expect( allRequests.filter((url) => /tsjs-render_owner_initial/u.test(url)), @@ -327,10 +388,33 @@ test.describe("TSJS hard-cutover runtime", () => { defer: false, firstAction: 1, }); + expect(observation.takeoverAttack).toEqual({ + claimExposed: false, + currentScriptShadowed: true, + namespaceReplacementBlocked: true, + replacementBlocked: true, + }); + expect(observation.deferredRegistrationAttack).toEqual({ + attempted: true, + prepareCalled: false, + registrationAccepted: false, + }); expect(observation.loaderCallsBeforePaint).toEqual([]); - expect(observation.loaderCallsAfterPaint).toEqual(["create:script"]); + expect(observation.loaderCallsAfterPaint).toEqual([ + "create:script", + "create:script", + ]); expect(observation.firstDisplayScripts).toBe(1); expect(observation.creativeFrames).toBe(1); + expect(observation.tracePanels).toBe(1); + expect(observation.diagnosticRequests).toEqual([ + expect.objectContaining({ + isEmpty: true, + requestedSlotSizes: [[300, 250]], + trustedServerAuctionId: "browser-first-display-owner", + trustedServerOpportunity: "renderable_candidate", + }), + ]); }); test("generated bootstrap transfers one direct-runtime watchdog to the persistent owner", async ({ diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index 128e7bff6..0cc3def6f 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -85,7 +85,6 @@ rewrite_sdk = true [integrations.gpt] enabled = true script_url = "https://ads.example.com/gpt.js" -cache_ttl_seconds = 3600 rewrite_script = true [integrations.gpt_diagnostics] 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 a6e1b4597..bc63a0a61 100644 --- a/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs +++ b/crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs @@ -344,7 +344,7 @@ fn corpus(runtime_id: &str) -> Vec { b"next".to_vec(), ), ]; - if runtime_id == "cloudflare" { + if matches!(runtime_id, "cloudflare" | "spin") { cases.insert( cases.len() - 1, CorpusCase { @@ -352,7 +352,7 @@ fn corpus(runtime_id: &str) -> Vec { Duration::from_secs(4) + Duration::from_millis(750), ), ..CorpusCase::failure( - "Cloudflare first-byte timeout is distinct from the total timeout", + "first-byte timeout is distinct from the total timeout", FictionalResponse::raw(vec![ResponseWrite::after( Duration::from_millis(4_500), b"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_vec(), @@ -361,6 +361,26 @@ fn corpus(runtime_id: &str) -> Vec { }, ); } + if runtime_id == "spin" { + cases.insert( + cases.len() - 1, + CorpusCase { + maximum_elapsed: Some(Duration::from_secs(1)), + ..CorpusCase::failure( + "Spin between-bytes timeout is distinct from the total timeout", + FictionalResponse::raw(vec![ + ResponseWrite::now( + b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: application/javascript\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nx\r\n".to_vec(), + ), + ResponseWrite::after( + Duration::from_millis(750), + b"0\r\n\r\n".to_vec(), + ), + ]), + ) + }, + ); + } cases } diff --git a/crates/trusted-server-js/Cargo.toml b/crates/trusted-server-js/Cargo.toml index 000184415..6c60d39fd 100644 --- a/crates/trusted-server-js/Cargo.toml +++ b/crates/trusted-server-js/Cargo.toml @@ -18,6 +18,8 @@ path = "src/lib.rs" [build-dependencies] build-print = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } sha2 = { workspace = true } which = { workspace = true } diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 92188524c..742d3e90d 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -23,6 +23,9 @@ const metricsFile = 'tsjs-build-metrics-v1.json'; const releaseFile = 'tsjs-release-v1.json'; const catalogFile = 'tsjs-catalog-v1.json'; const bootstrapFile = 'tsjs-bootstrap.js'; +const runtimeClaimBanner = + '(()=>{const __TSJS_RUNTIME_CLAIMED_V1__=(()=>{try{const source=window.tsjs,claim=source&&source._claimRuntimeV1;return typeof claim==="function"?claim(source):undefined}catch{return undefined}})();'; +const runtimeClaimFooter = '})();'; // Closure-private implementation names used only inside the inline artifact. // Registration, takeover, handoff, and public API protocol keys are excluded. @@ -51,7 +54,7 @@ const firstDisplayApsPrivateProperties = // These names are private to the GPT initial IIFE and never cross its protocol // receipt or handoff boundary. Keep the cross-artifact protocol keys authored. const firstDisplayGptPrivateProperties = - /^(?:options|clearTimer|setTimer|projection|diagnosticsActive|onNativeMutation|binding|command|cycleMap|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|activate|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restoreTargetingObservers|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting|sealedTargetingOwnership|targetingWrites|ingressClosing|diagnosticRecords|nextDiagnosticCycleOrdinal|publicCycle|requestTimer|completionTimer|requestOperation|requestInvoked|bindingState|operations|requested|settled|elementId|runtimeSlotNumber|current|valid|consumed|operation|protocol|restore|handle|fired|deadlines|externalReadyMs|requestStartMs|completionMs|requestPlan|classifyRenderEnded|encodedBytes|readPhysicalElementId|retireCycle|snapshotDestroyedCycles|invalidateCyclesForElement|invalidateCyclesForPublisherCall|consumeTargetingWrite|invalidateStaleTargetingObservers|captureRetainedTargeting|targetingOwnership|cycles|facts|nextTraceTokenOrdinal|overflowCount|dropCount|nextCycleOrdinal|quarantines|records|responseIdentifier|seen|state|unknownPriorCycle|ordinal|timer|start|closeIngress|captureHandoff|captureDiagnosticsHandoff|detachCommittedSlots|dispose)$/; + /^(?:options|clearTimer|setTimer|projection|diagnosticsActive|onNativeMutation|binding|command|cycleMap|createdSlots|diagnosticFacts|diagnosticListeners|targetingObservers|targetingRestorers|publisherCallRestorers|timers|service|started|disposed|ingressClosed|firstAction|commandQueue|commandQueueIndex|createdBinding|renderListener|requestedListener|committedSlotsDetached|detachedSlots|diagnosticFactOverflow|diagnosticFactDrops|targetingWriteDepth|ensureBinding|restoreCreatedBinding|activate|installListeners|removeListener|removePendingCommand|failCycle|failRows|journalPublisherTargeting|observePublisherTargeting|observePublisherCalls|restorePublisherCalls|restoreTargetingObservers|restorePublisherTargeting|notifyNativeMutation|captureDiagnosticFact|clearOwnedTimer|incrementDiagnosticDrops|writeTargeting|invalidateTargeting|sealedTargetingOwnership|targetingWrites|ingressClosing|diagnosticRecords|nextDiagnosticCycleOrdinal|publicCycle|requestTimer|completionTimer|requestOperation|requestInvoked|bindingState|operations|requested|settled|current|valid|consumed|operation|protocol|restore|handle|fired|deadlines|externalReadyMs|requestStartMs|completionMs|requestPlan|classifyRenderEnded|encodedBytes|readPhysicalElementId|retireCycle|snapshotDestroyedCycles|invalidateCyclesForElement|invalidateCyclesForPublisherCall|consumeTargetingWrite|invalidateStaleTargetingObservers|captureRetainedTargeting|targetingOwnership|cycles|facts|nextTraceTokenOrdinal|overflowCount|dropCount|nextCycleOrdinal|quarantines|records|responseIdentifier|seen|state|unknownPriorCycle|ordinal|timer|start|closeIngress|captureHandoff|captureDiagnosticsHandoff|detachCommittedSlots|dispose)$/; fs.rmSync(distributionDirectory, { recursive: true, force: true }); fs.mkdirSync(distributionDirectory, { recursive: true }); @@ -248,6 +251,9 @@ async function buildArtifact(artifact) { entryFileNames: artifact.file, extend: false, name: `tsjs_${artifact.id}`, + ...(artifact.id === 'core' + ? { banner: runtimeClaimBanner, footer: runtimeClaimFooter } + : {}), }, }, }, diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs index 742c0390c..aacf85a84 100644 --- a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs +++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs @@ -102,11 +102,13 @@ const CURRENT_PROVIDER_SOURCE_OWNERS = Object.freeze({ 'src/adapters/prebid.ts': 'prebid', 'src/integrations/prebid/module.ts': 'prebid', 'src/integrations/prebid/startup.ts': 'prebid', + 'src/integrations/render_runtime/prebid_selection.ts': 'core', 'src/integrations/sourcepoint/consent.ts': 'sourcepoint_consent', }); const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ 'src/first_display/agent.ts': 'bootstrap', 'src/first_display/base_marker.ts': 'first_display', + 'src/first_display/leaf/browser_route_owner.ts': 'bootstrap', 'src/first_display/leaf/projection.ts': 'bootstrap', 'src/first_display/render_journal.ts': 'render_owner_initial', 'src/first_display/render_bridge.ts': 'aps_initial', @@ -130,14 +132,8 @@ const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ 'src/integrations/aps/documents.ts': 'aps', 'src/integrations/aps/module.ts': 'aps', 'src/integrations/aps/render.ts': 'aps', - 'src/integrations/creative/click.ts': 'creative', - 'src/integrations/creative/dynamic_src_guard.ts': 'creative', - 'src/integrations/creative/iframe.ts': 'creative', - 'src/integrations/creative/image.ts': 'creative', 'src/integrations/creative/index.ts': 'creative', 'src/integrations/creative/module.ts': 'creative', - 'src/integrations/creative/proxy_sign.ts': 'creative', - 'src/integrations/creative/startup.ts': 'creative', 'src/integrations/datadome/index.ts': 'datadome', 'src/integrations/datadome/module.ts': 'datadome', 'src/integrations/datadome/script_guard.ts': 'datadome', @@ -160,6 +156,7 @@ const CURRENT_EXACT_SOURCE_OWNERS = Object.freeze({ 'src/integrations/gpt_diagnostics/overlay.ts': 'diagnostics_presentation', 'src/integrations/gpt_diagnostics/presentation.ts': 'diagnostics_presentation', 'src/integrations/gpt_diagnostics/presentation_helpers.ts': 'diagnostics_presentation', + 'src/integrations/gpt_diagnostics/slot_size_observer.ts': 'diagnostics_presentation', 'src/integrations/lockr/index.ts': 'lockr', 'src/integrations/lockr/module.ts': 'lockr', 'src/integrations/lockr/script_guard.ts': 'lockr', @@ -204,6 +201,7 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/log.ts': Object.freeze([ 'bootstrap', 'core', + 'creative_initial', 'creative', 'datadome', 'didomi', @@ -270,6 +268,12 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'src/core/styles/normalize.css?inline': Object.freeze(['core']), 'src/core/templates/iframe.html?raw': Object.freeze(['core']), 'src/core/trace.ts': Object.freeze(['core', 'gpt_diagnostics']), + 'src/integrations/creative/click.ts': Object.freeze(['creative_initial', 'creative']), + 'src/integrations/creative/dynamic_src_guard.ts': Object.freeze(['creative_initial', 'creative']), + 'src/integrations/creative/iframe.ts': Object.freeze(['creative_initial', 'creative']), + 'src/integrations/creative/image.ts': Object.freeze(['creative_initial', 'creative']), + 'src/integrations/creative/proxy_sign.ts': Object.freeze(['creative_initial', 'creative']), + 'src/integrations/creative/startup.ts': Object.freeze(['creative_initial', 'creative']), 'src/kernel/contracts/message_protocol.ts': Object.freeze(['core', 'gpt']), 'src/kernel/contracts/release_capacity.ts': Object.freeze(['core']), 'src/kernel/contracts/puc_dynamic_owner.ts': Object.freeze(['render_owner_initial', 'gpt']), @@ -297,7 +301,7 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'testlight', ]), 'src/kernel/sessions.ts': Object.freeze(['core']), - 'src/shared/async.ts': Object.freeze(['creative', 'datadome']), + 'src/shared/async.ts': Object.freeze(['creative_initial', 'creative', 'datadome']), 'src/shared/first_display_contracts.ts': Object.freeze(['bootstrap', 'core']), 'src/shared/first_display_handoff.ts': Object.freeze(['bootstrap', 'first_display', 'core']), 'src/shared/first_display_registration.ts': Object.freeze(['bootstrap', 'first_display']), @@ -329,10 +333,11 @@ const CURRENT_SHARED_SOURCE_OWNER_POLICIES = Object.freeze({ 'permutive_context', 'sourcepoint_consent', ]), - 'src/shared/globals.ts': Object.freeze(['creative']), - 'src/shared/origin.ts': Object.freeze(['core', 'creative']), + 'src/shared/globals.ts': Object.freeze(['creative_initial', 'creative']), + 'src/shared/gpt_diagnostics.ts': Object.freeze(['gpt', 'gpt_diagnostics']), + 'src/shared/origin.ts': Object.freeze(['core', 'creative_initial', 'creative']), 'src/shared/realm.ts': Object.freeze(['gpt_diagnostics', 'diagnostics_presentation']), - 'src/shared/scheduler.ts': Object.freeze(['creative']), + 'src/shared/scheduler.ts': Object.freeze(['creative_initial', 'creative']), 'src/shared/script_guard.ts': Object.freeze([ 'datadome', 'google_tag_manager', @@ -466,7 +471,7 @@ function loadHistoricalReleaseCatalog(capture, label) { /** Authenticate the immutable inputs and tool versions recorded by a historical capture. */ export function validateCaptureSourceProvenance( capture, - { buildInputs = CAPTURE_BUILD_INPUTS, head = 'HEAD', npmAuthorityCapture = capture } = {} + { buildInputs = CAPTURE_BUILD_INPUTS, npmAuthorityCapture = capture } = {} ) { const sha = capture?.source?.sha; if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/u.test(sha)) { @@ -488,15 +493,6 @@ export function validateCaptureSourceProvenance( fail('capture source SHA does not identify a commit'); } - try { - execFileSync('git', ['merge-base', '--is-ancestor', sha, head], { - cwd: repositoryRoot, - stdio: 'ignore', - }); - } catch { - fail('capture source SHA is not an ancestor of HEAD'); - } - for (const input of buildInputs) { try { execFileSync('git', ['cat-file', '-e', `${sha}:${input}`], { diff --git a/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs index d1f490894..74c9edcf7 100644 --- a/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs +++ b/crates/trusted-server-js/lib/scripts/check-hard-cutover-absence.mjs @@ -54,7 +54,11 @@ const shippedTsjsFiles = collect(path.join(packageRoot, 'src')); const productionTsjsFiles = shippedTsjsFiles.filter( (file) => !/(?:_test|\.test)\.[cm]?[jt]sx?$/u.test(file) ); -const generatedTsjsFiles = collect(path.join(packageRoot, 'dist')); +export function generatedTsjsArtifactFiles(root = packageRoot) { + return collect(path.resolve(root, '../dist')); +} + +const generatedTsjsFiles = generatedTsjsArtifactFiles(); const currentGuideFiles = collect(path.join(repositoryRoot, 'docs/guide')); const browserTestFiles = collect( path.join(repositoryRoot, 'crates/trusted-server-integration-tests/browser') @@ -403,7 +407,7 @@ for (const file of forbiddenFiles) { const requiredReplacements = [ ['crates/trusted-server-core/src/tsjs.rs', 'IntegrationConfigsV1'], - ['crates/trusted-server-js/lib/src/core/index.ts', '_claimBootSnapshot'], + ['crates/trusted-server-js/lib/src/core/index.ts', '_claimRuntimeV1'], ['crates/trusted-server-js/lib/src/integrations/didomi/module.ts', 'proxyPath'], ['crates/trusted-server-js/lib/src/integrations/prebid/module.ts', 'clientSideBidders'], ['crates/trusted-server-js/lib/src/integrations/sourcepoint/module.ts', 'rewriteSdk'], diff --git a/crates/trusted-server-js/lib/scripts/check-retired-concept-audit.mjs b/crates/trusted-server-js/lib/scripts/check-retired-concept-audit.mjs index 3e80147a0..c1a64fdef 100644 --- a/crates/trusted-server-js/lib/scripts/check-retired-concept-audit.mjs +++ b/crates/trusted-server-js/lib/scripts/check-retired-concept-audit.mjs @@ -14,7 +14,7 @@ const RECORDED_RETIRED_SNAPSHOT = '905984e62a0858c53d9f0ff6dd3a1bf190cf311d'; const RECORDED_INVENTORY_SHA256 = 'b1e28c8b30f0b8d95e38c0f8f57394df4ad43f760ae7abf5631e2054228aef08'; const RECORDED_MAIN_AUDIT_SHA = 'f6a2fb85ce623bf8a574e3941e1ee349acc3412d'; -const RECORDED_RC_BASELINE_SHA = 'f0825604ec6740111e99dd8a178e3b880e7d772b'; +const RECORDED_RC_BASELINE_SHA = '985ff22987d80cc729f45f702e0c3548e429b2cf'; const RECORDED_HISTORICAL_MAIN_ROWS_SHA256 = 'c9cd93ee97e61a5765a7a569b2ea5693cbbb671484120afe4726d3b05fdced68'; const HISTORICAL_PERFORMANCE_SHA256 = @@ -29,7 +29,12 @@ const HISTORICAL_IMPLEMENTATION_GAP_IDS = new Set([ 'RCJ-QUAL-01', 'RCJ-TRACE-01', ]); -const RC_IMPLEMENTATION_GAP_IDS = new Set(HISTORICAL_IMPLEMENTATION_GAP_IDS); +const RC_IMPLEMENTATION_GAP_IDS = new Set([ + 'RCJ-APS-03', + 'RCJ-APS-04', + 'RCJ-QUAL-01', + 'RCJ-TRACE-01', +]); const DISPOSITIONS = new Set(['preserve', 'rebuild', 'supersede', 'exclude']); const HISTORICAL_CLASSIFICATION_KEYS = [ 'classification', diff --git a/crates/trusted-server-js/lib/src/adapters/googletag.ts b/crates/trusted-server-js/lib/src/adapters/googletag.ts index 0c351d4be..e6f8a9a56 100644 --- a/crates/trusted-server-js/lib/src/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/adapters/googletag.ts @@ -172,6 +172,8 @@ export interface GoogletagFacade { adUnitPath?(slot: object): unknown; bindingToken(): object; clearTargeting(slot: object, key?: string): unknown; + /** Enable SRA and GPT services exactly once before the first TS-owned request. */ + enableServices(): void; transactionalDefine( definition: GoogletagReplacementDefinition, isGenerationCurrent: () => boolean, @@ -258,6 +260,18 @@ export type GoogletagDiagnosticsEventName = | 'impressionViewable' | 'slotVisibilityChanged'; +/** Safe Ad Manager identifiers copied from one GPT render callback. */ +export interface GoogletagDiagnosticsAdManagerIdentity { + readonly lineItemId?: number; + readonly creativeId?: number; + readonly campaignId?: number; + readonly advertiserId?: number; + readonly sourceAgnosticLineItemId?: number; + readonly sourceAgnosticCreativeId?: number; + readonly yieldGroupIds?: readonly number[]; + readonly companyIds?: readonly number[]; +} + export interface GoogletagDiagnosticsFact { readonly kind: GoogletagDiagnosticsEventName; readonly observedAtMs: number; @@ -268,6 +282,7 @@ export interface GoogletagDiagnosticsFact { readonly slotContentChanged?: boolean; readonly inViewPercentage?: number; readonly responseIdentifier?: string; + readonly adManager?: GoogletagDiagnosticsAdManagerIdentity; } export type GptSlotTokenV1 = string & { readonly __brand: 'GptSlotTokenV1' }; @@ -663,6 +678,12 @@ function createFacade( bindingToken: (): object => bindingToken, clearTargeting: (slot: object, key?: string): unknown => call(slot, 'clearTargeting', key === undefined ? [] : [key]), + enableServices: (): void => { + const currentService = service(); + if (value(binding.binding, 'pubadsReady') === true) return; + call(currentService, 'enableSingleRequest', []); + call(binding.binding, 'enableServices', []); + }, transactionalDefine: ( definition: GoogletagReplacementDefinition, isGenerationCurrent: () => boolean, @@ -1561,6 +1582,38 @@ export function createBrowserGoogletagAdapter( size = Object.freeze([width, height]); } } + const positiveInteger = (name: string): number | undefined => { + const value = safeMember(event as object, name); + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 + ? value + : undefined; + }; + const positiveIntegerList = (name: string): readonly number[] | undefined => { + const value = safeMember(event as object, name); + if (!Array.isArray(value)) return undefined; + const result = value + .map((entry) => + typeof entry === 'number' && Number.isSafeInteger(entry) && entry > 0 + ? entry + : undefined + ) + .filter((entry): entry is number => entry !== undefined) + .slice(0, 8); + return result.length === 0 ? undefined : Object.freeze(result); + }; + const adManagerCandidate = { + lineItemId: positiveInteger('lineItemId'), + creativeId: positiveInteger('creativeId'), + campaignId: positiveInteger('campaignId'), + advertiserId: positiveInteger('advertiserId'), + sourceAgnosticLineItemId: positiveInteger('sourceAgnosticLineItemId'), + sourceAgnosticCreativeId: positiveInteger('sourceAgnosticCreativeId'), + yieldGroupIds: positiveIntegerList('yieldGroupIds'), + companyIds: positiveIntegerList('companyIds'), + }; + const adManager = Object.fromEntries( + Object.entries(adManagerCandidate).filter(([, value]) => value !== undefined) + ) as GoogletagDiagnosticsAdManagerIdentity; return Object.freeze({ ...base, kind: eventType, @@ -1568,6 +1621,7 @@ export function createBrowserGoogletagAdapter( ...(size ? { size } : {}), ...(typeof isBackfill === 'boolean' ? { isBackfill } : {}), ...(typeof slotContentChanged === 'boolean' ? { slotContentChanged } : {}), + ...(Object.keys(adManager).length === 0 ? {} : { adManager: Object.freeze(adManager) }), }); } default: diff --git a/crates/trusted-server-js/lib/src/composition/browser_test.ts b/crates/trusted-server-js/lib/src/composition/browser_test.ts index 8a6de3652..a14697cfc 100644 --- a/crates/trusted-server-js/lib/src/composition/browser_test.ts +++ b/crates/trusted-server-js/lib/src/composition/browser_test.ts @@ -77,7 +77,7 @@ import { createPrebidSelectionCoordinator, publishPrebidBid, type PrebidSelectionCoordinator, -} from '../integrations/prebid/module'; +} from '../integrations/render_runtime/prebid_selection'; import { createPrebidRefreshPolicy, createPrebidSyntheticRefreshRunner, diff --git a/crates/trusted-server-js/lib/src/core/bootstrap.ts b/crates/trusted-server-js/lib/src/core/bootstrap.ts index 9fcd023fb..ff687b481 100644 --- a/crates/trusted-server-js/lib/src/core/bootstrap.ts +++ b/crates/trusted-server-js/lib/src/core/bootstrap.ts @@ -5,9 +5,14 @@ import { type FirstDisplayAgent, type FirstDisplayAgentRegistrationHostV1, } from '../first_display/agent'; +import { + registerFirstDisplayBrowserRoute, + type FirstDisplayBrowserRouteRuleV1, +} from '../first_display/leaf/browser_route_owner'; import type { InitialSliceInstaller } from '../first_display/slices/definition'; import type { FirstDisplaySliceId } from '../kernel/release_catalog'; import type { FirstDisplaySliceActivationContext } from '../shared/first_display_transaction'; +import type { FirstDisplayAgentCaptureFinalizerV1 } from '../shared/first_display_handoff'; import type { ClaimedFirstDisplayTakeoverV1, FirstDisplayTakeoverClaim } from '../shared/takeover'; import { @@ -178,11 +183,55 @@ function publishFallback( function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSnapshotV1): void { const ingress = prepareIngress(target); const firstDisplay = boot.manifest.firstDisplay; + const NativeHtmlScriptElement = window.HTMLScriptElement; + const nativeArrayIsArray = Array.isArray; + const nativeObjectDefineProperty = Object.defineProperty; + const nativeObjectFreeze = Object.freeze; + const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const nativeObjectIsFrozen = Object.isFrozen; + const nativeReflectApply = Reflect.apply; + const nativeReflectOwnKeys = Reflect.ownKeys; + const nativeCurrentScriptGetter = (() => { + try { + const constructor = document.defaultView?.Document; + const descriptor = constructor + ? nativeObjectGetOwnPropertyDescriptor(constructor.prototype, 'currentScript') + : undefined; + return typeof descriptor?.get === 'function' ? descriptor.get : undefined; + } catch { + return undefined; + } + })(); + const currentScript = (): HTMLScriptElement | null => { + if (!nativeCurrentScriptGetter) return null; + try { + const value = nativeReflectApply(nativeCurrentScriptGetter, document, []); + return value instanceof NativeHtmlScriptElement ? value : null; + } catch { + return null; + } + }; + let runtimeReadSource: HTMLScriptElement | undefined; + let runtimeReadLive = false; + let runtimeNamespaceSealed = false; + const sealRuntimeNamespace = (): void => { + if (runtimeNamespaceSealed) return; + nativeObjectDefineProperty(window, 'tsjs', { + configurable: false, + enumerable: true, + get: () => { + if (!runtimeReadLive || currentScript() !== runtimeReadSource) return target; + runtimeReadLive = false; + return runtimeReadSource; + }, + }); + runtimeNamespaceSealed = true; + }; const trustedOrigin = (): string | undefined => { try { if (/^https?:\/\//.test(location.origin)) return location.origin; if (location.origin !== 'null') return undefined; - const stamp = Object.getOwnPropertyDescriptor(window, '__tsCreativeOrigin'); + const stamp = nativeObjectGetOwnPropertyDescriptor(window, '__tsCreativeOrigin'); if ( !stamp || stamp.configurable || @@ -227,16 +276,6 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn let bootClaimState: 'available' | 'claiming' | 'claimed' = 'available'; let bootClaimCompleted = false; let completeClaimedBoot: ((outcome: BootClaimOutcome) => void) | undefined; - const removeBootClaim = (): void => { - try { - const descriptor = Object.getOwnPropertyDescriptor(target, '_claimBootSnapshot'); - if (descriptor?.configurable && 'value' in descriptor && descriptor.value === claimBoot) { - Reflect.deleteProperty(target, '_claimBootSnapshot'); - } - } catch { - // The closure-retained snapshot remains authoritative after hostile replacement. - } - }; const completeBootClaim = (outcome: BootClaimOutcome): void => { const acceptedOutcome = outcome === 'kernel' || outcome === 'abi_mismatch' || outcome === 'bundle_partial'; @@ -253,7 +292,12 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn completeClaimedBoot = undefined; complete(outcome); }; - const claimedBoot = Object.freeze({ boot, integrity, complete: completeBootClaim }); + const claimedBoot = nativeObjectFreeze({ + boot, + integrity, + complete: completeBootClaim, + currentScript, + }); const claimBoot = (source: unknown): Readonly | undefined => { if (bootClaimState !== 'available') return undefined; bootClaimState = 'claiming'; @@ -261,8 +305,8 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn try { const expectedId = firstDisplay === null ? 'trustedserver-js' : 'trustedserver-js-runtime'; accepted = - source instanceof HTMLScriptElement && - document.currentScript === source && + source instanceof NativeHtmlScriptElement && + currentScript() === source && authentic(source, boot.manifest.runtimeSrc, expectedId); } catch { // The claim stays reserved only until this authentication attempt fails. @@ -272,40 +316,53 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn return undefined; } bootClaimState = 'claimed'; - removeBootClaim(); return claimedBoot; }; - const installBootClaim = (): void => { - Object.defineProperty(target, '_claimBootSnapshot', { - configurable: true, + const installRuntimeClaim = ( + script: HTMLScriptElement, + mode: 'direct' | 'takeover', + bind: (input: unknown) => unknown + ): boolean => { + if (nativeObjectGetOwnPropertyDescriptor(script, '_claimRuntimeV1')) return false; + let live = true; + const claim = (source: unknown): Readonly> | undefined => { + if (!live) return undefined; + live = false; + const claimed = claimBoot(source); + if (!claimed) { + live = true; + return undefined; + } + return nativeObjectFreeze({ ...claimed, target, source, mode, bind }); + }; + nativeObjectDefineProperty(script, '_claimRuntimeV1', { + configurable: false, enumerable: false, - value: claimBoot, + value: claim, writable: false, }); + runtimeReadSource = script; + runtimeReadLive = true; + return true; }; if (firstDisplay === null) { let terminal = false; let cancelRuntime: (() => void) | undefined; + let directClaimState: 'available' | 'claiming' | 'claimed' = 'available'; + let claimObserver: MutationObserver | undefined; let timer: number | undefined; - const removeClaim = (): void => { - try { - const descriptor = Object.getOwnPropertyDescriptor(target, '_claimDirectRuntime'); - if (descriptor?.configurable) Reflect.deleteProperty(target, '_claimDirectRuntime'); - } catch { - // A hostile replacement cannot make the captured claim callable again. - } - }; const fallback = (reason: BootFailureReason = 'bundle_partial'): void => { if (terminal) return; terminal = true; + runtimeReadLive = false; if (timer !== undefined) window.clearTimeout(timer); try { cancelRuntime?.(); } catch { // Continue to the terminal shell after releasing the persistent owner. } - removeClaim(); - removeBootClaim(); + claimObserver?.disconnect(); + claimObserver = undefined; publishFallback(target, ingress, fallbackFields(boot, reason, false)); }; completeClaimedBoot = (outcome) => { @@ -315,16 +372,23 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn source: unknown, cancel: unknown ): ((outcome: 'kernel' | 'runtime_fallback' | 'failed_start') => void) | undefined => { - if ( - terminal || - !(source instanceof HTMLScriptElement) || - document.currentScript !== source || - !authentic(source, boot.manifest.runtimeSrc, 'trustedserver-js') || - typeof cancel !== 'function' || - cancelRuntime - ) { + if (terminal || directClaimState !== 'available') return undefined; + directClaimState = 'claiming'; + let accepted = false; + try { + accepted = + source instanceof NativeHtmlScriptElement && + currentScript() === source && + authentic(source, boot.manifest.runtimeSrc, 'trustedserver-js') && + typeof cancel === 'function'; + } catch { + // The reservation rolls back only when this authentication attempt fails. + } + if (!accepted) { + directClaimState = 'available'; return undefined; } + directClaimState = 'claimed'; cancelRuntime = cancel as () => void; return (outcome): void => { if (terminal) return; @@ -334,23 +398,52 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn } terminal = true; if (timer !== undefined) window.clearTimeout(timer); - removeClaim(); + claimObserver?.disconnect(); + claimObserver = undefined; }; }; + const installDirectClaims = (script: HTMLScriptElement): boolean => { + try { + if ( + terminal || + !authentic(script, boot.manifest.runtimeSrc, 'trustedserver-js') || + !installRuntimeClaim(script, 'direct', (cancel) => claim(script, cancel)) + ) { + return false; + } + claimObserver?.disconnect(); + claimObserver = undefined; + return true; + } catch { + return false; + } + }; try { - Object.defineProperty(target, 'boot', { + sealRuntimeNamespace(); + nativeObjectDefineProperty(target, 'boot', { configurable: true, enumerable: true, value: boot, writable: false, }); - installBootClaim(); - Object.defineProperty(target, '_claimDirectRuntime', { - configurable: true, - enumerable: false, - value: claim, - writable: false, + claimObserver = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if ( + node instanceof NativeHtmlScriptElement && + node.id === 'trustedserver-js' && + installDirectClaims(node) + ) { + return; + } + } + } }); + claimObserver.observe(document.documentElement, { childList: true, subtree: true }); + const existing = document.querySelectorAll('script#trustedserver-js'); + if (existing.length === 1 && existing[0] instanceof NativeHtmlScriptElement) { + installDirectClaims(existing[0]); + } performance.mark('tsjs:bids-script'); timer = window.setTimeout(fallback, 10_000); } catch { @@ -369,6 +462,7 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn let agent: FirstDisplayAgent | undefined; let agentScript: HTMLScriptElement | undefined; let runtimeScript: HTMLScriptElement | undefined; + let takeoverClaim: FirstDisplayTakeoverClaim | undefined; let bootstrapTimer: number | undefined; let takeoverTimer: number | undefined; let takeoverStartedAt = 0; @@ -378,7 +472,7 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn }; const removePrivate = (key: string): void => { try { - const descriptor = Object.getOwnPropertyDescriptor(target, key); + const descriptor = nativeObjectGetOwnPropertyDescriptor(target, key); if (descriptor?.configurable) Reflect.deleteProperty(target, key); } catch { // Generation invalidation makes an unremovable private field inert. @@ -401,11 +495,11 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn if (terminal) return; const initialDisplayCommitted = agent?.initialDisplayCommitted ?? false; terminal = true; + runtimeReadLive = false; current = false; clear(bootstrapTimer); clear(takeoverTimer); - removePrivate('_firstDisplayTakeover'); - removeBootClaim(); + takeoverClaim = undefined; if (runtimeScript) { runtimeScript.onload = null; runtimeScript.onerror = null; @@ -419,21 +513,73 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn }; const now = (): number => performance.now(); const startedAtMs = now(); + let runtimeScriptPolicy: + Readonly<{ createScriptURL: (value: string) => unknown }> | null | undefined; + const admittedScriptUrls = new Set([ + new URL(boot.manifest.runtimeSrc, location.origin).href, + ...boot.manifest.integrations.flatMap((entry) => + entry.phase === 'deferred' ? [new URL(entry.src, location.origin).href] : [] + ), + ]); + const trustedRuntimeScriptUrl = (expectedUrl: string): unknown => { + if (runtimeScriptPolicy === undefined) { + runtimeScriptPolicy = null; + try { + const trustedTypes = ( + window as Window & { + trustedTypes?: { + createPolicy: ( + name: string, + rules: Readonly<{ createScriptURL: (value: string) => string }> + ) => Readonly<{ createScriptURL: (value: string) => unknown }>; + }; + } + ).trustedTypes; + if (trustedTypes) { + runtimeScriptPolicy = trustedTypes.createPolicy('trusted-server#tsjs-v1', { + createScriptURL: (value: string) => { + if (!admittedScriptUrls.has(value)) throw new TypeError('tsjs'); + return value; + }, + }); + } + } catch { + runtimeScriptPolicy = null; + } + } + return runtimeScriptPolicy ? runtimeScriptPolicy.createScriptURL(expectedUrl) : expectedUrl; + }; const bridge = (selected: FirstDisplayAgent): void => { agent = selected; - let live = true; + let claimState: 'available' | 'claiming' | 'claimed' = 'available'; const claim: FirstDisplayTakeoverClaim = ( + source, finalize ): ClaimedFirstDisplayTakeoverV1 | undefined => { - if (!live) return undefined; - live = false; + if (claimState !== 'available') return undefined; + claimState = 'claiming'; + let accepted = false; + try { + accepted = + source === runtimeScript && + currentScript() === source && + source instanceof NativeHtmlScriptElement && + authentic(source, boot.manifest.runtimeSrc, 'trustedserver-js-runtime'); + } catch { + // The reservation rolls back only when this authentication attempt fails. + } + if (!accepted) { + claimState = 'available'; + return undefined; + } + claimState = 'claimed'; try { if (terminal || !current || selected.state !== 'painted') { throw new TypeError('tsjs'); } const finalized = selected.finalizeHandoff(finalize); if (!finalized) throw new TypeError('tsjs'); - return Object.freeze([ + return nativeObjectFreeze([ finalized, outline, () => current && !terminal && now() - takeoverStartedAt < 10_000, @@ -453,20 +599,17 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn } catch { // The committed persistent owner remains terminal if timer cleanup fails. } - removePrivate('_firstDisplayTakeover'); }, + trustedRuntimeScriptUrl, + currentScript, ]); } catch (error) { commitFallback('bundle_partial'); throw error; } }; - Object.defineProperty(target, '_firstDisplayTakeover', { - configurable: true, - enumerable: false, - value: claim, - writable: false, - }); + if (takeoverClaim) throw new TypeError('tsjs'); + takeoverClaim = claim; }; const protectedPaint = (): void => { try { @@ -475,11 +618,24 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn } takeoverStartedAt = now(); takeoverTimer = window.setTimeout(() => commitFallback('bundle_partial'), 10_000); + sealRuntimeNamespace(); const script = document.createElement('script'); script.id = 'trustedserver-js-runtime'; script.async = true; + if (!takeoverClaim) throw new TypeError('tsjs'); + if ( + !installRuntimeClaim(script, 'takeover', (finalize) => { + const claim = takeoverClaim; + takeoverClaim = undefined; + return claim?.(script, finalize as FirstDisplayAgentCaptureFinalizerV1); + }) + ) { + throw new TypeError('tsjs'); + } if (agentScript?.nonce) script.nonce = agentScript.nonce; - script.src = new URL(boot.manifest.runtimeSrc, location.origin).href; + const expectedUrl = new URL(boot.manifest.runtimeSrc, location.origin).href; + script.src = trustedRuntimeScriptUrl(expectedUrl) as string; + if (script.src !== expectedUrl) throw new TypeError('tsjs'); script.onerror = () => commitFallback('bundle_partial'); runtimeScript = script; (document.head ?? document.documentElement).append(script); @@ -500,23 +656,32 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn ): readonly [bindings: unknown, config: unknown] => { let bindings: unknown; if (id === 'gpt_initial') { - bindings = Object.freeze({ browser: window, observe, register }); + bindings = nativeObjectFreeze({ browser: window, observe, register }); } else if (id === 'render_owner_initial') { - bindings = Object.freeze({ observe, register }); + bindings = nativeObjectFreeze({ observe, register }); } else if (id === 'aps_initial') { - bindings = Object.freeze({ + bindings = nativeObjectFreeze({ observe, publisherOrigin: location.origin, register, }); } else if (id === 'creative_initial') { - bindings = Object.freeze({ - location: Object.freeze({ href: location.href, origin: location.origin }), + bindings = nativeObjectFreeze({ observe }); + } else if (id === 'google_tag_manager_initial') { + bindings = nativeObjectFreeze({ observe, - register: () => () => undefined, + register: (rule: FirstDisplayBrowserRouteRuleV1) => + registerFirstDisplayBrowserRoute(rule, true), }); + } else if ( + id === 'datadome_initial' || + id === 'lockr_initial' || + id === 'permutive_initial' || + id === 'sourcepoint_initial' + ) { + bindings = nativeObjectFreeze({ observe, register: registerFirstDisplayBrowserRoute }); } else if (id === 'testlight_initial') { - bindings = Object.freeze({ + bindings = nativeObjectFreeze({ enqueue: (callback: () => void) => { ingress.push(callback); }, @@ -524,14 +689,16 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn target: window, }); } else { - bindings = register ? Object.freeze({ observe, register }) : Object.freeze({ observe }); + bindings = register + ? nativeObjectFreeze({ observe, register }) + : nativeObjectFreeze({ observe }); } const product = id.endsWith('_initial') ? id.slice(0, -'_initial'.length) : ''; let config: unknown = id === 'creative_initial' ? boot.creative : id === 'render_owner_initial' - ? Object.freeze({}) + ? nativeObjectFreeze({}) : undefined; if (config === undefined && product !== '') { for (const entry of boot.integrations.entries) { @@ -541,18 +708,18 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn } } } - return Object.freeze([bindings, config]); + return nativeObjectFreeze([bindings, config]); }; - const host: FirstDisplayAgentRegistrationHostV1 = Object.freeze({ - options: Object.freeze({ - batch: Object.freeze({ + const host: FirstDisplayAgentRegistrationHostV1 = nativeObjectFreeze({ + options: nativeObjectFreeze({ + batch: nativeObjectFreeze({ version: 1, projectionDigest: outline.projectionDigest, projection: boot.auctionProjection, }), startedAtMs, performance, - paint: Object.freeze({ + paint: nativeObjectFreeze({ hidden: () => document.visibilityState === 'hidden', requestFrame: (callback: () => void) => window.requestAnimationFrame(() => callback()), scheduleHidden: (callback: () => void) => window.setTimeout(callback, 0), @@ -560,14 +727,14 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn onProtectedPaint: protectedPaint, onSettled: () => clear(bootstrapTimer), onFailure: commitFallback, - gptInput: Object.freeze([ + gptInput: nativeObjectFreeze([ window, (handle: unknown) => window.clearTimeout(handle as number), document, (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), boot.diagnostics.gpt.active, ] as const), - handoff: Object.freeze({ + handoff: nativeObjectFreeze({ releaseId: RELEASE, generation: outline.generation, integrationConfigDigest: outline.integrationConfigDigest, @@ -589,7 +756,7 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn let afterActivate: (() => void) | undefined; let ownershipOpen = true; try { - const context: FirstDisplaySliceActivationContext = Object.freeze({ + const context: FirstDisplaySliceActivationContext = nativeObjectFreeze({ own: (dispose: () => void) => { if (!ownershipOpen || terminal || typeof dispose !== 'function') { throw new TypeError('tsjs'); @@ -615,30 +782,63 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn if (terminal || !current) return false; return Boolean(agent && !terminal); }; - const register = function (this: unknown, candidate: unknown, source: unknown): boolean { + let registrationOpen = true; + let registrationClaiming = false; + const register = function (this: unknown, candidate: unknown, _source?: unknown): boolean { + if (!registrationOpen || registrationClaiming) return false; + registrationClaiming = true; try { const expectedId = firstDisplay.slices[registrations.length + 1]; + const source = currentScript(); if ( terminal || this !== target || - !(source instanceof HTMLScriptElement) || - document.currentScript !== source || + !(source instanceof NativeHtmlScriptElement) || (agentScript && source !== agentScript) || !authentic(source, firstDisplay.src, 'trustedserver-js') || - !Array.isArray(candidate) || - !Object.isFrozen(candidate) || - candidate.length !== 4 + !nativeArrayIsArray(candidate) ) { + registrationClaiming = false; return fail(); } if (terminal || !current) return false; - const fields = candidate as readonly unknown[]; + const keys = nativeReflectOwnKeys(candidate); + const length = nativeObjectGetOwnPropertyDescriptor(candidate, 'length'); + if ( + keys.length !== 5 || + keys[0] !== '0' || + keys[1] !== '1' || + keys[2] !== '2' || + keys[3] !== '3' || + keys[4] !== 'length' || + !length || + !('value' in length) || + length.value !== 4 + ) { + registrationClaiming = false; + return fail(); + } + const fields: unknown[] = []; + for (let index = 0; index < 4; index += 1) { + const descriptor = nativeObjectGetOwnPropertyDescriptor(candidate, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + registrationClaiming = false; + return fail(); + } + fields.push(descriptor.value); + } if ( fields[0] !== 1 || fields[1] !== expectedId || fields[2] !== RELEASE || typeof fields[3] !== 'function' ) { + registrationClaiming = false; + return fail(); + } + nativeObjectFreeze(candidate); + if (!nativeObjectIsFrozen(candidate)) { + registrationClaiming = false; return fail(); } agentScript = source; @@ -646,22 +846,24 @@ function installBootstrap({ target, boot, integrity, outline }: BootstrapInputSn id: fields[1] as Exclude, install: fields[3] as InitialSliceInstaller, }); + registrationClaiming = false; if (registrations.length !== firstDisplay.slices.length - 1) return true; + registrationOpen = false; return activateComponents(); } catch { + registrationClaiming = false; return fail(); } }; try { - Object.defineProperty(target, 'boot', { + nativeObjectDefineProperty(target, 'boot', { configurable: true, enumerable: true, value: boot, writable: false, }); - installBootClaim(); - Object.defineProperty(target, '_registerFirstDisplay', { + nativeObjectDefineProperty(target, '_registerFirstDisplay', { configurable: true, enumerable: false, value: register, diff --git a/crates/trusted-server-js/lib/src/core/first_impression.ts b/crates/trusted-server-js/lib/src/core/first_impression.ts deleted file mode 100644 index fc53dad36..000000000 --- a/crates/trusted-server-js/lib/src/core/first_impression.ts +++ /dev/null @@ -1,358 +0,0 @@ -import type { - FirstImpressionPhase, - FirstImpressionPublisherAuction, - FirstImpressionSlotClaim, - FirstImpressionState, - TsjsApi, -} from './types'; - -/** Time allowed for one navigation's losing first-impression delivery. */ -export const FIRST_IMPRESSION_LEASE_MS = 5000; - -const MAX_FIRST_IMPRESSION_SLOTS = 256; -const MAX_PUBLISHER_AUCTIONS_PER_SLOT = 16; - -function currentGeneration(ts: TsjsApi): number { - return ts.navGeneration ?? 0; -} - -function claimMatchesElement( - claim: FirstImpressionSlotClaim, - element: HTMLElement, - generation: number -): boolean { - return ( - claim.generation === generation && - claim.slotElementId === element.id && - claim.element === element && - element.isConnected - ); -} - -function removePublisherAuction( - state: FirstImpressionState, - claim: FirstImpressionSlotClaim, - token: string, - now: number -): void { - delete claim.publisherAuctions[token]; - if ( - claim.owner === 'publisher' && - (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && - Object.keys(claim.publisherAuctions).length === 0 && - claim.expiresAt <= now - ) { - delete state.slots[claim.slotElementId]; - } -} - -function pruneFirstImpressionState(ts: TsjsApi, now = Date.now()): FirstImpressionState { - const generation = currentGeneration(ts); - if (ts.firstImpression?.generation !== generation) { - ts.firstImpression = { generation, nextToken: 0, slots: {}, fallbackSlots: {} }; - } - - const state = ts.firstImpression; - state.slots ??= {}; - state.fallbackSlots ??= {}; - for (const [elementId, claim] of Object.entries(state.slots)) { - if (!claimMatchesElement(claim, claim.element, generation)) { - delete state.slots[elementId]; - continue; - } - for (const [token, auction] of Object.entries(claim.publisherAuctions)) { - if (auction.expiresAt <= now) removePublisherAuction(state, claim, token, now); - } - if ( - claim.owner === 'publisher' && - (claim.phase === 'auctioning' || claim.phase === 'delivery_pending') && - Object.keys(claim.publisherAuctions).length === 0 && - claim.expiresAt <= now - ) { - delete state.slots[elementId]; - } - } - for (const [elementId, element] of Object.entries(state.fallbackSlots)) { - if ( - !element.isConnected || - element.id !== elementId || - document.getElementById(elementId) !== element - ) { - delete state.fallbackSlots[elementId]; - } - } - return state; -} - -function activePhysicalElement(element: HTMLElement | null): HTMLElement | undefined { - return element?.isConnected && element.id ? element : undefined; -} - -function visibleThroughAncestors(element: HTMLElement): boolean { - for (let current: HTMLElement | null = element; current; current = current.parentElement) { - const style = window.getComputedStyle(current); - if (style.display === 'none' || style.visibility === 'hidden') return false; - } - return true; -} - -/** Resolve a publisher ad-unit code to one exact active physical slot element. */ -export function resolveFirstImpressionElement(adUnitCode: string): HTMLElement | undefined { - if (!adUnitCode) return undefined; - const exact = activePhysicalElement(document.getElementById(adUnitCode)); - if (exact) return exact; - - const matches = Array.from(document.querySelectorAll('[id]')).filter( - (element) => - element.id.startsWith(adUnitCode) && - !element.id.endsWith('-container') && - visibleThroughAncestors(element) - ); - return matches.length === 1 ? matches[0] : undefined; -} - -/** Return the live ownership claim for an exact slot element. */ -export function firstImpressionClaim( - ts: TsjsApi, - element: HTMLElement -): FirstImpressionSlotClaim | undefined { - const state = pruneFirstImpressionState(ts); - const claim = state.slots[element.id]; - return claim && claimMatchesElement(claim, element, state.generation) ? claim : undefined; -} - -function storeClaim(state: FirstImpressionState, claim: FirstImpressionSlotClaim): boolean { - if ( - !state.slots[claim.slotElementId] && - Object.keys(state.slots).length >= MAX_FIRST_IMPRESSION_SLOTS - ) { - return false; - } - state.slots[claim.slotElementId] = claim; - return true; -} - -/** Atomically claim an untouched slot for Trusted Server. */ -export function claimFirstImpressionForTrustedServer( - ts: TsjsApi, - element: HTMLElement, - now = Date.now() -): FirstImpressionSlotClaim | undefined { - const state = pruneFirstImpressionState(ts, now); - const existing = state.slots[element.id]; - if (existing && claimMatchesElement(existing, element, state.generation)) return undefined; - - const claim: FirstImpressionSlotClaim = { - generation: state.generation, - slotElementId: element.id, - element, - owner: 'trusted_server', - phase: 'delivery_pending', - expiresAt: now + FIRST_IMPRESSION_LEASE_MS, - publisherAuctions: {}, - }; - return storeClaim(state, claim) ? claim : undefined; -} - -function schedulePublisherAuctionExpiry(ts: TsjsApi, token: string): void { - window.setTimeout( - () => releasePublisherFirstImpressionAuction(ts, token), - FIRST_IMPRESSION_LEASE_MS - ); -} - -/** Release a TS claim when slot setup failed before any request could start. */ -export function releaseTrustedServerFirstImpressionClaim( - ts: TsjsApi, - element: HTMLElement, - claim: FirstImpressionSlotClaim -): void { - const state = pruneFirstImpressionState(ts); - if ( - state.slots[element.id] === claim && - claim.owner === 'trusted_server' && - claim.phase === 'delivery_pending' && - Object.keys(claim.publisherAuctions).length === 0 - ) { - delete state.slots[element.id]; - } -} - -/** Register real publisher auctions before native `requestBids()` starts. */ -export function registerPublisherFirstImpressionAuctions( - ts: TsjsApi, - adUnitCodes: Iterable, - now = Date.now() -): Map { - const state = pruneFirstImpressionState(ts, now); - const registrations = new Map(); - - for (const adUnitCode of adUnitCodes) { - const element = resolveFirstImpressionElement(adUnitCode); - if (!element) continue; - - let claim = state.slots[element.id]; - if (!claim || !claimMatchesElement(claim, element, state.generation)) { - claim = { - generation: state.generation, - slotElementId: element.id, - element, - owner: 'publisher', - phase: 'auctioning', - expiresAt: now + FIRST_IMPRESSION_LEASE_MS, - publisherAuctions: {}, - }; - if (!storeClaim(state, claim)) continue; - } - - if ( - claim.owner === 'publisher' && - (claim.phase === 'requested' || claim.phase === 'rendered') - ) { - continue; - } - if (claim.owner === 'trusted_server' && (claim.suppressionConsumed || claim.expiresAt <= now)) { - continue; - } - if (Object.keys(claim.publisherAuctions).length >= MAX_PUBLISHER_AUCTIONS_PER_SLOT) continue; - - const token = `${state.generation}:${++state.nextToken}`; - const auction: FirstImpressionPublisherAuction = { - token, - adUnitCode, - phase: 'auctioning', - expiresAt: now + FIRST_IMPRESSION_LEASE_MS, - adIds: [], - suppressDelivery: claim.owner === 'trusted_server', - }; - claim.publisherAuctions[token] = auction; - if (claim.owner === 'publisher') claim.expiresAt = Math.max(claim.expiresAt, auction.expiresAt); - registrations.set(adUnitCode, token); - schedulePublisherAuctionExpiry(ts, token); - } - - return registrations; -} - -function findPublisherAuction( - ts: TsjsApi, - token: string, - now = Date.now() -): - | { - state: FirstImpressionState; - claim: FirstImpressionSlotClaim; - auction: FirstImpressionPublisherAuction; - } - | undefined { - const state = pruneFirstImpressionState(ts, now); - for (const claim of Object.values(state.slots)) { - const auction = claim.publisherAuctions[token]; - if (auction) return { state, claim, auction }; - } - return undefined; -} - -/** Move one publisher auction to delivery-pending without disturbing overlaps. */ -export function markPublisherFirstImpressionDeliveryPending( - ts: TsjsApi, - token: string, - adIds: string[], - now = Date.now() -): void { - const found = findPublisherAuction(ts, token, now); - if (!found) return; - found.auction.phase = 'delivery_pending'; - found.auction.adIds = [...new Set(adIds)]; - if (found.claim.owner === 'publisher') found.claim.phase = 'delivery_pending'; -} - -/** Release exactly one publisher auction token after failure, timeout, or removal. */ -export function releasePublisherFirstImpressionAuction( - ts: TsjsApi, - token: string, - now = Date.now() -): void { - const found = findPublisherAuction(ts, token, now); - if (!found) return; - found.auction.expiresAt = Math.min(found.auction.expiresAt, now); - if ( - found.claim.owner === 'publisher' && - Object.keys(found.claim.publisherAuctions).length === 1 - ) { - found.claim.expiresAt = now; - } - removePublisherAuction(found.state, found.claim, token, now); -} - -/** Consume one correlated publisher delivery and report whether TS owns it. */ -export function consumePublisherFirstImpressionDelivery( - ts: TsjsApi, - token: string | undefined, - now = Date.now() -): boolean { - if (!token) return false; - const found = findPublisherAuction(ts, token, now); - if (!found) return false; - - const suppress = - found.claim.owner === 'trusted_server' && - found.auction.suppressDelivery && - !found.claim.suppressionConsumed && - found.claim.expiresAt > now; - delete found.claim.publisherAuctions[token]; - if (suppress) found.claim.suppressionConsumed = true; - return suppress; -} - -/** Record a GPT request or render, using publisher ownership when no claimant exists. */ -export function observeFirstImpressionGptLifecycle( - ts: TsjsApi, - element: HTMLElement, - phase: Extract, - now = Date.now() -): void { - const state = pruneFirstImpressionState(ts, now); - let claim = state.slots[element.id]; - if (!claim || !claimMatchesElement(claim, element, state.generation)) { - claim = { - generation: state.generation, - slotElementId: element.id, - element, - owner: 'publisher', - phase, - expiresAt: Number.POSITIVE_INFINITY, - publisherAuctions: {}, - }; - storeClaim(state, claim); - return; - } - - claim.phase = phase; - if (claim.owner === 'publisher') claim.expiresAt = Number.POSITIVE_INFINITY; -} - -/** Reserve the only Trusted Server fallback allowed for this physical slot and generation. */ -export function reservePublisherFirstImpressionFallback( - ts: TsjsApi, - element: HTMLElement -): boolean { - const state = pruneFirstImpressionState(ts); - const reservedElement = state.fallbackSlots[element.id]; - if (reservedElement) return false; - state.fallbackSlots[element.id] = element; - return true; -} - -/** Delay before an abandoned publisher claim can receive one per-slot TS fallback. */ -export function publisherFirstImpressionRetryDelay( - ts: TsjsApi, - element: HTMLElement, - now = Date.now() -): number | undefined { - const claim = firstImpressionClaim(ts, element); - if (!claim) return 0; - if (claim.owner !== 'publisher') return undefined; - if (claim.phase === 'requested' || claim.phase === 'rendered') return undefined; - return Math.max(0, claim.expiresAt - now); -} diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index 525f74538..9dacfee04 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -1,4 +1,6 @@ // Sole production bootstrap for the resilient TSJS runtime. +declare const __TSJS_RUNTIME_CLAIMED_V1__: unknown; + export type { AddAdUnitsResult, GptDiagnosticsApi, @@ -19,7 +21,7 @@ import { coordinatePreparedFirstDisplayTakeoverV1, finalizeFirstDisplayAgentCaptureV1, } from '../shared/first_display_handoff'; -import { consumeFirstDisplayTakeoverTransport } from '../shared/takeover'; +import type { ClaimedFirstDisplayTakeoverV1 } from '../shared/takeover'; import { ownDataObject } from './contracts/auction_projection'; import { snapshotFrozenTsjsBootV1 } from './contracts/boot'; @@ -36,9 +38,14 @@ type BootstrapTarget = object & { type DirectRuntimeCompletion = (outcome: 'kernel' | 'runtime_fallback' | 'failed_start') => void; interface ClaimedServerBootV1 { + readonly source: object; + readonly target: BootstrapTarget; readonly boot: Readonly; readonly integrity: Readonly; readonly complete: (outcome: 'kernel' | 'abi_mismatch' | 'bundle_partial') => void; + readonly currentScript: () => HTMLScriptElement | null; + readonly mode: 'direct' | 'takeover'; + readonly bind: (input: unknown) => unknown; } function retainServerBoot(candidate: unknown, releaseId: string): Readonly | undefined { @@ -59,73 +66,37 @@ function retainServerBoot(candidate: unknown, releaseId: string): Readonly DirectRuntimeCompletion | undefined) | null | undefined { - try { - const descriptor = Object.getOwnPropertyDescriptor(target, '_claimDirectRuntime'); - if (!descriptor) return undefined; - if ( - !descriptor.configurable || - descriptor.enumerable || - descriptor.writable || - !('value' in descriptor) || - typeof descriptor.value !== 'function' - ) { - return null; - } - return descriptor.value as ( - source: unknown, - cancel: unknown - ) => DirectRuntimeCompletion | undefined; - } catch { - return null; - } -} - export type BrowserRuntimeCompositionFactory = ( runtimeOptions: RuntimeOptions, compositionOptions: Readonly> ) => Readonly<{ runtime: Runtime }>; -function bootstrapTarget(): BootstrapTarget | undefined { - try { - 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 { - return undefined; +function claimedRuntimeCandidate(): unknown { + if (typeof __TSJS_RUNTIME_CLAIMED_V1__ !== 'undefined') { + return __TSJS_RUNTIME_CLAIMED_V1__; } -} - -function bootSnapshotClaim( - target: BootstrapTarget -): ((source: unknown) => Readonly | undefined) | null | undefined { try { - const descriptor = Object.getOwnPropertyDescriptor(target, '_claimBootSnapshot'); - if (!descriptor) return undefined; - if ( - !descriptor.configurable || - descriptor.enumerable || - descriptor.writable || - !('value' in descriptor) || - typeof descriptor.value !== 'function' - ) { - return null; - } - return descriptor.value as (source: unknown) => Readonly | undefined; + const source = (window as unknown as { tsjs?: unknown }).tsjs; + if ((typeof source !== 'object' && typeof source !== 'function') || source === null) return; + const claim = (source as { _claimRuntimeV1?: unknown })._claimRuntimeV1; + return typeof claim === 'function' ? claim(source) : undefined; } catch { - return null; + return; } } function retainClaimedServerBootV1(candidate: unknown): ClaimedServerBootV1 | undefined { try { - const fields = ownDataObject(candidate, ['boot', 'integrity', 'complete']); + const fields = ownDataObject(candidate, [ + 'boot', + 'integrity', + 'complete', + 'currentScript', + 'source', + 'target', + 'mode', + 'bind', + ]); if (!fields || !Object.isFrozen(candidate)) return undefined; const boot = retainServerBoot(fields['boot'], EMBEDDED_RELEASE_ID); const integrity = fields['integrity']; @@ -138,7 +109,14 @@ function retainClaimedServerBootV1(candidate: unknown): ClaimedServerBootV1 | un !boot || !acceptedIntegrity || !Object.isFrozen(integrity) || - typeof fields['complete'] !== 'function' + typeof fields['complete'] !== 'function' || + typeof fields['currentScript'] !== 'function' || + (typeof fields['source'] !== 'object' && typeof fields['source'] !== 'function') || + fields['source'] === null || + (typeof fields['target'] !== 'object' && typeof fields['target'] !== 'function') || + fields['target'] === null || + (fields['mode'] !== 'direct' && fields['mode'] !== 'takeover') || + typeof fields['bind'] !== 'function' ) { return undefined; } @@ -158,7 +136,16 @@ function retainClaimedServerBootV1(candidate: unknown): ClaimedServerBootV1 | un } function claimedBootCompletion(candidate: unknown): ClaimedServerBootV1['complete'] | undefined { - const fields = ownDataObject(candidate, ['boot', 'integrity', 'complete']); + const fields = ownDataObject(candidate, [ + 'boot', + 'integrity', + 'complete', + 'currentScript', + 'source', + 'target', + 'mode', + 'bind', + ]); return fields && typeof fields['complete'] === 'function' ? (fields['complete'] as ClaimedServerBootV1['complete']) : undefined; @@ -166,12 +153,7 @@ function claimedBootCompletion(candidate: unknown): ClaimedServerBootV1['complet /** Claim the browser namespace and start the injected sole composition root. */ export function startProductionRuntime(createComposition: BrowserRuntimeCompositionFactory): void { - const target = bootstrapTarget(); - if (!target) return; - const claimBoot = bootSnapshotClaim(target); - if (!claimBoot) return; - const source = document.currentScript; - const candidate = claimBoot(source); + const candidate = claimedRuntimeCandidate(); const completeBoot = claimedBootCompletion(candidate); const claimed = retainClaimedServerBootV1(candidate); if (!claimed) { @@ -182,12 +164,34 @@ export function startProductionRuntime(createComposition: BrowserRuntimeComposit } return; } + const source = claimed.source; + const target = claimed.target; + let authenticatedSource: HTMLScriptElement | null; + try { + authenticatedSource = claimed.currentScript(); + } catch { + authenticatedSource = null; + } + if (authenticatedSource !== source) { + try { + claimed.complete('abi_mismatch'); + } catch { + // The authenticated bootstrap watchdog remains the terminal fallback owner. + } + return; + } const { boot } = claimed; - const takeover = consumeFirstDisplayTakeoverTransport(target); - if (takeover.status === 'invalid') return; - let takeoverClaim = takeover.status === 'accepted' ? takeover.claim : undefined; - const claimDirect = takeover.status === 'absent' ? directRuntimeClaim(target) : undefined; - if (claimDirect === null) return; + const directMode = boot.manifest.firstDisplay === null; + if ((directMode && claimed.mode !== 'direct') || (!directMode && claimed.mode !== 'takeover')) { + try { + claimed.complete('abi_mismatch'); + } catch { + // The authenticated bootstrap watchdog remains the terminal fallback owner. + } + return; + } + let trustedScriptUrl: ((value: string) => unknown) | undefined; + let trustedCurrentScript: (() => HTMLScriptElement | null) | undefined = claimed.currentScript; const composition = createComposition( { target, @@ -196,13 +200,19 @@ export function startProductionRuntime(createComposition: BrowserRuntimeComposit knownIntegrationIds: EMBEDDED_INTEGRATION_IDS, catalog: EMBEDDED_RUNTIME_CATALOG, boot, - ...(takeoverClaim + currentScript: () => trustedCurrentScript?.() ?? null, + ...(!directMode ? { + trustedScriptUrl: (value: string) => { + if (!trustedScriptUrl) throw new TypeError('tsjs'); + return trustedScriptUrl(value); + }, coordinateTakeover: (prepared) => { - const claim = takeoverClaim; - takeoverClaim = undefined; - const leased = claim?.(finalizeFirstDisplayAgentCaptureV1); + const leased = claimed.bind(finalizeFirstDisplayAgentCaptureV1) as + ClaimedFirstDisplayTakeoverV1 | undefined; if (!leased) throw new TypeError('tsjs'); + trustedScriptUrl = leased[9]; + trustedCurrentScript = leased[10]; if ( !coordinatePreparedFirstDisplayTakeoverV1({ prepared, @@ -243,8 +253,22 @@ export function startProductionRuntime(createComposition: BrowserRuntimeComposit }, {} ); - const completeDirect = claimDirect?.(document.currentScript, () => composition.runtime.dispose()); - if (claimDirect && !completeDirect) return; + const completeDirect = directMode + ? (claimed.bind(() => composition.runtime.dispose()) as DirectRuntimeCompletion | undefined) + : undefined; + if (directMode && !completeDirect) { + try { + composition.runtime.dispose(); + } catch { + // Completion still commits the independently authenticated terminal fallback. + } + try { + claimed.complete('abi_mismatch'); + } catch { + // The authenticated bootstrap watchdog remains the terminal fallback owner. + } + return; + } if (!composition.runtime.start()) { completeDirect?.('failed_start'); } diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index ea47b0464..f88010c51 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -134,14 +134,14 @@ export interface GptDiagnosticsDurations { * Manager delivered; they claim nothing about which demand source supplied it. */ export interface GptDiagnosticsAdManagerIdentity { - lineItemId?: number; - creativeId?: number; - campaignId?: number; - advertiserId?: number; - sourceAgnosticLineItemId?: number; - sourceAgnosticCreativeId?: number; - yieldGroupIds?: number[]; - companyIds?: number[]; + lineItemId?: number | undefined; + creativeId?: number | undefined; + campaignId?: number | undefined; + advertiserId?: number | undefined; + sourceAgnosticLineItemId?: number | undefined; + sourceAgnosticCreativeId?: number | undefined; + yieldGroupIds?: number[] | undefined; + companyIds?: number[] | undefined; } /** @@ -149,31 +149,19 @@ export interface GptDiagnosticsAdManagerIdentity { * facts GPT reported. */ export type GptDiagnosticsResponseClass = - | 'empty' - | 'backfill' - | 'reservation' - | 'unclassified_non_empty'; + 'empty' | 'backfill' | 'reservation' | 'unclassified_non_empty'; /** The request path observed for a GPT request cycle. */ export type GptDiagnosticsRequestPath = - | 'trusted_server_direct' - | 'prebid_refresh' - | 'publisher_refresh' - | 'competing' - | 'unattributed'; + 'trusted_server_direct' | 'prebid_refresh' | 'publisher_refresh' | 'competing' | 'unattributed'; /** The Trusted Server creative opportunity observed for a request. */ export type GptDiagnosticsTrustedServerOpportunity = - | 'renderable_candidate' - | 'unrenderable_candidate' - | 'no_candidate'; + 'renderable_candidate' | 'unrenderable_candidate' | 'no_candidate'; /** A safe failure category observed while obtaining or posting creative markup. */ export type GptDiagnosticsCreativeFailure = - | 'missing_render_source' - | 'cache_fetch_failed' - | 'invalid_cache_payload' - | 'response_post_failed'; + 'missing_render_source' | 'cache_fetch_failed' | 'invalid_cache_payload' | 'response_post_failed'; /** Delivery evidence derived for a GPT request cycle. */ export type GptDiagnosticsDelivery = @@ -206,23 +194,23 @@ export interface GptDiagnosticsRequestCycle { isBackfill?: boolean | undefined; slotContentChanged?: boolean | undefined; incompleteSequence: boolean; - adManager?: GptDiagnosticsAdManagerIdentity; - responseClass?: GptDiagnosticsResponseClass; - requestPath?: GptDiagnosticsRequestPath; - requestIntentId?: number; - trustedServerAuctionId?: string; - opportunityToRequestMs?: number; - replacedRequestNumber?: number; - previousRenderToRequestMs?: number; - creativeChanged?: boolean; - previousCreativeId?: GptDiagnosticsAdManagerIdentity['creativeId']; - loadObservedBeforeRender?: boolean; - trustedServerOpportunity?: GptDiagnosticsTrustedServerOpportunity; - trustedServerCreativeRequestAtMs?: number; - trustedServerCreativeResponseAtMs?: number; - trustedServerCreativeFailures?: GptDiagnosticsCreativeFailure[]; + adManager?: GptDiagnosticsAdManagerIdentity | undefined; + responseClass?: GptDiagnosticsResponseClass | undefined; + requestPath?: GptDiagnosticsRequestPath | undefined; + requestIntentId?: number | undefined; + trustedServerAuctionId?: string | undefined; + opportunityToRequestMs?: number | undefined; + replacedRequestNumber?: number | undefined; + previousRenderToRequestMs?: number | undefined; + creativeChanged?: boolean | undefined; + previousCreativeId?: GptDiagnosticsAdManagerIdentity['creativeId'] | undefined; + loadObservedBeforeRender?: boolean | undefined; + trustedServerOpportunity?: GptDiagnosticsTrustedServerOpportunity | undefined; + trustedServerCreativeRequestAtMs?: number | undefined; + trustedServerCreativeResponseAtMs?: number | undefined; + trustedServerCreativeFailures?: GptDiagnosticsCreativeFailure[] | undefined; /** Derived on every snapshot; absent only on a cycle read before derivation. */ - delivery?: GptDiagnosticsDelivery; + delivery?: GptDiagnosticsDelivery | undefined; } export interface GptDiagnosticsSlotExport { @@ -285,7 +273,7 @@ export interface GptDiagnosticsExportV1 { >; readonly metadata: Readonly<{ droppedCallbacks: number; - droppedAttributionIssues?: number; + droppedAttributionIssues: number; evictedSlots: number; evictedRequestCycles: number; }>; @@ -312,7 +300,8 @@ export interface GptDiagnosticsRecorder { slot: GptDiagnosticsSlotHandle, auctionSlotId: string, opportunity: GptDiagnosticsTrustedServerOpportunity, - trustedServerAuctionId?: string + trustedServerAuctionId?: string, + requestedSlotSizes?: ReadonlyArray ): void; recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void; recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined; diff --git a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts index a2d2002a0..d04ff1728 100644 --- a/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts +++ b/crates/trusted-server-js/lib/src/first_display/adapters/googletag.ts @@ -172,8 +172,10 @@ interface FirstDisplayDiagnosticCycleRecord { interface ActiveCycle { readonly bindingState: { current: boolean }; readonly diagnosticRecords: FirstDisplayDiagnosticCycleRecord[]; - readonly elementId: string; + elementId: string; + readonly initialLoadDisabled: boolean; readonly operations: readonly ('display' | 'refresh')[]; + ownership: 'publisher' | 'trusted_server'; readonly publicCycle: FirstDisplayGptBoundCycleV1; readonly requestOperation: 0 | 1; readonly runtimeSlotNumber: number; @@ -183,6 +185,8 @@ interface ActiveCycle { requested: boolean; requestTimer?: unknown; settled: boolean; + suppressPublisherDisplay: boolean; + suppressPublisherRefresh: boolean; unknownPriorCycle: boolean; } @@ -370,6 +374,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { private diagnosticFactDrops = 0; private nextTraceTokenOrdinal = 1; private readonly targetingWrites: TargetingWrite[] = []; + private trustedServerCall: readonly [receiver: ExternalObject, key: string] | undefined; public constructor(private readonly options: FirstDisplayGoogletagBatchOptions) {} @@ -499,7 +504,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { Object.freeze([ cycle.publicCycle[6], cycle.elementId, - cycle.publicCycle[3], + cycle.ownership, Object.freeze(this.sealedTargetingOwnership.get(cycle.publicCycle[4]) ?? []), cycle.publicCycle[7], cycle.publicCycle[4], @@ -668,7 +673,11 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { const slot = publisherSlot ?? physicalSlot( - call(binding, 'defineSlot', [placement.gamUnitPath, placement.formats, element.id]) + this.callTrustedServer(binding, 'defineSlot', [ + placement.gamUnitPath, + placement.formats, + element.id, + ]) ); if (!slot) { callbacks[1](placement.slot, SLOT_UNRESOLVED); @@ -710,7 +719,9 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { bindingState, diagnosticRecords: [], elementId: element.id, + initialLoadDisabled: disabled, operations: plan.operations, + ownership, publicCycle, requestOperation: plan.requestOperation, runtimeSlotNumber: traceTokenOrdinal, @@ -718,6 +729,8 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { requestInvoked: false, requested: false, settled: false, + suppressPublisherDisplay: false, + suppressPublisherRefresh: false, unknownPriorCycle: ownership === 'publisher', }; this.cycleMap.set(slot, cycle); @@ -728,28 +741,65 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } + const armRequest = (candidate: ActiveCycle): void => { + if (candidate.requestInvoked) return; + candidate.requestInvoked = true; + candidate.requestTimer = this.timer( + () => this.failCycle(candidate, callbacks, 'gpt_request_timeout'), + this.options.protocol.deadlines.requestStartMs + ); + candidate.completionTimer = this.timer( + () => this.failCycle(candidate, callbacks, 'gpt_completion_timeout'), + this.options.protocol.deadlines.completionMs + ); + }; + const synchronousSraCycles = [...this.cycleMap.values()].filter( + (candidate) => + !candidate.settled && + candidate.requestOperation === 0 && + candidate.operations[0] === 'display' + ); for (const cycle of this.cycleMap.values()) { if (this.disposed || cycle.settled) continue; try { for (let index = 0; index < cycle.operations.length; index += 1) { const operation = cycle.operations[index]; if (index === cycle.requestOperation) { - cycle.requestInvoked = true; - cycle.requestTimer = this.timer( - () => this.failCycle(cycle, callbacks, 'gpt_request_timeout'), - this.options.protocol.deadlines.requestStartMs - ); - cycle.completionTimer = this.timer( - () => this.failCycle(cycle, callbacks, 'gpt_completion_timeout'), - this.options.protocol.deadlines.completionMs - ); + if (operation === 'display' && cycle.requestOperation === 0) { + for (const candidate of synchronousSraCycles) armRequest(candidate); + } else { + armRequest(cycle); + } if (!this.firstAction) { this.firstAction = true; - if (!callbacks[2]()) throw new TypeError('tsjs'); + if (!callbacks[2]()) { + for (const active of this.cycleMap.values()) { + this.failCycle(active, callbacks, GPT_REQUEST_FAILED); + } + return; + } + if (member(binding, 'pubadsReady') !== true) { + try { + call(service, 'enableSingleRequest', []); + call(binding, 'enableServices', []); + } catch { + for (const active of this.cycleMap.values()) { + this.failCycle(active, callbacks, GPT_REQUEST_FAILED); + } + return; + } + } } + if (cycle.settled) break; + } + if (operation === 'display') { + this.callTrustedServer(binding, 'display', [cycle.elementId]); + } else { + this.callTrustedServer(service, 'refresh', [ + [cycle.publicCycle[4]], + { changeCorrelator: false }, + ]); } - if (operation === 'display') call(binding, 'display', [cycle.elementId]); - else call(service, 'refresh', [[cycle.publicCycle[4]], { changeCorrelator: false }]); } } catch { if (cycle.settled) continue; @@ -947,6 +997,147 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { }); } + private replacementSizesEqual( + candidate: unknown, + expected: readonly (readonly [number, number])[] + ): boolean { + if (!Array.isArray(candidate)) return false; + const values = + candidate.length === 2 && candidate.every((value) => typeof value === 'number') + ? [candidate] + : candidate; + if (values.length !== expected.length) return false; + return values.every( + (value, index) => + Array.isArray(value) && + value.length === 2 && + value[0] === expected[index]?.[0] && + value[1] === expected[index]?.[1] + ); + } + + private claimPublisherSlot(arguments_: readonly unknown[]): ActiveCycle | undefined { + if (arguments_.length !== 3 || typeof arguments_[2] !== 'string') return undefined; + const elementId = arguments_[2]; + const exact: ActiveCycle[] = []; + const hydration: ActiveCycle[] = []; + for (const cycle of this.cycleMap.values()) { + if (cycle.ownership !== 'trusted_server' || !cycle.bindingState.current) continue; + if (cycle.elementId === elementId) { + exact.push(cycle); + continue; + } + const placement = cycle.publicCycle[5]; + const oldElement = this.options.document.getElementById(cycle.elementId); + const replacement = this.options.document.getElementById(elementId); + if ( + elementId.startsWith(placement.divId) && + (!oldElement || !oldElement.isConnected) && + replacement?.isConnected === true && + arguments_[0] === placement.gamUnitPath && + this.replacementSizesEqual(arguments_[1], placement.formats) + ) { + hydration.push(cycle); + } + } + const matches = exact.length > 0 ? exact : hydration; + if (matches.length !== 1) return undefined; + const cycle = matches[0]; + if (!cycle) return undefined; + const slot = cycle.publicCycle[4]; + try { + this.observePublisherTargeting(slot); + } catch { + return undefined; + } + for (const [key, installed] of targetingEntries(cycle.publicCycle[0], cycle.publicCycle[5])) { + this.targetingRestorers.push({ installed, key, prior: Object.freeze([]), slot, valid: true }); + } + if (exact.length === 1) { + const placement = cycle.publicCycle[5]; + const formatsMismatch = !this.replacementSizesEqual(arguments_[1], placement.formats); + const pathMismatch = arguments_[0] !== placement.gamUnitPath; + if (formatsMismatch || pathMismatch) { + try { + this.options.document.defaultView?.console.warn( + 'GPT publisher handoff metadata mismatch', + { + formatsMismatch, + pathMismatch, + } + ); + } catch { + // A bounded local diagnostic cannot block exact publisher ownership transfer. + } + } + } + cycle.ownership = 'publisher'; + cycle.elementId = elementId; + cycle.suppressPublisherDisplay = true; + cycle.suppressPublisherRefresh = this.binding + ? initialLoadDisabled(this.binding) + : cycle.initialLoadDisabled; + this.createdSlots.delete(slot); + return cycle; + } + + private publisherCycleForTarget(target: unknown): ActiveCycle | undefined { + const slot = physicalSlot(target); + if (slot) return this.cycleMap.get(slot); + if (typeof target !== 'string') return undefined; + const matches = [...this.cycleMap.values()].filter((cycle) => cycle.elementId === target); + return matches.length === 1 ? matches[0] : undefined; + } + + private mediatePublisherRequest( + key: string, + arguments_: readonly unknown[] + ): + | Readonly<{ action: 'forward'; arguments: readonly unknown[] }> + | Readonly<{ action: 'suppress' }> { + if (key === 'display' && arguments_.length === 1) { + const cycle = this.publisherCycleForTarget(arguments_[0]); + if (cycle?.suppressPublisherDisplay) { + cycle.suppressPublisherDisplay = false; + return Object.freeze({ action: 'suppress' }); + } + } + if (key !== 'refresh' || arguments_.length > 2) { + return Object.freeze({ action: 'forward', arguments: arguments_ }); + } + let requested: readonly unknown[]; + if (arguments_[0] === undefined) { + const all = this.service ? call(this.service, 'getSlots', []) : undefined; + if (!Array.isArray(all)) return Object.freeze({ action: 'forward', arguments: arguments_ }); + requested = all; + } else if (Array.isArray(arguments_[0])) { + requested = arguments_[0]; + } else { + return Object.freeze({ action: 'forward', arguments: arguments_ }); + } + const forwarded: object[] = []; + let suppressed = false; + for (const candidate of requested) { + const slot = physicalSlot(candidate); + const cycle = slot ? this.cycleMap.get(slot) : undefined; + if (cycle?.suppressPublisherRefresh) { + cycle.suppressPublisherRefresh = false; + suppressed = true; + } else if (slot) { + forwarded.push(slot); + } + } + if (!suppressed) return Object.freeze({ action: 'forward', arguments: arguments_ }); + if (forwarded.length === 0) return Object.freeze({ action: 'suppress' }); + return Object.freeze({ + action: 'forward', + arguments: Object.freeze([ + Object.freeze(forwarded), + ...(arguments_.length === 2 ? [arguments_[1]] : []), + ]), + }); + } + private observePublisherCalls( binding: ExternalObject, service: ExternalObject, @@ -972,11 +1163,41 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { destroyed: readonly ActiveCycle[] ): void => this.invalidateCyclesForPublisherCall(key, arguments_, result, destroyed, callbacks); + const invalidateRequest = (arguments_: readonly unknown[]): void => + this.invalidateCyclesForPublisherRequest(key, arguments_, callbacks); + const claimPublisherSlot = (arguments_: readonly unknown[]): ActiveCycle | undefined => + this.claimPublisherSlot(arguments_); + const mediatePublisherRequest = (arguments_: readonly unknown[]) => + this.mediatePublisherRequest(key, arguments_); + const trustedServer = (): boolean => { + const current = this.trustedServerCall; + if (!current || current[0] !== receiver || current[1] !== key) return false; + this.trustedServerCall = undefined; + return true; + }; const wrapper = function (this: unknown, ...arguments_: unknown[]): unknown { + const trusted = this === receiver && trustedServer(); const destroyed = this === receiver ? snapshotDestroy(arguments_) : Object.freeze([]); - const result = Reflect.apply(original, this, arguments_); - if (this === receiver) { - invalidate(arguments_, result, destroyed); + let forwardedArguments: readonly unknown[] = arguments_; + if (this === receiver && !trusted) { + if (key === 'defineSlot') { + const claimed = claimPublisherSlot(arguments_); + if (claimed) { + notify(); + return claimed.publicCycle[4]; + } + } + const mediation = mediatePublisherRequest(arguments_); + if (mediation.action === 'suppress') { + notify(); + return undefined; + } + forwardedArguments = mediation.arguments; + invalidateRequest(forwardedArguments); + } + const result = Reflect.apply(original, this, forwardedArguments); + if (this === receiver && !trusted) { + invalidate(forwardedArguments, result, destroyed); notify(); } return result; @@ -1081,6 +1302,58 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { } } + private invalidateCyclesForPublisherRequest( + key: string, + arguments_: readonly unknown[], + callbacks: FirstDisplayGoogletagBatchCallbacks + ): void { + try { + const cycles: ActiveCycle[] = []; + if (key === 'display') { + const requested = arguments_[0]; + const slot = physicalSlot(requested); + const exactCycle = slot ? this.cycleMap.get(slot) : undefined; + if (exactCycle) cycles.push(exactCycle); + else if (typeof requested === 'string') { + for (const cycle of this.cycleMap.values()) { + if (cycle.elementId === requested) cycles.push(cycle); + } + } + } else if (key === 'refresh') { + const requested = arguments_[0]; + if (requested === undefined) cycles.push(...this.cycleMap.values()); + else if (Array.isArray(requested)) { + for (const candidate of requested) { + const slot = physicalSlot(candidate); + const cycle = slot ? this.cycleMap.get(slot) : undefined; + if (cycle && !cycles.includes(cycle)) cycles.push(cycle); + } + } + } + for (const cycle of cycles) { + if (cycle.settled) this.retireCycle(cycle, callbacks); + else this.failCycle(cycle, callbacks, 'cycle_unattributable'); + } + } catch { + // Publisher calls remain pass-through even if competition observation fails. + } + } + + private callTrustedServer( + receiver: ExternalObject, + key: string, + arguments_: readonly unknown[] + ): unknown { + if (this.trustedServerCall) throw new TypeError('tsjs'); + const marker = Object.freeze([receiver, key] as const); + this.trustedServerCall = marker; + try { + return call(receiver, key, arguments_); + } finally { + if (this.trustedServerCall === marker) this.trustedServerCall = undefined; + } + } + private restorePublisherCalls(): void { for (const restore of this.publisherCallRestorers.reverse()) { try { @@ -1231,6 +1504,14 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { return typeof value === 'boolean' ? value : null; }; const visibility = member(event, 'inViewPercentage'); + const requestedSlotSizes = + eventType === SLOT_REQUESTED && disposition === 'matched' && !cycle.settled + ? Object.freeze( + cycle.publicCycle[5].formats + .slice(0, 16) + .map((size) => Object.freeze([size[0], size[1]] as const)) + ) + : null; const fact: Readonly = Object.freeze({ version: 1, event: eventType, @@ -1242,6 +1523,7 @@ class FirstDisplayGoogletagBatchOwner implements FirstDisplayGoogletagBatch { capturedAtMs: observedAtMs, elementId: cycle.elementId, adUnitPath: cycle.publicCycle[5].gamUnitPath, + requestedSlotSizes, isEmpty: eventType === SLOT_RENDER_ENDED ? optionalBoolean('isEmpty') : null, renderedSize, isBackfill: eventType === SLOT_RENDER_ENDED ? optionalBoolean('isBackfill') : null, diff --git a/crates/trusted-server-js/lib/src/first_display/agent.ts b/crates/trusted-server-js/lib/src/first_display/agent.ts index 02c782b5c..ecfc30060 100644 --- a/crates/trusted-server-js/lib/src/first_display/agent.ts +++ b/crates/trusted-server-js/lib/src/first_display/agent.ts @@ -669,11 +669,15 @@ class FirstDisplayAgentOwner implements FirstDisplayAgent { this.disposeNativeMutationIngress(); try { this.productionBatch?.[5](); + } catch { + // Every owned subsystem must still receive its independent disposal attempt. + } + try { this.options.production?.renderer?.[9](); - this.bound.clear(); } catch { // Disposal is generation-latched; physical cleanup failure cannot restore authority. } + this.bound.clear(); } private installNativeMutationIngress(): void { @@ -841,13 +845,12 @@ export function prepareFirstDisplayBase( throw new TypeError('tsjs'); } const installed = install(transported[0], own, transported[1]); - if (protocolId && SLICE_PROTOCOLS.includes(protocolId)) { - if (!fullProtocolIdentity(installed, protocolId)) { - throw new TypeError('tsjs'); - } - if (AUCTION_PROTOCOLS.includes(protocolId as FirstDisplayAuctionProtocolId)) { - if (!protocolIdentity(installed, protocolId)) throw new TypeError('tsjs'); - } + if ( + protocolId && + SLICE_PROTOCOLS.includes(protocolId) && + !protocolIdentity(installed, protocolId) + ) { + throw new TypeError('tsjs'); } }, }); diff --git a/crates/trusted-server-js/lib/src/first_display/driver.ts b/crates/trusted-server-js/lib/src/first_display/driver.ts index c1e654074..231e34893 100644 --- a/crates/trusted-server-js/lib/src/first_display/driver.ts +++ b/crates/trusted-server-js/lib/src/first_display/driver.ts @@ -274,14 +274,31 @@ export function createFirstDisplayProjectedDriver( ); const cycles = gptCycles.flatMap((captured) => { const cycle = bound.get(captured[0]); - return cycle && - acceptedIds.has(captured[0]) && - cycle[1].id === captured[1] && - cycle[3] === captured[2] && - cycle[7] === captured[4] && - cycle[4] === captured[5] - ? [Object.freeze([...cycle, captured[3]] as const)] - : []; + if ( + !cycle || + !acceptedIds.has(captured[0]) || + cycle[7] !== captured[4] || + cycle[4] !== captured[5] + ) { + return []; + } + const element = + cycle[1].id === captured[1] ? cycle[1] : options.gptInput[2].getElementById(captured[1]); + const ElementConstructor = options.gptInput[2].defaultView?.HTMLElement; + if (!ElementConstructor || !(element instanceof ElementConstructor)) return []; + return [ + Object.freeze([ + cycle[0], + element, + cycle[2], + captured[2], + cycle[4], + cycle[5], + cycle[6], + cycle[7], + captured[3], + ] as const), + ]; }); const diagnosticCycles = gptDiagnostics[0].filter((cycle) => acceptedIds.has(cycle[0])); if ( diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/browser_route_owner.ts b/crates/trusted-server-js/lib/src/first_display/leaf/browser_route_owner.ts index c542417c0..d79c6c2ed 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/browser_route_owner.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/browser_route_owner.ts @@ -5,29 +5,73 @@ export interface FirstDisplayBrowserRouteRuleV1 { readonly rewrite: (url: string) => string; } -function route(rule: FirstDisplayBrowserRouteRuleV1, kind: BrowserRouteKind, url: string): string { +export interface FirstDisplayBrowserRouteOwnerV1 { + readonly register: (rule: FirstDisplayBrowserRouteRuleV1, network?: boolean) => () => void; + readonly dispose: () => void; +} + +interface RouteRegistration { + readonly rule: FirstDisplayBrowserRouteRuleV1; + readonly network: boolean; +} + +function restoreOwnedProperty( + owner: object, + key: PropertyKey, + installed: unknown, + original: unknown +): void { try { - return rule.matches(kind, url) ? rule.rewrite(url) : url; + if (Reflect.get(owner, key) === installed) Reflect.set(owner, key, original); } catch { - return url; + // Browser-owned or publisher-hardened surfaces may reject restoration. + // Every other surface must still receive its independent best-effort restore. } } -/** Own one compact parser-time route interceptor until takeover or rollback. */ -export function registerFirstDisplayBrowserRoute( - rule: FirstDisplayBrowserRouteRuleV1, - network = false -): () => void { - const prototype = Element.prototype; +function route( + registrations: readonly RouteRegistration[], + kind: BrowserRouteKind, + url: string +): string { + let current = url; + for (const registration of registrations) { + if ((kind === 'beacon' || kind === 'fetch') && !registration.network) continue; + try { + if (registration.rule.matches(kind, current)) current = registration.rule.rewrite(current); + } catch { + // A malformed integration rule cannot suppress the remaining route owners. + } + } + return current; +} + +/** Create one document-scoped parser-time route owner shared by every selected slice. */ +export function createFirstDisplayBrowserRouteOwner( + routeDocument: Document, + browser: Window +): FirstDisplayBrowserRouteOwnerV1 { + const registrations: RouteRegistration[] = []; + const ElementConstructor = routeDocument.defaultView?.Element ?? Element; + const prototype = ElementConstructor.prototype; const appendChild = prototype.appendChild; const insertBefore = prototype.insertBefore; + const append = prototype.append; + const prepend = prototype.prepend; + const replaceChildren = prototype.replaceChildren; + const navigator = browser.navigator; + const sendBeacon = navigator.sendBeacon; + const fetch = browser.fetch; + let disposed = false; + let networkInstalled = false; + const rewriteNode = (node: Node): void => { if (node.nodeType !== Node.ELEMENT_NODE) return; const element = node as Element; if (element.tagName === 'SCRIPT') { const script = element as HTMLScriptElement; const source = script.getAttribute('src') ?? script.src; - const rewritten = source && route(rule, 'script', source); + const rewritten = source && route(registrations, 'script', source); if (rewritten && rewritten !== source) script.src = rewritten; return; } @@ -36,14 +80,29 @@ export function registerFirstDisplayBrowserRoute( const rel = link.getAttribute('rel'); if ((rel !== 'preload' && rel !== 'prefetch') || link.getAttribute('as') !== 'script') return; const source = link.getAttribute('href') ?? link.href; - const rewritten = source && route(rule, rel, source); + const rewritten = source && route(registrations, rel, source); if (rewritten && rewritten !== source) link.href = rewritten; }; + const rewriteTree = (node: Node): void => { + rewriteNode(node); + if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) + return; + for (const nested of (node as Element | DocumentFragment).querySelectorAll( + 'script[src],link[href]' + )) { + rewriteNode(nested); + } + }; + const rewriteVariadic = (nodes: readonly (Node | string)[]): void => { + for (const node of nodes) { + if (typeof node !== 'string') rewriteTree(node); + } + }; const appendWrapper: typeof prototype.appendChild = function ( this: Element, node: T ): T { - rewriteNode(node); + rewriteTree(node); return Reflect.apply(appendChild, this, [node]) as T; }; const insertWrapper: typeof prototype.insertBefore = function ( @@ -51,62 +110,185 @@ export function registerFirstDisplayBrowserRoute( node: T, child: Node | null ): T { - rewriteNode(node); + rewriteTree(node); return Reflect.apply(insertBefore, this, [node, child]) as T; }; - prototype.appendChild = appendWrapper; - prototype.insertBefore = insertWrapper; + const appendVariadicWrapper: typeof prototype.append = function ( + this: Element, + ...nodes: (Node | string)[] + ): void { + rewriteVariadic(nodes); + Reflect.apply(append, this, nodes); + }; + const prependWrapper: typeof prototype.prepend = function ( + this: Element, + ...nodes: (Node | string)[] + ): void { + rewriteVariadic(nodes); + Reflect.apply(prepend, this, nodes); + }; + const replaceChildrenWrapper: typeof prototype.replaceChildren = function ( + this: Element, + ...nodes: (Node | string)[] + ): void { + rewriteVariadic(nodes); + Reflect.apply(replaceChildren, this, nodes); + }; + const restoreDom = (): void => { + restoreOwnedProperty(prototype, 'appendChild', appendWrapper, appendChild); + restoreOwnedProperty(prototype, 'insertBefore', insertWrapper, insertBefore); + restoreOwnedProperty(prototype, 'append', appendVariadicWrapper, append); + restoreOwnedProperty(prototype, 'prepend', prependWrapper, prepend); + restoreOwnedProperty(prototype, 'replaceChildren', replaceChildrenWrapper, replaceChildren); + }; - const Observer = document.defaultView?.MutationObserver; - const observer = Observer - ? new Observer((records) => { - for (const record of records) { - for (const node of record.addedNodes) { - rewriteNode(node); - if (node.nodeType === Node.ELEMENT_NODE) { - for (const nested of (node as Element).querySelectorAll('script[src],link[href]')) { - rewriteNode(nested); - } - } + const Observer = routeDocument.defaultView?.MutationObserver; + let observer: MutationObserver | undefined; + try { + prototype.appendChild = appendWrapper; + prototype.insertBefore = insertWrapper; + prototype.append = appendVariadicWrapper; + prototype.prepend = prependWrapper; + prototype.replaceChildren = replaceChildrenWrapper; + observer = Observer + ? new Observer((records) => { + for (const record of records) { + for (const node of record.addedNodes) rewriteTree(node); } - } - }) - : undefined; - observer?.observe(document.documentElement, { childList: true, subtree: true }); + }) + : undefined; + observer?.observe(routeDocument.documentElement, { childList: true, subtree: true }); + } catch (error) { + try { + observer?.disconnect(); + } catch { + // Continue restoring every installed insertion surface. + } + restoreDom(); + throw error; + } - const navigator = window.navigator; - const sendBeacon = navigator.sendBeacon; - const fetch = window.fetch; const beaconWrapper = function (url: string | URL, data?: BodyInit | null): boolean { - return Reflect.apply(sendBeacon, navigator, [route(rule, 'beacon', String(url)), data]); + return Reflect.apply(sendBeacon, navigator, [ + route(registrations, 'beacon', String(url)), + data, + ]); }; const fetchWrapper = function (input: RequestInfo | URL, init?: RequestInit): Promise { let source: string | undefined; if (typeof input === 'string' || input instanceof URL) source = String(input); else if (typeof Request !== 'undefined' && input instanceof Request) source = input.url; - const rewritten = source && route(rule, 'fetch', source); - const request = - rewritten && - rewritten !== source && - typeof Request !== 'undefined' && - input instanceof Request - ? new Request(rewritten, input) - : (rewritten ?? input); - return Reflect.apply(fetch, window, [request, init]); + const rewritten = source && route(registrations, 'fetch', source); + let request = input; + if (rewritten && rewritten !== source) { + if (typeof Request !== 'undefined' && input instanceof Request) { + const requestInit: RequestInit & { duplex?: 'half' } = { + cache: input.cache, + credentials: input.credentials, + headers: input.headers, + integrity: input.integrity, + keepalive: input.keepalive, + method: input.method, + mode: input.mode, + redirect: input.redirect, + referrer: input.referrer, + referrerPolicy: input.referrerPolicy, + signal: input.signal, + }; + if (input.body !== null && input.method !== 'GET' && input.method !== 'HEAD') { + requestInit.body = input.body as BodyInit; + requestInit.duplex = 'half'; + } + request = new Request(rewritten, requestInit); + } else { + request = rewritten; + } + } + return Reflect.apply(fetch, browser, [request, init]); }; - if (network) { - if (typeof sendBeacon === 'function') navigator.sendBeacon = beaconWrapper; - if (typeof fetch === 'function') window.fetch = fetchWrapper; - } + const restoreNetwork = (): void => { + if (!networkInstalled) return; + networkInstalled = false; + restoreOwnedProperty(navigator, 'sendBeacon', beaconWrapper, sendBeacon); + restoreOwnedProperty(browser, 'fetch', fetchWrapper, fetch); + }; + const installNetwork = (): void => { + if (networkInstalled) return; + try { + if (typeof sendBeacon === 'function') navigator.sendBeacon = beaconWrapper; + if (typeof fetch === 'function') browser.fetch = fetchWrapper; + networkInstalled = true; + } catch (error) { + restoreOwnedProperty(navigator, 'sendBeacon', beaconWrapper, sendBeacon); + restoreOwnedProperty(browser, 'fetch', fetchWrapper, fetch); + throw error; + } + }; + const dispose = (): void => { + if (disposed) return; + disposed = true; + registrations.length = 0; + try { + observer?.disconnect(); + } catch { + // Generation latching makes an unremovable observer inert. + } + restoreDom(); + restoreNetwork(); + }; + + return Object.freeze({ + register: (rule: FirstDisplayBrowserRouteRuleV1, network = false): (() => void) => { + if ( + disposed || + !rule || + typeof rule.matches !== 'function' || + typeof rule.rewrite !== 'function' + ) { + throw new TypeError('tsjs'); + } + const registration = Object.freeze({ rule, network }); + registrations.push(registration); + try { + if (network) installNetwork(); + } catch (error) { + registrations.pop(); + if (registrations.length === 0) dispose(); + throw error; + } + let active = true; + return (): void => { + if (!active) return; + active = false; + const index = registrations.indexOf(registration); + if (index >= 0) registrations.splice(index, 1); + if (!registrations.some((entry) => entry.network)) restoreNetwork(); + if (registrations.length === 0) dispose(); + }; + }, + dispose, + }); +} + +let defaultOwner: FirstDisplayBrowserRouteOwnerV1 | undefined; +let defaultRegistrations = 0; + +/** Register through the document singleton used by direct unit consumers. */ +export function registerFirstDisplayBrowserRoute( + rule: FirstDisplayBrowserRouteRuleV1, + network = false +): () => void { + if (!defaultOwner) defaultOwner = createFirstDisplayBrowserRouteOwner(document, window); + const owner = defaultOwner; + const release = owner.register(rule, network); + defaultRegistrations += 1; let active = true; return (): void => { if (!active) return; active = false; - observer?.disconnect(); - if (prototype.appendChild === appendWrapper) prototype.appendChild = appendChild; - if (prototype.insertBefore === insertWrapper) prototype.insertBefore = insertBefore; - if (network && navigator.sendBeacon === beaconWrapper) navigator.sendBeacon = sendBeacon; - if (network && window.fetch === fetchWrapper) window.fetch = fetch; + release(); + defaultRegistrations -= 1; + if (defaultRegistrations === 0 && defaultOwner === owner) defaultOwner = undefined; }; } diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/context_snapshot.ts b/crates/trusted-server-js/lib/src/first_display/leaf/context_snapshot.ts index 2d4765c91..73b3317f9 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/context_snapshot.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/context_snapshot.ts @@ -8,8 +8,6 @@ export interface FirstDisplayContextRouteRuleV1 { readonly rewrite: (url: string) => string; } -type ContextContributor = () => Readonly> | undefined; - const CONFIG_FIELDS = [ 'apiHost', 'apiProtocol', @@ -20,6 +18,8 @@ const CONFIG_FIELDS = [ ] as const; const SCRIPT_KINDS = new Set(['script', 'preload', 'prefetch']); const MAX_SEGMENTS = 100; +const MAX_SEGMENT_LENGTH = 256; +const MAX_SERIALIZED_SEGMENTS_LENGTH = 4_096; type ConfigField = (typeof CONFIG_FIELDS)[number]; type PermutiveConfig = Record; @@ -36,7 +36,6 @@ interface PermutiveInitialBindings { readonly origin: string; readonly protocol: 'http:' | 'https:'; readonly readStorage: (key: string) => string | null; - readonly registerContext: (contributor: ContextContributor) => () => void; readonly registerRoute: (rule: FirstDisplayContextRouteRuleV1) => () => void; readonly setTimer: (callback: () => void, delayMs: number) => unknown; } @@ -80,7 +79,6 @@ function snapshotBindings(candidate: unknown): PermutiveInitialBindings | undefi 'origin', 'protocol', 'readStorage', - 'registerContext', 'registerRoute', 'setTimer', ]); @@ -96,7 +94,6 @@ function snapshotBindings(candidate: unknown): PermutiveInitialBindings | undefi typeof fields.origin !== 'string' || !['http:', 'https:'].includes(fields.protocol as string) || typeof fields.readStorage !== 'function' || - typeof fields.registerContext !== 'function' || typeof fields.registerRoute !== 'function' || typeof fields.setTimer !== 'function' ) { @@ -117,7 +114,12 @@ function normalizedSegments(candidate: unknown): readonly string[] { const values: string[] = []; for (let index = 0; index < candidate.length && values.length < MAX_SEGMENTS; index += 1) { const value = candidate[index]; - if (typeof value === 'string' || typeof value === 'number') values.push(String(value)); + if (typeof value !== 'string' && typeof value !== 'number') continue; + const normalized = String(value); + if (normalized.length === 0 || normalized.length > MAX_SEGMENT_LENGTH) continue; + const candidateValues = [...values, normalized]; + if (JSON.stringify(candidateValues).length > MAX_SERIALIZED_SEGMENTS_LENGTH) break; + values.push(normalized); } return Object.freeze(values); } @@ -188,19 +190,15 @@ export function installPermutiveInitial( if (typeof releaseRoute !== 'function') throw new TypeError('invalid Permutive route disposer'); own(releaseRoute); - const releaseContext = bindings.registerContext(() => { - let segments: readonly string[]; - try { - segments = snapshotPermutiveInitialSegments(bindings.readStorage('permutive-app')); - } catch { - return undefined; - } - return segments.length === 0 ? undefined : Object.freeze({ permutive_segments: segments }); - }); - if (typeof releaseContext !== 'function') { - throw new TypeError('invalid Permutive context disposer'); + let initialSegments: readonly string[] = Object.freeze([]); + try { + initialSegments = snapshotPermutiveInitialSegments(bindings.readStorage('permutive-app')); + } catch { + // Hostile storage is equivalent to no initial Permutive context. + } + if (initialSegments.length > 0) { + bindings.observe('segments', JSON.stringify(initialSegments)); } - own(releaseContext); let active = true; let timer: unknown; diff --git a/crates/trusted-server-js/lib/src/first_display/leaf/creative_guard.ts b/crates/trusted-server-js/lib/src/first_display/leaf/creative_guard.ts index f636fce68..9dbd38bf6 100644 --- a/crates/trusted-server-js/lib/src/first_display/leaf/creative_guard.ts +++ b/crates/trusted-server-js/lib/src/first_display/leaf/creative_guard.ts @@ -1,18 +1,15 @@ +import type { CreativeBootV1 } from '../../core/types'; +import { + createCreativeStartup, + type CreativeStartupOptions, +} from '../../integrations/creative/startup'; import type { FirstDisplaySliceActivationContext } from '../../shared/first_display_transaction'; -export interface FirstDisplayCreativeGuardV1 { - readonly version: 1; - readonly id: 'creative'; - readonly clickGuard: boolean; - readonly renderGuard: boolean; - readonly normalizeNavigation: (raw: string) => string | undefined; - readonly shouldProxyResource: (raw: string) => boolean; -} - -interface CreativeInitialBindings { - readonly location: Readonly<{ href: string; origin: string }>; +interface CreativeInitialBindings extends Pick< + CreativeStartupOptions, + 'document' | 'installClickGuard' | 'installDynamicIframeProxy' | 'installDynamicImageProxy' +> { readonly observe: (name: 'guard_count', value: number) => void; - readonly register: (guard: FirstDisplayCreativeGuardV1) => () => void; } function exactRecord(value: unknown, keys: readonly string[]): Record | undefined { @@ -45,12 +42,7 @@ function exactRecord(value: unknown, keys: readonly string[]): Record - | undefined { +function snapshotConfig(candidate: unknown): Readonly | undefined { const config = exactRecord(candidate, ['version', 'enabled', 'clickGuard', 'renderGuard']); if ( !config || @@ -62,60 +54,30 @@ function snapshotConfig(candidate: unknown): ) { return undefined; } - return Object.freeze({ clickGuard: config.clickGuard, renderGuard: config.renderGuard }); -} - -function normalizeNavigation(raw: string, base: string): string | undefined { - try { - const url = new URL(String(raw), base); - if ( - !['http:', 'https:'].includes(url.protocol) || - url.username.length > 0 || - url.password.length > 0 - ) { - return undefined; - } - return url.toString(); - } catch { - return undefined; - } -} - -function shouldProxyResource(raw: string, href: string, origin: string): boolean { - const value = String(raw || '').trim(); - if (!value || /^(data:|javascript:|blob:|about:)/i.test(value)) return false; - if (value.startsWith('/first-party/proxy')) return false; - try { - const url = new URL(value, href); - return url.origin !== origin && (url.protocol === 'http:' || url.protocol === 'https:'); - } catch { - return false; - } + return Object.freeze({ + version: 1, + enabled: true, + clickGuard: config.clickGuard, + renderGuard: config.renderGuard, + }); } -/** Register the selected parser-time creative policy with one generic provisional guard owner. */ +/** Install the selected parser-time creative guards under the agent rollback owner. */ export function installCreativeInitial( candidate: unknown, own: FirstDisplaySliceActivationContext['own'], configCandidate: unknown ): void { - // The authenticated bootstrap owns bindings; only server-carried config is untrusted here. const bindings = candidate as CreativeInitialBindings; const config = snapshotConfig(configCandidate); - if (!config || typeof own !== 'function') { - throw new TypeError('tsjs'); - } - const guard: FirstDisplayCreativeGuardV1 = Object.freeze({ - version: 1, - id: 'creative', - clickGuard: config.clickGuard, - renderGuard: config.renderGuard, - normalizeNavigation: (raw: string) => normalizeNavigation(raw, bindings.location.href), - shouldProxyResource: (raw: string) => - shouldProxyResource(raw, bindings.location.href, bindings.location.origin), + if (!config || typeof own !== 'function') throw new TypeError('tsjs'); + const startup = createCreativeStartup({ + document: bindings.document, + installClickGuard: bindings.installClickGuard, + installDynamicIframeProxy: bindings.installDynamicIframeProxy, + installDynamicImageProxy: bindings.installDynamicImageProxy, }); - const release = bindings.register(guard); - if (typeof release !== 'function') throw new TypeError('tsjs'); + const release = startup.activate(config); own(release); bindings.observe('guard_count', (config.clickGuard ? 1 : 0) + (config.renderGuard ? 2 : 0)); } diff --git a/crates/trusted-server-js/lib/src/first_display/registration_client.ts b/crates/trusted-server-js/lib/src/first_display/registration_client.ts index a25afa239..05c393ff6 100644 --- a/crates/trusted-server-js/lib/src/first_display/registration_client.ts +++ b/crates/trusted-server-js/lib/src/first_display/registration_client.ts @@ -8,14 +8,11 @@ export function registerCurrentFirstDisplayComponent( install: FirstDisplayComponentRegistrationV1['install'] ): boolean { try { - const browser = (globalThis as unknown as { window: Window }).window; - const target = Object.getOwnPropertyDescriptor(browser, 'tsjs')?.value; - const sink = Object.getOwnPropertyDescriptor(target, '_registerFirstDisplay')?.value; + const target = window.tsjs as unknown as { + _registerFirstDisplay?: (candidate: readonly unknown[]) => boolean; + }; return ( - Reflect.apply(sink, target, [ - Object.freeze([1, id, __TSJS_EMBEDDED_RELEASE_ID_V1__, install]), - browser.document.currentScript, - ]) === true + target._registerFirstDisplay?.([1, id, __TSJS_EMBEDDED_RELEASE_ID_V1__, install]) === true ); } catch { return false; diff --git a/crates/trusted-server-js/lib/src/first_display/slices/creative.ts b/crates/trusted-server-js/lib/src/first_display/slices/creative.ts index a839acdab..bf5bbb01f 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/creative.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/creative.ts @@ -1,4 +1,22 @@ import { installCreativeInitial } from '../leaf/creative_guard'; import { registerCurrentFirstDisplayComponent } from '../registration_client'; +import { installClickGuard } from '../../integrations/creative/click'; +import { installDynamicIframeProxy } from '../../integrations/creative/iframe'; +import { installDynamicImageProxy } from '../../integrations/creative/image'; -registerCurrentFirstDisplayComponent('creative_initial', installCreativeInitial); +import type { InitialSliceInstaller } from './definition'; + +const installCreativeInitialSlice: InitialSliceInstaller = (candidate, own, config) => + installCreativeInitial( + Object.freeze({ + document, + installClickGuard: () => installClickGuard(false), + installDynamicIframeProxy: () => installDynamicIframeProxy(false), + installDynamicImageProxy: () => installDynamicImageProxy(false), + observe: (candidate as Readonly<{ observe: unknown }>).observe, + }), + own, + config + ); + +registerCurrentFirstDisplayComponent('creative_initial', installCreativeInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts b/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts index efb48d75f..37a5cb632 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/datadome.ts @@ -1,17 +1,18 @@ import { installDataDomeInitial } from '../leaf/route_guard'; -import { registerFirstDisplayBrowserRoute } from '../leaf/browser_route_owner'; import { registerCurrentFirstDisplayComponent } from '../registration_client'; import type { InitialSliceInstaller } from './definition'; -export const installDataDomeInitialSlice: InitialSliceInstaller = (candidate, own) => - installDataDomeInitial( +export const installDataDomeInitialSlice: InitialSliceInstaller = (candidate, own) => { + const bindings = candidate as Readonly<{ observe: unknown; register: unknown }>; + return installDataDomeInitial( Object.freeze({ - observe: (candidate as Readonly<{ observe: unknown }>).observe, + observe: bindings.observe, origin: location.origin, - register: registerFirstDisplayBrowserRoute, + register: bindings.register, }), own ); +}; registerCurrentFirstDisplayComponent('datadome_initial', installDataDomeInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts b/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts index 22283a54d..0c1d350be 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/google_tag_manager.ts @@ -1,18 +1,19 @@ -import { installGoogleTagManagerInitial, type FirstDisplayRouteRuleV1 } from '../leaf/route_guard'; -import { registerFirstDisplayBrowserRoute } from '../leaf/browser_route_owner'; +import { installGoogleTagManagerInitial } from '../leaf/route_guard'; import { registerCurrentFirstDisplayComponent } from '../registration_client'; import type { InitialSliceInstaller } from './definition'; -export const installGoogleTagManagerInitialSlice: InitialSliceInstaller = (candidate, own) => - installGoogleTagManagerInitial( +export const installGoogleTagManagerInitialSlice: InitialSliceInstaller = (candidate, own) => { + const bindings = candidate as Readonly<{ observe: unknown; register: unknown }>; + return installGoogleTagManagerInitial( Object.freeze({ - observe: (candidate as Readonly<{ observe: unknown }>).observe, + observe: bindings.observe, origin: location.origin, - register: (rule: FirstDisplayRouteRuleV1) => registerFirstDisplayBrowserRoute(rule, true), + register: bindings.register, }), own ); +}; registerCurrentFirstDisplayComponent( 'google_tag_manager_initial', diff --git a/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts b/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts index ad41dc509..830146419 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/lockr.ts @@ -1,22 +1,23 @@ import { installLockrInitial } from '../leaf/route_guard'; -import { registerFirstDisplayBrowserRoute } from '../leaf/browser_route_owner'; import { registerCurrentFirstDisplayComponent } from '../registration_client'; import type { InitialSliceInstaller } from './definition'; -export const installLockrInitialSlice: InitialSliceInstaller = (candidate, own) => - installLockrInitial( +export const installLockrInitialSlice: InitialSliceInstaller = (candidate, own) => { + const bindings = candidate as Readonly<{ observe: unknown; register: unknown }>; + return installLockrInitial( Object.freeze({ clearTimer: (handle: unknown) => window.clearTimeout(handle as number), getSdk: () => Reflect.get(window, 'identityLockr'), host: location.host, - observe: (candidate as Readonly<{ observe: unknown }>).observe, + observe: bindings.observe, origin: location.origin, protocol: location.protocol, - register: registerFirstDisplayBrowserRoute, + register: bindings.register, setTimer: (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), }), own ); +}; registerCurrentFirstDisplayComponent('lockr_initial', installLockrInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts b/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts index 419db2599..3fa181e05 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/permutive.ts @@ -1,24 +1,24 @@ import { installPermutiveInitial } from '../leaf/context_snapshot'; -import { registerFirstDisplayBrowserRoute } from '../leaf/browser_route_owner'; import { registerCurrentFirstDisplayComponent } from '../registration_client'; import type { InitialSliceInstaller } from './definition'; -export const installPermutiveInitialSlice: InitialSliceInstaller = (candidate, own) => - installPermutiveInitial( +export const installPermutiveInitialSlice: InitialSliceInstaller = (candidate, own) => { + const bindings = candidate as Readonly<{ observe: unknown; register: unknown }>; + return installPermutiveInitial( Object.freeze({ clearTimer: (handle: unknown) => window.clearTimeout(handle as number), getSdk: () => Reflect.get(window, 'permutive'), host: location.host, - observe: (candidate as Readonly<{ observe: unknown }>).observe, + observe: bindings.observe, origin: location.origin, protocol: location.protocol, readStorage: (key: string) => window.localStorage.getItem(key), - registerContext: () => () => undefined, - registerRoute: registerFirstDisplayBrowserRoute, + registerRoute: bindings.register, setTimer: (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), }), own ); +}; registerCurrentFirstDisplayComponent('permutive_initial', installPermutiveInitialSlice); diff --git a/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts b/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts index f12a57abb..6acc7bf63 100644 --- a/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts +++ b/crates/trusted-server-js/lib/src/first_display/slices/sourcepoint.ts @@ -1,20 +1,21 @@ import { installSourcepointInitial } from '../leaf/consent_snapshot'; -import { registerFirstDisplayBrowserRoute } from '../leaf/browser_route_owner'; import { registerCurrentFirstDisplayComponent } from '../registration_client'; import type { InitialSliceInstaller } from './definition'; -export const installSourcepointInitialSlice: InitialSliceInstaller = (candidate, own, config) => - installSourcepointInitial( +export const installSourcepointInitialSlice: InitialSliceInstaller = (candidate, own, config) => { + const bindings = candidate as Readonly<{ observe: unknown; register: unknown }>; + return installSourcepointInitial( Object.freeze({ config, document, - observe: (candidate as Readonly<{ observe: unknown }>).observe, + observe: bindings.observe, origin: location.origin, - registerRoute: registerFirstDisplayBrowserRoute, + registerRoute: bindings.register, storage: window.localStorage, }), own ); +}; registerCurrentFirstDisplayComponent('sourcepoint_initial', installSourcepointInitialSlice); 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 913edd61e..414314c52 100644 --- a/crates/trusted-server-js/lib/src/integrations/aps/render.ts +++ b/crates/trusted-server-js/lib/src/integrations/aps/render.ts @@ -104,7 +104,6 @@ export function prepareApsRenderSource( } } - export interface DirectApsAttemptOptions { readonly attempt: RenderAttempt; readonly bindArtifactGuard: ( 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 316364d1e..8e6306ee6 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts @@ -299,22 +299,22 @@ function resolveSafeNavigationUrl(url: string): string | null { return null; } -// Longest rebuild URL we will navigate to. Fastly Compute rejects request URLs -// over 8192 bytes before the handler runs, and a signed click with many -// tracking parameters can exceed that once nested inside another query string. -// The margin covers percent-encoding growth and the request line's own overhead. +// Fastly rejects request URLs over 8192 bytes before the handler runs. Leave +// room for request-line overhead and percent-encoding expansion. const MAX_REBUILD_URL_LENGTH = 7000; - const REBUILD_PATH = '/first-party/proxy-rebuild'; -// Navigate the rebuild as a form POST rather than a GET URL. -// -// The click is too long to nest in a query string, but a request body has no -// such bound, and a form submission is a navigation — so it is not subject to -// the CORS rules that rule out `fetch` from an opaque origin. The sandbox grants -// `allow-forms`, and the endpoint answers a form-encoded POST with the same 302 -// it gives a GET. Only ever called from a click handler: submitting outside a -// user gesture would navigate the frame on its own. +function isRebuildNavigationUrl(url: string): boolean { + try { + return new URL(url, TRUSTED_BASE_URL).pathname === REBUILD_PATH; + } catch { + return false; + } +} + +// An opaque-origin creative cannot use the JSON fetch path. For a recovery URL +// too long to navigate as GET, submit the same fields in a form body; this stays +// a user-initiated navigation and therefore does not require CORS. function submitRebuildNavigation(anchor: AnchorLike, rebuildUrl: string): boolean { try { if (typeof document === 'undefined' || typeof document.createElement !== 'function') { @@ -325,6 +325,7 @@ function submitRebuildNavigation(anchor: AnchorLike, rebuildUrl: string): boolea form.method = 'POST'; form.action = parsed.origin + parsed.pathname; form.target = anchor.getAttribute('target') || '_self'; + form.rel = 'noopener noreferrer'; form.style.display = 'none'; for (const field of ['tsclick', 'add', 'del']) { const value = parsed.searchParams.get(field); @@ -352,12 +353,10 @@ function submitRebuildNavigation(anchor: AnchorLike, rebuildUrl: string): boolea function navigate(a: AnchorLike, url: string, isMiddle: boolean): void { const resolved = resolveSafeNavigationUrl(url); if (!resolved) return; - // An over-long recovery URL would be rejected by the platform before the - // handler runs, losing the click entirely. Carry it in a form body instead. if ( !isMiddle && resolved.length > MAX_REBUILD_URL_LENGTH && - resolved.includes(REBUILD_PATH) && + isRebuildNavigationUrl(resolved) && submitRebuildNavigation(a, resolved) ) { return; @@ -375,6 +374,15 @@ 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 canonicalClickValue(resolved: string): string { + try { + const url = new URL(resolved, TRUSTED_BASE_URL); + return `${url.pathname}${url.search}`; + } catch { + return resolved; + } +} + function persistRebuiltClick( anchor: AnchorLike, finalUrl: string, 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 65ea5d873..618c1b068 100644 --- a/crates/trusted-server-js/lib/src/integrations/didomi/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/didomi/index.ts @@ -6,6 +6,8 @@ if (typeof window !== 'undefined') { const register = (window.tsjs as unknown as { _registerIntegration?: unknown } | undefined) ?._registerIntegration; if (typeof register === 'function') { - Reflect.apply(register, window.tsjs, [createDidomiIntegrationRegistration(EMBEDDED_RELEASE_ID)]); + Reflect.apply(register, window.tsjs, [ + createDidomiIntegrationRegistration(EMBEDDED_RELEASE_ID), + ]); } } 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 84c8a8d4e..7169628dd 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 @@ -3,10 +3,17 @@ import type { GptTraceCycleOrdinalV1, GoogletagAdapter, GoogletagDiagnosticsFact, - GoogletagDiagnosticsObserver, GoogletagDiagnosticsSlotSnapshot, } from '../../adapters/googletag'; +import { + createTrustedServerOpportunityFact, + isTrustedServerOpportunityFact, + type GptDiagnosticsOpportunityFact, +} from '../../shared/gpt_diagnostics'; import type { FirstDisplayGptDiagnosticsV1, FirstDisplayGptFactV1 } from '../../shared/takeover'; +import type { BrowserAuctionProjectionV1 } from '../../core/types'; + +export { createTrustedServerOpportunityFact, type GptDiagnosticsOpportunityFact }; const MAX_BUFFERED_FACTS = 512; const DIAGNOSTICS_ONLY_EVENTS = Object.freeze([ @@ -28,13 +35,92 @@ export interface GptDiagnosticsFactBufferOptions { readonly onOverflow?: (droppedFacts: number) => void; } +export type GptDiagnosticsFact = GoogletagDiagnosticsFact | GptDiagnosticsOpportunityFact; +export type GptDiagnosticsFactObserver = (fact: Readonly) => void; + +/** Publish immutable request evidence captured by the exact request-producing operation. */ +export function publishTrustedServerOpportunityFact(input: { + readonly adapter: Pick; + readonly buffer: Pick; + readonly physicalSlot: object; + readonly registeredSlotId: string; + readonly opportunity: Readonly<{ + requestedSlotSizes: readonly (readonly [number, number])[]; + trustedServerAuctionId: string; + }>; +}): boolean { + try { + const identity = input.adapter.diagnosticsIdentity(input.physicalSlot); + if (!identity) return false; + const fact = createTrustedServerOpportunityFact({ + auctionSlotId: input.registeredSlotId, + opportunity: 'renderable_candidate', + requestedSlotSizes: input.opportunity.requestedSlotSizes, + slot: identity, + trustedServerAuctionId: input.opportunity.trustedServerAuctionId, + }); + return fact ? input.buffer.publish(fact) : false; + } catch { + return false; + } +} + +/** Publish the exact projected Trusted Server winner immediately before its GPT request. */ +export function publishTrustedServerRequestFact(input: { + readonly adapter: Pick; + readonly buffer: Pick; + readonly physicalSlot: object; + readonly projection: Readonly<{ + auction: Readonly<{ + auctionId: string; + results: readonly Readonly[]; + }>; + bids: readonly Readonly[]; + slots: readonly Readonly[]; + }>; + readonly registeredSlotId: string; +}): boolean { + try { + const placements = input.projection.slots.filter( + (placement) => placement.slot === input.registeredSlotId + ); + const decisions = input.projection.auction.results.filter( + (decision) => decision.slot === input.registeredSlotId + ); + if (placements.length !== 1 || decisions.length !== 1) return false; + const placement = placements[0]; + const decision = decisions[0]; + if (!placement || decision?.outcome !== 'winner') return false; + const bids = input.projection.bids.filter( + (bid) => bid.slot === input.registeredSlotId && bid.candidateId === decision.candidateId + ); + if (bids.length !== 1) return false; + return publishTrustedServerOpportunityFact({ + adapter: input.adapter, + buffer: input.buffer, + physicalSlot: input.physicalSlot, + registeredSlotId: input.registeredSlotId, + opportunity: Object.freeze({ + requestedSlotSizes: placement.formats, + trustedServerAuctionId: input.projection.auction.auctionId, + }), + }); + } catch { + return false; + } +} + export interface GptDiagnosticsFactBuffer { readonly adoptFirstDisplay: ( diagnostics: Readonly, - resolveSlot?: (traceToken: string) => Readonly | undefined + resolveSlot?: (traceToken: string) => Readonly | undefined, + resolveOpportunity?: ( + traceToken: string, + slot: Readonly + ) => Readonly | undefined ) => boolean; - readonly publish: (fact: Readonly) => boolean; - readonly activate: (consumer: GoogletagDiagnosticsObserver) => (() => void) | undefined; + readonly publish: (fact: Readonly) => boolean; + readonly activate: (consumer: GptDiagnosticsFactObserver) => (() => void) | undefined; readonly dispose: () => void; } @@ -80,9 +166,12 @@ export function projectGptTraceFact( } } -function validFact(fact: unknown): fact is Readonly { +function validFact(fact: unknown): fact is Readonly { if (typeof fact !== 'object' || fact === null || !Object.isFrozen(fact)) return false; const kind = Object.getOwnPropertyDescriptor(fact, 'kind'); + if (kind && 'value' in kind && kind.value === 'trustedServerOpportunity') { + return isTrustedServerOpportunityFact(fact); + } const slot = Object.getOwnPropertyDescriptor(fact, 'slot'); return ( ((kind !== undefined && @@ -107,6 +196,7 @@ const FIRST_DISPLAY_FACT_KEYS = Object.freeze([ 'capturedAtMs', 'elementId', 'adUnitPath', + 'requestedSlotSizes', 'isEmpty', 'renderedSize', 'isBackfill', @@ -153,6 +243,7 @@ function validTransferredFact(candidate: unknown): candidate is Readonly 0)) && (fields.adUnitPath === null || (typeof fields.adUnitPath === 'string' && fields.adUnitPath.length > 0)) && + (requestedSlotSizes === null || + (event === 'slotRequested' && + disposition === 'matched' && + Array.isArray(requestedSlotSizes) && + Object.isFrozen(requestedSlotSizes) && + requestedSlotSizes.length >= 1 && + requestedSlotSizes.length <= 16 && + requestedSlotSizes.every( + (size) => + Array.isArray(size) && + Object.isFrozen(size) && + size.length === 2 && + size.every( + (dimension) => Number.isInteger(dimension) && dimension >= 1 && dimension <= 4096 + ) + ))) && (fields.isEmpty === null || typeof fields.isEmpty === 'boolean') && (renderedSize === null || (Array.isArray(renderedSize) && @@ -197,8 +304,12 @@ function validTransferredFact(candidate: unknown): candidate is Readonly, - resolveSlot?: (traceToken: string) => Readonly | undefined -): readonly Readonly[] | undefined { + resolveSlot?: (traceToken: string) => Readonly | undefined, + resolveOpportunity?: ( + traceToken: string, + slot: Readonly + ) => Readonly | undefined +): readonly Readonly[] | undefined { const fields = exactFrozenRecord(diagnostics, ['facts', 'overflowCount', 'dropCount']); if ( !fields || @@ -214,12 +325,19 @@ function rawFirstDisplayFacts( ) { return undefined; } - const slotByTraceToken = new Map>(); - const facts: Array> = []; + const physicalByTraceToken = new Map< + string, + Readonly<{ + runtimeSlotNumber: number; + token: object; + traceToken: GptSlotTokenV1; + }> + >(); + const facts: Array> = []; for (const candidate of fields.facts) { if (!validTransferredFact(candidate)) return undefined; - let slot = slotByTraceToken.get(candidate.token); - if (!slot) { + let physical = physicalByTraceToken.get(candidate.token); + if (!physical) { let resolved: Readonly | undefined; try { resolved = resolveSlot?.(candidate.token); @@ -235,17 +353,34 @@ function rawFirstDisplayFacts( ) { return undefined; } - slot = Object.freeze({ + physical = Object.freeze({ token: resolved?.token ?? Object.freeze(Object.create(null) as object), traceToken: candidate.token as GptSlotTokenV1, runtimeSlotNumber: candidate.runtimeSlotNumber, - ...(candidate.cycleOrdinal === null - ? {} - : { cycleOrdinal: candidate.cycleOrdinal as GptTraceCycleOrdinalV1 }), - ...(candidate.elementId === null ? {} : { elementId: candidate.elementId }), - ...(candidate.adUnitPath === null ? {} : { adUnitPath: candidate.adUnitPath }), }); - slotByTraceToken.set(candidate.token, slot); + physicalByTraceToken.set(candidate.token, physical); + } + if (physical.runtimeSlotNumber !== candidate.runtimeSlotNumber) return undefined; + const slot: Readonly = Object.freeze({ + token: physical.token, + traceToken: physical.traceToken, + runtimeSlotNumber: physical.runtimeSlotNumber, + ...(candidate.cycleOrdinal === null + ? {} + : { cycleOrdinal: candidate.cycleOrdinal as GptTraceCycleOrdinalV1 }), + ...(candidate.elementId === null ? {} : { elementId: candidate.elementId }), + ...(candidate.adUnitPath === null ? {} : { adUnitPath: candidate.adUnitPath }), + }); + if (candidate.requestedSlotSizes !== null) { + if (!resolveOpportunity) return undefined; + let opportunity: Readonly | undefined; + try { + opportunity = resolveOpportunity(candidate.token, slot); + } catch { + opportunity = undefined; + } + if (!opportunity || !validFact(opportunity)) return undefined; + facts.push(opportunity); } facts.push( Object.freeze({ @@ -271,8 +406,8 @@ function rawFirstDisplayFacts( export function createGptDiagnosticsFactBuffer( options: GptDiagnosticsFactBufferOptions = {} ): GptDiagnosticsFactBuffer { - const pending: Readonly[] = []; - let consumer: GoogletagDiagnosticsObserver | undefined; + const pending: Readonly[] = []; + let consumer: GptDiagnosticsFactObserver | undefined; let consumerGeneration = 0; let replaying = false; let disposed = false; @@ -294,14 +429,14 @@ export function createGptDiagnosticsFactBuffer( // Overflow reporting is diagnostics-only. } }; - const enqueue = (fact: Readonly): void => { + const enqueue = (fact: Readonly): void => { if (pending.length >= MAX_BUFFERED_FACTS) { pending.shift(); reportOverflow(); } pending.push(fact); }; - const deliver = (fact: Readonly): void => { + const deliver = (fact: Readonly): void => { const current = consumer; if (!current) return; try { @@ -314,9 +449,13 @@ export function createGptDiagnosticsFactBuffer( return Object.freeze({ adoptFirstDisplay: ( diagnostics: Readonly, - resolveSlot?: (traceToken: string) => Readonly | undefined + resolveSlot?: (traceToken: string) => Readonly | undefined, + resolveOpportunity?: ( + traceToken: string, + slot: Readonly + ) => Readonly | undefined ): boolean => { - const facts = rawFirstDisplayFacts(diagnostics, resolveSlot); + const facts = rawFirstDisplayFacts(diagnostics, resolveSlot, resolveOpportunity); if (disposed || !adoptionOpen || pending.length !== 0 || consumer !== undefined || !facts) { return false; } @@ -325,14 +464,14 @@ export function createGptDiagnosticsFactBuffer( adoptionOpen = false; return true; }, - publish: (fact: Readonly): boolean => { + publish: (fact: Readonly): boolean => { adoptionOpen = false; if (disposed || !validFact(fact)) return false; if (replaying || !consumer) enqueue(fact); else deliver(fact); return true; }, - activate: (nextConsumer: GoogletagDiagnosticsObserver): (() => void) | undefined => { + activate: (nextConsumer: GptDiagnosticsFactObserver): (() => void) | undefined => { adoptionOpen = false; if (disposed || consumer || typeof nextConsumer !== 'function') return undefined; consumer = nextConsumer; @@ -445,7 +584,7 @@ export function activateGptDiagnosticsEventListeners( /** Connect the sole GPT adapter stream and only the four diagnostics-only listeners. */ export function activateGptDiagnosticsFactCapture( adapter: Pick, - buffer: Pick + buffer: Readonly<{ publish: (fact: Readonly) => boolean }> ): (() => void) | undefined { let disposed = false; const observerRelease = adapter.observeDiagnostics((fact) => { 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 98bc53bc4..0e57d186d 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/module.ts @@ -50,8 +50,11 @@ import { createBrowserSlotReconciliationBoundary, createSlotService, type GptSlotBinding, + type SlotRequestHandle, + type SlotRequestInput, type SlotRequestOutcome, type SlotService, + type TrustedServerRequestOpportunity, } from '../../services/slots'; import type { TargetingBoundary, @@ -62,7 +65,9 @@ import { createTargetingService } from '../../services/targeting'; import { activateGptDiagnosticsEventListeners, + createTrustedServerOpportunityFact, createGptDiagnosticsFactBuffer, + publishTrustedServerOpportunityFact, projectGptTraceFact, } from './diagnostics_facts'; import { installGptGuard, resetGuardState } from './script_guard'; @@ -221,7 +226,8 @@ export function adoptInitialPucTicketsFromHandoff( export function adoptInitialGptFactsFromHandoff( candidate: unknown, buffer: Pick, 'adoptFirstDisplay'> | undefined, - adapter: Pick + adapter: Pick, + projection: Readonly | undefined ): PersistentFirstDisplayAdoptionV1 | undefined { const adoption = snapshotPersistentFirstDisplayAdoptionV1(candidate); if (!adoption) return undefined; @@ -235,14 +241,55 @@ export function adoptInitialGptFactsFromHandoff( } const cycles = frozenArray(dataField(adoption.handoff, 'cycles')); const artifacts = frozenArray(dataField(adoption.handoff, 'artifacts')); - if (!cycles || !artifacts || adoption.identities.length !== cycles.length + artifacts.length) { + const slots = frozenArray(dataField(adoption.handoff, 'slots')); + if ( + !cycles || + !artifacts || + !slots || + adoption.identities.length !== cycles.length + artifacts.length + ) { return undefined; } + const formatsBySlot = new Map(); + for (const slot of slots) { + const slotId = dataField(slot, 'id'); + const formats = frozenArray(dataField(slot, 'formats')); + if (typeof slotId !== 'string' || formatsBySlot.has(slotId) || !formats) return undefined; + const copied: Array = []; + for (let index = 0; index < formats.length && index < 16; index += 1) { + const format = formats[index]; + const dimensions = frozenArray(format); + if ( + !dimensions || + dimensions.length !== 2 || + !dimensions.every( + (dimension) => + typeof dimension === 'number' && + Number.isInteger(dimension) && + dimension >= 1 && + dimension <= 4096 + ) + ) { + return undefined; + } + copied.push(Object.freeze([dimensions[0] as number, dimensions[1] as number] as const)); + } + if (copied.length === 0) return undefined; + formatsBySlot.set(slotId, Object.freeze(copied)); + } const identities = new Map>(); + const requestByToken = new Map< + string, + Readonly<{ formats: readonly (readonly [number, number])[]; slotId: string }> + >(); for (let index = 0; index < cycles.length; index += 1) { const token = dataField(cycles[index], 'token'); + const slotId = dataField(cycles[index], 'slotId'); const physicalSlot = adoption.identities[index]; - if (typeof token !== 'string' || !physicalSlot) return undefined; + const formats = typeof slotId === 'string' ? formatsBySlot.get(slotId) : undefined; + if (typeof token !== 'string' || typeof slotId !== 'string' || !formats || !physicalSlot) { + return undefined; + } let identity: ReturnType; try { identity = adapter.diagnosticsIdentity(physicalSlot); @@ -251,11 +298,27 @@ export function adoptInitialGptFactsFromHandoff( } if (!identity || identity.traceToken !== token || identities.has(token)) return undefined; identities.set(token, identity); + requestByToken.set(token, Object.freeze({ formats, slotId })); } try { return buffer.adoptFirstDisplay( diagnostics as Readonly, - (traceToken) => identities.get(traceToken) + (traceToken) => identities.get(traceToken), + (traceToken, slot) => { + const request = requestByToken.get(traceToken); + const trustedServerAuctionId = projection?.auction.auctionId; + return request && + typeof trustedServerAuctionId === 'string' && + trustedServerAuctionId.length > 0 + ? createTrustedServerOpportunityFact({ + auctionSlotId: request.slotId, + opportunity: 'renderable_candidate', + requestedSlotSizes: request.formats, + slot, + trustedServerAuctionId, + }) + : undefined; + } ) ? adoption : undefined; @@ -555,6 +618,7 @@ export interface GptSlotOperationInput extends Omit; readonly requestClass: string; readonly slots: Pick; + readonly trustedServerOpportunity?: TrustedServerRequestOpportunity; } export type GptWinnerPublicationFailureReason = Extract< @@ -587,10 +651,11 @@ export interface GptWinnerPublicationInput extends Omit< readonly targeting: Pick; } -function currentProjectedWinner(input: GptWinnerPublicationInput): boolean { +function currentProjectedWinner( + input: GptWinnerPublicationInput, + projection: Readonly | undefined +): boolean { try { - const projection = input.navigation.currentAuctionProjection as - BrowserAuctionProjectionV1 | undefined; const bid = input.bid; const placement = input.placement; if ( @@ -786,10 +851,18 @@ export async function publishGptWinner( // Rejected publication retains no artifact authority. } }; - if (!currentProjectedWinner(input)) { + const projection = input.navigation.currentAuctionProjection as + Readonly | undefined; + if (!projection || !currentProjectedWinner(input, projection)) { disposeArtifact(); return failAttempt('winner_not_renderable'); } + const trustedServerOpportunity: TrustedServerRequestOpportunity = Object.freeze({ + requestedSlotSizes: Object.freeze( + input.placement.formats.map((size) => Object.freeze([size[0], size[1]] as const)) + ), + trustedServerAuctionId: projection.auction.auctionId, + }); const isStillBound = (): boolean => { try { return input.slots.isBoundGptSlot(input.navigation.generation, input.bid.slot, input.slot); @@ -921,6 +994,7 @@ export async function publishGptWinner( recordNonemptyGam: (bridgeInput) => input.pucBridge.recordNonemptyGam(bridgeInput), }, requestClass: input.requestClass, + trustedServerOpportunity, reservationId: input.bid.rendererReservationId, slots: { request: (requestInput) => { @@ -956,6 +1030,12 @@ async function publishPbsCacheGptWinner(input: PbsCacheGptPublicationInput): Pro const projection = input.navigation.currentAuctionProjection as Readonly | undefined; if (!projection) return; + const trustedServerOpportunity: TrustedServerRequestOpportunity = Object.freeze({ + requestedSlotSizes: Object.freeze( + input.placement.formats.map((size) => Object.freeze([size[0], size[1]] as const)) + ), + trustedServerAuctionId: projection.auction.auctionId, + }); const current = (): boolean => { try { if ( @@ -1027,6 +1107,7 @@ async function publishPbsCacheGptWinner(input: PbsCacheGptPublicationInput): Pro operation: input.binding.operation, registeredSlotId: input.bid.slot, requestClass: input.requestClass, + trustedServerOpportunity, }); await handle.result; } catch (error) { @@ -1125,6 +1206,9 @@ export function startGptSlotOperation(input: GptSlotOperationInput): SlotOperati operation: input.operation, registeredSlotId: input.attempt.slot, requestClass: input.requestClass, + ...(input.trustedServerOpportunity === undefined + ? {} + : { trustedServerOpportunity: input.trustedServerOpportunity }), }); } catch { input.attempt.fail('gpt_request_failed'); @@ -1458,6 +1542,169 @@ interface PreparedInitialGptWinner { readonly terminal: Promise; } +interface PreparedInitialPbsCacheWinner { + readonly bid: PbsCacheBrowserAuctionBidV1; + readonly binding: Readonly<{ operation: 'display' | 'refresh'; slot: object }>; + readonly placement: BrowserAuctionSlotV1; +} + +interface InitialGptRequestParticipant { + readonly complete: () => void; + readonly request: (input: SlotRequestInput) => SlotRequestHandle; +} + +function failedBatchRequestHandle(): SlotRequestHandle { + const outcome = Object.freeze({ + reason: 'gpt_request_failed' as const, + status: 'failed' as const, + }); + return Object.freeze({ + status: 'terminal' as const, + result: Promise.resolve(outcome), + dispose: () => undefined, + }); +} + +function createInitialGptRequestBatch( + slots: Pick, + participantCount: number +): Readonly<{ participant: () => InitialGptRequestParticipant }> { + interface PendingRequest { + readonly bind: (handle: SlotRequestHandle) => void; + readonly fail: () => void; + readonly input: SlotRequestInput; + readonly isDisposed: () => boolean; + } + + const pending: PendingRequest[] = []; + let claimedParticipants = 0; + let readyParticipants = 0; + let flushed = false; + const flush = (): void => { + if (flushed || readyParticipants !== participantCount) return; + flushed = true; + const active = pending.filter((request) => !request.isDisposed()); + if (active.length === 0) return; + let handles: readonly SlotRequestHandle[] = Object.freeze([]); + try { + const inputs = Object.freeze(active.map(({ input }) => input)); + handles = slots.requestBatch(inputs); + if (!Array.isArray(handles) || handles.length !== active.length) { + throw new Error('GPT SRA batch admission failed'); + } + for (let index = 0; index < active.length; index += 1) { + const request = active[index]; + const handle = handles[index]; + if (!request || !handle) throw new Error('GPT SRA batch result is incomplete'); + request.bind(handle); + } + } catch { + for (let index = 0; index < handles.length; index += 1) { + try { + handles[index]?.dispose(); + } catch { + // Continue rolling back the other partially returned handles. + } + } + for (let index = 0; index < active.length; index += 1) active[index]?.fail(); + } + }; + + return Object.freeze({ + participant: (): InitialGptRequestParticipant => { + if (claimedParticipants >= participantCount) { + return Object.freeze({ + complete: () => undefined, + request: () => failedBatchRequestHandle(), + }); + } + claimedParticipants += 1; + let ready = false; + let requested = false; + const markReady = (): void => { + if (ready) return; + ready = true; + readyParticipants += 1; + flush(); + }; + return Object.freeze({ + complete: markReady, + request: (input: SlotRequestInput): SlotRequestHandle => { + if (requested || ready) return failedBatchRequestHandle(); + requested = true; + let disposed = false; + let terminal = false; + let activeHandle: SlotRequestHandle | undefined; + let resolve!: (outcome: SlotRequestOutcome) => void; + const result = new Promise((resolveResult) => { + resolve = resolveResult; + }); + const settleFailed = (): void => { + if (terminal) return; + terminal = true; + resolve(Object.freeze({ reason: 'gpt_request_failed', status: 'failed' })); + }; + const settleDisposed = (): void => { + if (terminal) return; + terminal = true; + resolve(Object.freeze({ reason: 'superseded', status: 'cancelled' })); + }; + const pendingRequest: PendingRequest = { + bind: (handle): void => { + if (terminal) { + try { + handle.dispose(); + } catch { + // The placeholder outcome already owns terminal settlement. + } + return; + } + activeHandle = handle; + void handle.result.then((outcome) => { + if (terminal) return; + terminal = true; + resolve(outcome); + }, settleFailed); + if (disposed) { + try { + handle.dispose(); + } catch { + settleDisposed(); + } + } + }, + fail: settleFailed, + input, + isDisposed: () => disposed, + }; + pending[pending.length] = pendingRequest; + const placeholder = Object.freeze({ + get status(): 'active' | 'queued' | 'terminal' { + return terminal ? 'terminal' : (activeHandle?.status ?? 'active'); + }, + result, + dispose: (): void => { + if (disposed) return; + disposed = true; + if (activeHandle) { + try { + activeHandle.dispose(); + } catch { + settleDisposed(); + } + } else { + settleDisposed(); + } + }, + }); + markReady(); + return placeholder; + }, + }); + }, + }); +} + /** Start one immutable initial GPT winner batch and protect every terminal latch together. */ export async function publishInitialGptProjection( document: Document, @@ -1543,7 +1790,7 @@ export async function publishInitialGptProjection( const batch = navigation.createAuctionBatch(`gpt:${projection.auction.auctionId}`); if (!batch) return; const prepared: PreparedInitialGptWinner[] = []; - const cachePublications: Promise[] = []; + const preparedCache: PreparedInitialPbsCacheWinner[] = []; let winnerIndex = 0; for (let index = 0; index < projection.auction.results.length; index += 1) { const decision = projection.auction.results[index]; @@ -1555,18 +1802,7 @@ export async function publishInitialGptProjection( const binding = physicalBySlot.get(decision.slot); if (!('rendererReservationId' in bid)) { if (binding) { - cachePublications.push( - publishPbsCacheGptWinner({ - bid, - binding, - googletag, - navigation, - placement, - requestClass: input.requestClass ?? 'initial', - slots, - targeting: input.targeting, - }) - ); + preparedCache.push(Object.freeze({ bid, binding, placement })); } continue; } @@ -1586,69 +1822,100 @@ export async function publishInitialGptProjection( }) ); } - if (prepared.length === 0 && cachePublications.length === 0) return; + if (prepared.length === 0 && preparedCache.length === 0) return; + const requestBatch = createInitialGptRequestBatch(slots, prepared.length + preparedCache.length); + const cachePublications = preparedCache.map(({ bid, binding, placement }) => { + const participant = requestBatch.participant(); + return Promise.resolve().then(async () => { + try { + await publishPbsCacheGptWinner({ + bid, + binding, + googletag, + navigation, + placement, + requestClass: input.requestClass ?? 'initial', + slots: Object.freeze({ + isBoundGptSlot: slots.isBoundGptSlot, + request: participant.request, + }), + targeting: input.targeting, + }); + } finally { + participant.complete(); + } + }); + }); input.protect(Object.freeze([...prepared.map(({ terminal }) => terminal), ...cachePublications])); await Promise.all([ ...cachePublications, ...prepared.map(async ({ attempt, bid, binding, decision, owner, placement }) => { - if (!binding) { - attempt.fail('slot_unresolved'); - return; - } - const artifact = Object.freeze({ - kind: 'puc' as const, - attemptId: attempt.id, - slot: attempt.slot, - navigationGeneration: attempt.navigationGeneration, - dispose: () => undefined, - }); - const published = await publishGptWinner({ - artifact, - attempt, - bid, - createSlotOperation: render.createSlotOperation, - googletag, - navigation, - operation: binding.operation, - owner, - placement, - pucBridge: input.pucBridge, - requestClass: input.requestClass ?? 'initial', - reservations: render.reservations, - slot: binding.slot, - slots, - targeting: input.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 = render.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'); + const participant = requestBatch.participant(); + try { + if (!binding) { + attempt.fail('slot_unresolved'); + return; + } + const artifact = Object.freeze({ + kind: 'puc' as const, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: attempt.navigationGeneration, + dispose: () => undefined, + }); + const published = await publishGptWinner({ + artifact, + attempt, + bid, + createSlotOperation: render.createSlotOperation, + googletag, + navigation, + operation: binding.operation, + owner, + placement, + pucBridge: input.pucBridge, + requestClass: input.requestClass ?? 'initial', + reservations: render.reservations, + slot: binding.slot, + slots: Object.freeze({ + isBoundGptSlot: slots.isBoundGptSlot, + request: participant.request, + }), + targeting: input.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 = render.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 (!render.renderWinner(fallback.value)) fallback.value.fail('winner_not_renderable'); return fallback; - } - if (!render.renderWinner(fallback.value)) fallback.value.fail('winner_not_renderable'); - return fallback; - }, - }); - if (!published.ok && navigation.isCurrent()) { - log.warn('GPT projection winner publication failed', published.reason); + }, + }); + if (!published.ok && navigation.isCurrent()) { + log.warn('GPT projection winner publication failed', published.reason); + } + } finally { + participant.complete(); } }), ]); @@ -1704,6 +1971,14 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg } ); scope.onDispose(() => googletag.dispose()); + const diagnosticsEnabled = gptDiagnosticsActive(runtime); + const diagnosticsFacts = diagnosticsEnabled + ? createGptDiagnosticsFactBuffer({ + onOverflow: (droppedFacts) => + log.warn('GPT diagnostics fact buffer overflow', droppedFacts), + }) + : undefined; + if (diagnosticsFacts) scope.onDispose(diagnosticsFacts.dispose); const reconciliation = createBrowserSlotReconciliationBoundary( document, document.defaultView.MutationObserver @@ -1716,20 +1991,26 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg render.artifacts.release(artifact); }, googletag, + ...(diagnosticsFacts + ? { + onTrustedServerRequest: ({ opportunity, registeredSlotId, slot }) => { + if (!opportunity) return; + publishTrustedServerOpportunityFact({ + adapter: googletag, + buffer: diagnosticsFacts, + physicalSlot: slot, + registeredSlotId, + opportunity, + }); + }, + } + : {}), ...(reconciliation ? { reconciliation } : {}), }); scope.onDispose(() => slots.dispose()); const targeting = createTargetingService(); scope.onDispose(() => targeting.dispose()); const startup = createGptStartup({ googletag, slots: () => slots }); - const diagnosticsEnabled = gptDiagnosticsActive(runtime); - const diagnosticsFacts = diagnosticsEnabled - ? createGptDiagnosticsFactBuffer({ - onOverflow: (droppedFacts) => - log.warn('GPT diagnostics fact buffer overflow', droppedFacts), - }) - : undefined; - if (diagnosticsFacts) scope.onDispose(diagnosticsFacts.dispose); let pucBridge: PucBridge | undefined; let takeoverReconciliationRelease: (() => void) | undefined; let laterLifecycleActive = false; @@ -1950,7 +2231,13 @@ function prepareProductionGpt(context: IntegrationPrepareContext): PreparedInteg const factAdoption = adoptionCandidate === undefined ? undefined - : adoptInitialGptFactsFromHandoff(adoptionCandidate, diagnosticsFacts, googletag); + : adoptInitialGptFactsFromHandoff( + adoptionCandidate, + diagnosticsFacts, + googletag, + auction.navigation.currentAuctionProjection as + Readonly | undefined + ); if (adoptionCandidate !== undefined && !factAdoption) { throw new TypeError('GPT first-display diagnostics facts are invalid'); } 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 3b9b2d244..5bcbff4ba 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,11 @@ -import type { GptDiagnosticsApi, GptDiagnosticsExportV1 } from '../../core/types'; +import type { + GptDiagnosticsApi, + GptDiagnosticsCreativeFailure, + GptDiagnosticsExportV1, + GptDiagnosticsRecorder, + GptDiagnosticsSlotHandle, + GptDiagnosticsTrustedServerOpportunity, +} from '../../core/types'; import { DiagnosticsSubscriberLimitError } from '../../core/trace'; import type { GptDiagnosticsBindingManager } from './binding'; @@ -7,6 +14,20 @@ import type { GptDiagnosticsStoreSnapshot } from './store'; interface ApiStore { snapshot(): GptDiagnosticsStoreSnapshot; subscribeCommits(listener: () => void): () => void; + recordTrustedServerOpportunity( + slot: GptDiagnosticsSlotHandle, + auctionSlotId: string, + opportunity: GptDiagnosticsTrustedServerOpportunity, + trustedServerAuctionId?: string, + requestedSlotSizes?: ReadonlyArray + ): void; + recordPrebidRefresh(slots: GptDiagnosticsSlotHandle[]): void; + recordTrustedServerCreativeRequest(auctionSlotId: string): number | undefined; + recordTrustedServerCreativeResponse(attemptId: number): void; + recordTrustedServerCreativeFailure( + attemptId: number, + reason: GptDiagnosticsCreativeFailure + ): void; } interface ApiBindingManager { @@ -44,45 +65,6 @@ function scheduleTask(callback: () => void): () => void { return () => globalThis.clearTimeout(handle); } -function cloneExportSnapshot(snapshot: GptDiagnosticsExportV1): GptDiagnosticsExportV1 { - return { - version: snapshot.version, - capturedAt: snapshot.capturedAt, - page: { ...snapshot.page }, - slots: snapshot.slots.map((slot) => ({ - ...slot, - binding: { ...slot.binding }, - requests: slot.requests.map((cycle) => ({ - ...cycle, - durations: { ...cycle.durations }, - requestedSlotSizes: cycle.requestedSlotSizes?.map((size) => [...size]), - size: cycle.size ? [...cycle.size] : undefined, - observedSlotSize: cycle.observedSlotSize ? [...cycle.observedSlotSize] : undefined, - adManager: cycle.adManager - ? { - ...cycle.adManager, - yieldGroupIds: cycle.adManager.yieldGroupIds - ? [...cycle.adManager.yieldGroupIds] - : undefined, - companyIds: cycle.adManager.companyIds ? [...cycle.adManager.companyIds] : undefined, - } - : undefined, - trustedServerCreativeFailures: cycle.trustedServerCreativeFailures - ? [...cycle.trustedServerCreativeFailures] - : undefined, - })), - })), - callbackIssues: snapshot.callbackIssues.map((issue) => ({ ...issue })), - ...(snapshot.attributionIssues === undefined - ? {} - : { attributionIssues: snapshot.attributionIssues.map((issue) => ({ ...issue })) }), - coverage: Object.fromEntries( - Object.entries(snapshot.coverage).map(([kind, counters]) => [kind, { ...counters }]) - ) as GptDiagnosticsExportV1['coverage'], - metadata: { ...snapshot.metadata }, - }; -} - function safelyRecord(action: () => void): void { try { action(); @@ -217,6 +199,9 @@ export class GptDiagnosticsApiController { const callbackIssues = Object.freeze( store.callbackIssues.map((issue) => Object.freeze({ ...issue })) ); + const attributionIssues = Object.freeze( + store.attributionIssues.map((issue) => Object.freeze({ ...issue })) + ); const coverage = Object.freeze( Object.fromEntries( Object.entries(store.coverage).map(([kind, counters]) => [ @@ -234,6 +219,7 @@ export class GptDiagnosticsApiController { }), slots, callbackIssues, + attributionIssues, coverage, metadata: Object.freeze({ ...store.metadata }), }) as GptDiagnosticsExportV1; 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 2de7e1c52..ed67c2caf 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 @@ -36,20 +36,6 @@ interface BadgeOptions { scheduleFrame?: ((callback: () => void) => () => void) | undefined; } -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 { return ( rectangle.width > 0 && @@ -119,10 +105,6 @@ function deliveryLabel(cycle: GptDiagnosticsRequestCycle): string | undefined { } } -function formatSizes(sizes: ReadonlyArray): string { - return sizes.map((size) => `${size[0]}×${size[1]}`).join(', '); -} - /** Format one observed GPT request cycle for both badge and accessible text surfaces. */ export function formatGptDiagnosticsBadgeText(cycle: GptDiagnosticsRequestCycle): string { const firstLine: string[] = []; @@ -191,7 +173,8 @@ export class GptDiagnosticsBadgeManager { options.window ?? (this.document.defaultView as unknown as BadgeWindow | null) ?? (window as unknown as BadgeWindow); - this.scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + this.scheduleFrame = + options.scheduleFrame ?? ((callback) => scheduleFrame(this.window, callback)); this.refreshSlotElementIds(); this.unsubscribeStore = this.store.subscribe(() => { this.refreshSlotElementIds(); 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 0eb3752f2..b2c2a78d2 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 @@ -29,20 +29,6 @@ export interface GptDiagnosticsBindingView { type BindingListener = () => void; -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 { const rectangle = element.getBoundingClientRect(); if (rectangle.width <= 0 || rectangle.height <= 0) return false; @@ -119,7 +105,8 @@ export class GptDiagnosticsBindingManager { options.window ?? (this.document.defaultView as unknown as BindingWindow | null) ?? (window as unknown as BindingWindow); - this.scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + this.scheduleFrame = + options.scheduleFrame ?? ((callback) => scheduleFrame(this.window, callback)); this.unsubscribeStore = this.store.subscribe(() => this.scheduleRefresh()); this.window.addEventListener('scroll', this.scheduleRefresh, { passive: true }); diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/data_api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/data_api.ts index d2473b501..299eca58c 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/data_api.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/data_api.ts @@ -2,6 +2,7 @@ import type { GptDiagnosticsApi, GptDiagnosticsBinding, GptDiagnosticsExportV1, + Size, } from '../../core/types'; import { DiagnosticsSubscriberLimitError } from '../../core/trace'; @@ -9,6 +10,11 @@ import type { GptDiagnosticsBindingInput, GptDiagnosticsStoreSnapshot } from './ interface DiagnosticsDataStore { readonly bindingInputs: () => GptDiagnosticsBindingInput[]; + readonly recordObservedSlotSize: ( + runtimeSlotNumber: number, + requestNumber: number, + size: Size + ) => void; readonly snapshot: () => GptDiagnosticsStoreSnapshot; readonly subscribe: (listener: () => void) => () => void; readonly subscribeCommits: (listener: () => void) => () => void; @@ -25,6 +31,11 @@ export interface GptDiagnosticsPresentationControls { export interface GptDiagnosticsPresentationSource { readonly bindingInputs: () => GptDiagnosticsBindingInput[]; + readonly recordObservedSlotSize: ( + runtimeSlotNumber: number, + requestNumber: number, + size: Size + ) => void; readonly snapshot: () => GptDiagnosticsStoreSnapshot; readonly subscribe: (listener: () => void) => () => void; } @@ -128,6 +139,8 @@ export class GptDiagnosticsDataApiController { this.unsubscribeStore = store.subscribeCommits(() => this.scheduleNotification()); this.presentationSource = Object.freeze({ bindingInputs: () => store.bindingInputs(), + recordObservedSlotSize: (runtimeSlotNumber: number, requestNumber: number, size: Size) => + store.recordObservedSlotSize(runtimeSlotNumber, requestNumber, size), snapshot: () => store.snapshot(), subscribe: (listener: () => void) => store.subscribe(listener), }); @@ -237,6 +250,9 @@ export class GptDiagnosticsDataApiController { callbackIssues: Object.freeze( store.callbackIssues.map((issue) => Object.freeze({ ...issue })) ), + attributionIssues: Object.freeze( + store.attributionIssues.map((issue) => Object.freeze({ ...issue })) + ), coverage: Object.freeze( Object.fromEntries( Object.entries(store.coverage).map(([kind, counters]) => [ 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 index 635db6b1d..2ba1ba108 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/module.ts @@ -1,4 +1,3 @@ -import type { GoogletagDiagnosticsFact } from '../../adapters/googletag'; import type { IntegrationActivationContext, IntegrationPrepareContext, @@ -10,13 +9,13 @@ import { GptDiagnosticsDataApiController, type GptDiagnosticsPresentationFactory, } from './data_api'; -import { GptDiagnosticsObserver } from './observer'; +import { GptDiagnosticsObserver, type GptDiagnosticsFact } from './observer'; import { GptDiagnosticsStore } from './store'; export const GPT_DIAGNOSTICS_INTEGRATION_ID = 'gpt_diagnostics' as const; interface GptEventsCapability { - readonly subscribe: (listener: (fact: Readonly>) => void) => () => void; + readonly subscribe: (listener: (fact: Readonly) => void) => () => void; } function activeConfiguration(candidate: unknown): boolean { @@ -107,7 +106,7 @@ export function createGptDiagnosticsIntegrationRegistration( }); active = true; const release = events.subscribe((fact) => { - if (active) observer.consume(fact as unknown as Readonly); + if (active) observer.consume(fact); }); if (typeof release !== 'function') { throw new TypeError('GPT diagnostics event disposer is unavailable'); 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 8a5a20902..7e646d294 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,11 +1,38 @@ import type { GoogletagDiagnosticsFact } from '../../adapters/googletag'; import { log } from '../../core/log'; -import type { GptDiagnosticsAdManagerIdentity, Size, TsjsApi } from '../../core/types'; +import type { Size } from '../../core/types'; +import type { GptDiagnosticsOpportunityFact } from '../../shared/gpt_diagnostics'; import type { GptDiagnosticsSlotLike, GptRenderFacts } from './store'; +export type GptDiagnosticsFact = GoogletagDiagnosticsFact | GptDiagnosticsOpportunityFact; + +function renderFacts(fact: Readonly): GptRenderFacts { + const adManager = fact.adManager; + return { + isEmpty: fact.isEmpty, + size: fact.size ? ([...fact.size] as Size) : undefined, + isBackfill: fact.isBackfill, + slotContentChanged: fact.slotContentChanged, + adManager: adManager + ? { + ...adManager, + yieldGroupIds: adManager.yieldGroupIds ? [...adManager.yieldGroupIds] : undefined, + companyIds: adManager.companyIds ? [...adManager.companyIds] : undefined, + } + : undefined, + }; +} + export interface GptDiagnosticsObserverStore { markGptObserved(): void; + recordTrustedServerOpportunity( + slot: GptDiagnosticsSlotLike, + auctionSlotId: string, + opportunity: 'renderable_candidate' | 'unrenderable_candidate' | 'no_candidate', + trustedServerAuctionId?: string, + requestedSlotSizes?: ReadonlyArray + ): void; recordSlotRequested(slot: GptDiagnosticsSlotLike, timestampMs?: number): void; recordSlotResponseReceived(slot: GptDiagnosticsSlotLike, timestampMs?: number): void; recordSlotRenderEnded( @@ -47,8 +74,20 @@ export class GptDiagnosticsObserver { this.started = true; } - consume(fact: Readonly): void { + consume(fact: Readonly): void { this.start(); + if (fact.kind === 'trustedServerOpportunity') { + this.handle(fact.kind, () => + this.store.recordTrustedServerOpportunity( + fact.slot, + fact.auctionSlotId, + fact.opportunity, + fact.trustedServerAuctionId, + fact.requestedSlotSizes + ) + ); + return; + } if (!this.observed) { this.observed = true; this.handle('observation', () => this.store.markGptObserved()); @@ -76,22 +115,8 @@ export class GptDiagnosticsObserver { case 'slotRenderEnded': this.handle(fact.kind, () => 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 - ) + ? this.store.recordSlotRenderEnded(slot, renderFacts(fact)) + : this.store.recordSlotRenderEnded(slot, renderFacts(fact), observedAtMs) ); return; case 'slotOnload': @@ -124,10 +149,7 @@ export class GptDiagnosticsObserver { } } - private handle( - kind: GoogletagDiagnosticsFact['kind'] | 'observation', - callback: () => void - ): void { + private handle(kind: GptDiagnosticsFact['kind'] | 'observation', callback: () => void): void { try { callback(); } catch (error) { @@ -138,44 +160,4 @@ export class GptDiagnosticsObserver { } } } - - private installRefreshObserver(pubads: GptPubAdsService): void { - if ( - typeof pubads.refresh !== 'function' || - (pubads as GptPubAdsService & { [REFRESH_OBSERVER_INSTALLED]?: boolean })[ - REFRESH_OBSERVER_INSTALLED - ] - ) { - return; - } - const originalRefresh = pubads.refresh; - const { store, window: observerWindow } = this; - pubads.refresh = function (this: unknown, ...args: unknown[]): unknown { - try { - if ( - !( - observerWindow.tsjs?.adInitRefreshInProgress || - observerWindow.tsjs?.prebidRefreshDispatchInProgress - ) - ) { - // GPT treats a missing or null `slots` argument as "refresh every - // slot", and `refresh(null, opts)` is the documented way to pass - // options while doing so. - const rawSlots = args.length === 0 || args[0] == null ? pubads.getSlots?.() : args[0]; - const slots = Array.isArray(rawSlots) - ? rawSlots.filter( - (slot): slot is GptDiagnosticsSlotLike => typeof slot === 'object' && slot !== null - ) - : []; - if (slots.length > 0) store.recordPublisherRefresh(slots); - } - } catch { - // Diagnostics must not affect the original refresh. - } - return Reflect.apply(originalRefresh, this, args); - }; - (pubads as GptPubAdsService & { [REFRESH_OBSERVER_INSTALLED]?: boolean })[ - REFRESH_OBSERVER_INSTALLED - ] = true; - } } 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 384b59841..9278ee1e7 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 @@ -100,20 +100,6 @@ const PANEL_STYLES = ` } `; -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( slot: GptDiagnosticsStoreSlotSnapshot ): GptDiagnosticsRequestCycle | undefined { @@ -633,7 +619,7 @@ export class GptDiagnosticsOverlay { const summary = this.document.createElement('div'); summary.className = 'tsgd-summary'; - summary.textContent = `${snapshot.slots.length} slots · ${snapshot.callbackIssues.length} callback issues · ${snapshot.attributionIssues?.length ?? 0} attribution issues`; + summary.textContent = `${snapshot.slots.length} slots · ${snapshot.callbackIssues.length} callback issues · ${snapshot.attributionIssues.length} attribution issues`; const coverage = this.document.createElement('ul'); coverage.className = 'tsgd-coverage'; for (const [kind, counters] of Object.entries(snapshot.coverage)) { diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation.ts index 4469f57b3..fb461cdaf 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation.ts @@ -21,6 +21,7 @@ import type { GptDiagnosticsPresentationSource, } from './data_api'; import { GptDiagnosticsOverlay } from './overlay'; +import { GptDiagnosticsSlotSizeObserver } from './slot_size_observer'; type GptDiagnosticsWindow = Window & typeof globalThis; @@ -483,9 +484,11 @@ function createPresentationControls( }); let badges: GptDiagnosticsBadgeManager | undefined; let overlay: GptDiagnosticsOverlay | undefined; + let slotSizeObserver: GptDiagnosticsSlotSizeObserver | undefined; const cleanup = (): void => { isolate(() => overlay?.destroy()); isolate(() => badges?.destroy()); + isolate(() => slotSizeObserver?.destroy()); isolate(() => bindings.destroy()); }; try { @@ -493,6 +496,9 @@ function createPresentationControls( window: targetWindow, document: targetDocument, }); + slotSizeObserver = new GptDiagnosticsSlotSizeObserver(source, bindings, { + window: targetWindow, + }); overlay = new GptDiagnosticsOverlay(source, bindings, { window: targetWindow, document: targetDocument, diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts index b601833b8..bf35543c0 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/presentation_helpers.ts @@ -7,12 +7,27 @@ export function formatSizes(sizes: ReadonlyArray): string { /** Schedules presentation work in the target window's next animation frame. */ export function scheduleFrame( - window: Pick, + window: Pick, callback: () => void -): void { +): () => void { if (typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(() => callback()); - } else { - queueMicrotask(callback); + let active = true; + const frame = window.requestAnimationFrame(() => { + if (active) callback(); + }); + return () => { + active = false; + if (typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(frame); + } + }; } + + let active = true; + queueMicrotask(() => { + if (active) callback(); + }); + return () => { + active = false; + }; } 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 b73bceb44..4204c6a3c 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 @@ -12,7 +12,6 @@ import type { GptDiagnosticsRequestCycle, GptDiagnosticsRequestPath, GptDiagnosticsResponseClass, - GptDiagnosticsSlotHandle, GptDiagnosticsTrustedServerOpportunity, Size, } from '../../core/types'; @@ -55,6 +54,7 @@ export interface GptRenderFacts { size?: Size | undefined; isBackfill?: boolean | undefined; slotContentChanged?: boolean | undefined; + adManager?: GptDiagnosticsAdManagerIdentity | undefined; } export interface GptDiagnosticsStoreSlotSnapshot { @@ -99,6 +99,8 @@ interface MutableSlotRecord { interface StoreOptions { now?: (() => number) | undefined; schedule?: ((callback: () => void) => void) | undefined; + /** Deferred marker cleanup and diagnostic-window re-notification. */ + defer?: ((callback: () => void, delayMs: number) => void) | undefined; } type RequestIntentSource = 'trusted_server_direct' | 'prebid_refresh' | 'publisher_refresh'; @@ -119,16 +121,19 @@ type AttemptStatus = 'live' | 'completed' | 'expired' | 'evicted'; interface CreativeAttemptRecord { id: number; - cycle?: MutableRequestCycle; - runtimeSlotNumber?: number; - slotElementId?: string; + cycle?: MutableRequestCycle | undefined; + runtimeSlotNumber?: number | undefined; + slotElementId?: string | undefined; requestedAtMs: number; expiresAtMs: number; provisionalBeforeRender: boolean; status: AttemptStatus; } -type AttributionIdentity = Partial>; +interface AttributionIdentity { + readonly runtimeSlotNumber?: number | undefined; + readonly slotElementId?: string | undefined; +} type StoreListener = () => void; @@ -217,10 +222,12 @@ function normalizedRequestedSlotSizes(value: unknown): ReadonlyArray | und candidate.length !== 2 || typeof candidate[0] !== 'number' || typeof candidate[1] !== 'number' || - !Number.isFinite(candidate[0]) || - !Number.isFinite(candidate[1]) || - candidate[0] <= 0 || - candidate[1] <= 0 + !Number.isInteger(candidate[0]) || + !Number.isInteger(candidate[1]) || + candidate[0] < 1 || + candidate[0] > 4_096 || + candidate[1] < 1 || + candidate[1] > 4_096 ) { continue; } @@ -349,6 +356,7 @@ export class GptDiagnosticsStore { return; } + const identity = slot.token ?? slot; this.trustedServerSlots.delete(auctionSlotId); this.trustedServerSlots.set(auctionSlotId, slot); while (this.trustedServerSlots.size > MAX_TRUSTED_SERVER_ASSOCIATIONS) { @@ -357,7 +365,7 @@ export class GptDiagnosticsStore { this.trustedServerSlots.delete(oldest); } - this.recordRequestIntentSource(slot, 'trusted_server_direct', { + this.recordRequestIntentSource(identity, 'trusted_server_direct', { trustedServerOpportunity: opportunity, trustedServerAuctionId: normalizedAuctionId(trustedServerAuctionId), requestedSlotSizes: normalizedRequestedSlotSizes(requestedSlotSizes), @@ -400,7 +408,7 @@ export class GptDiagnosticsStore { } const identity = this.attributionIdentity(slot); - const runtimeSlotNumber = this.slotNumbers.get(slot); + const runtimeSlotNumber = this.slotNumbers.get(slot.token ?? slot); const record = runtimeSlotNumber === undefined ? undefined : this.slots.get(runtimeSlotNumber); if (!record || record.requests.length === 0) { this.reportAttributionIssue('creative_request_without_cycle', timestampMs, identity); @@ -598,6 +606,9 @@ export class GptDiagnosticsStore { const identity = slot.token ?? slot; const requestNumber = (this.requestNumbers.get(identity) ?? 0) + 1; this.requestNumbers.set(identity, requestNumber); + const intent = this.consumeRequestIntent(identity, timestampMs); + const trustedServerEvidence = intent?.sources.get('trusted_server_direct'); + const requestPath = this.requestPath(intent); record.requests.push({ requestNumber, requestedAtMs: timestampMs, @@ -724,7 +735,9 @@ export class GptDiagnosticsStore { } const record = this.slots.get(runtimeSlotNumber); - const cycle = record?.requests.find((candidate) => candidate.requestNumber === requestNumber); + if (!record) return; + + const cycle = record.requests.find((candidate) => candidate.requestNumber === requestNumber); if ( !record || !cycle || @@ -752,7 +765,8 @@ export class GptDiagnosticsStore { 'slotOnload', slot, timestampMs, - (cycle) => cycle.responseAtMs !== undefined && cycle.loadAtMs === undefined, + (cycle) => + cycle.responseAtMs !== undefined && cycle.isEmpty !== true && cycle.loadAtMs === undefined, (record, cycle) => { cycle.loadAtMs = timestampMs; if (cycle.renderAtMs === undefined) { @@ -863,6 +877,187 @@ export class GptDiagnosticsStore { }; } + private attributionIdentity(slot: GptDiagnosticsSlotLike & object): AttributionIdentity { + const identity = slot.token ?? slot; + const runtimeSlotNumber = this.slotNumbers.get(identity); + const record = runtimeSlotNumber === undefined ? undefined : this.slots.get(runtimeSlotNumber); + const slotElementId = + record?.slotElementId ?? + (typeof slot.elementId === 'string' && slot.elementId.length > 0 + ? slot.elementId + : undefined) ?? + optionalNonEmptyString( + typeof slot.getSlotElementId === 'function' ? slot.getSlotElementId.bind(slot) : undefined + ); + return { + ...(runtimeSlotNumber !== undefined ? { runtimeSlotNumber } : {}), + ...(slotElementId !== undefined ? { slotElementId } : {}), + }; + } + + private addAttributionIssue( + reason: GptDiagnosticsAttributionIssueReason, + timestampMs: number, + identity: AttributionIdentity = {} + ): void { + if (this.attributionIssues.length >= MAX_ATTRIBUTION_ISSUES) { + this.attributionIssues.shift(); + this.metadata.droppedAttributionIssues += 1; + } + + this.attributionIssues.push({ + reason, + timestampMs, + ...(identity.runtimeSlotNumber !== undefined && identity.runtimeSlotNumber > 0 + ? { runtimeSlotNumber: identity.runtimeSlotNumber } + : {}), + ...(identity.slotElementId !== undefined ? { slotElementId: identity.slotElementId } : {}), + }); + } + + private reportAttributionIssue( + reason: GptDiagnosticsAttributionIssueReason, + timestampMs: number, + identity: AttributionIdentity = {} + ): void { + this.addAttributionIssue(reason, timestampMs, identity); + this.notify(); + } + + private expireCreativeAttempts(timestampMs: number): void { + for (const attempt of this.creativeAttempts.values()) { + if (attempt.status !== 'live' || timestampMs < attempt.expiresAtMs) continue; + attempt.status = 'expired'; + attempt.cycle = undefined; + } + } + + private evictCreativeAttempt(cycle: MutableRequestCycle): void { + const attemptId = this.attemptIdsByCycle.get(cycle); + if (attemptId === undefined) return; + const attempt = this.creativeAttempts.get(attemptId); + if (!attempt || attempt.status !== 'live') return; + attempt.status = 'evicted'; + attempt.cycle = undefined; + } + + private ensureCreativeAttemptCapacity(): boolean { + if (this.creativeAttempts.size < MAX_CREATIVE_ATTEMPTS) return true; + + for (const [attemptId, attempt] of this.creativeAttempts) { + if (attempt.status === 'live') continue; + this.creativeAttempts.delete(attemptId); + return true; + } + return false; + } + + /** Record one source's evidence for the slot's next GPT request. */ + private recordRequestIntentSource( + slot: object, + source: RequestIntentSource, + facts: Pick< + PendingSourceEvidence, + 'trustedServerOpportunity' | 'trustedServerAuctionId' | 'requestedSlotSizes' + > = {} + ): void { + const observedAtMs = this.now(); + let intent = this.pendingRequestIntents.get(slot); + if (intent) { + this.expireRequestIntentSources(intent, observedAtMs); + if (intent.sources.size === 0) intent = undefined; + } + if (!intent) { + intent = { intentId: this.nextRequestIntentId, sources: new Map() }; + this.nextRequestIntentId += 1; + this.pendingRequestIntents.set(slot, intent); + } + intent.sources.set(source, { observedAtMs, ...facts }); + } + + private expireRequestIntentSources(intent: PendingRequestIntent, timestampMs: number): void { + for (const [source, evidence] of intent.sources) { + if (timestampMs - evidence.observedAtMs >= REQUEST_PATH_ATTRIBUTION_WINDOW_MS) { + intent.sources.delete(source); + } + } + } + + private consumeRequestIntent( + slot: object, + timestampMs: number + ): PendingRequestIntent | undefined { + const intent = this.pendingRequestIntents.get(slot); + this.pendingRequestIntents.delete(slot); + if (!intent) return undefined; + this.expireRequestIntentSources(intent, timestampMs); + return intent.sources.size > 0 ? intent : undefined; + } + + /** Keep at most one outstanding delivery-evidence boundary timer. */ + private scheduleDeliveryBoundaryNotification( + delayMs: number = TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS + ): void { + if (this.deliveryBoundaryScheduled) return; + this.deliveryBoundaryScheduled = true; + this.defer(() => { + this.deliveryBoundaryScheduled = false; + this.notify(); + const nextDelayMs = this.nextDeliveryBoundaryDelayMs(); + if (nextDelayMs !== undefined) this.scheduleDeliveryBoundaryNotification(nextDelayMs); + }, delayMs); + } + + private nextDeliveryBoundaryDelayMs(): number | undefined { + const nowMs = this.now(); + let earliestDeadlineMs: number | undefined; + for (const record of this.slots.values()) { + for (const cycle of record.requests) { + if ( + cycle.renderAtMs === undefined || + cycle.isEmpty !== false || + (cycle.trustedServerOpportunity !== 'renderable_candidate' && + cycle.trustedServerOpportunity !== 'unrenderable_candidate') + ) { + continue; + } + const deadlineMs = cycle.renderAtMs + TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS; + if (deadlineMs <= nowMs) continue; + if (earliestDeadlineMs === undefined || deadlineMs < earliestDeadlineMs) { + earliestDeadlineMs = deadlineMs; + } + } + } + return earliestDeadlineMs === undefined ? undefined : earliestDeadlineMs - nowMs; + } + + private requestPath(intent: PendingRequestIntent | undefined): GptDiagnosticsRequestPath { + if (!intent) return 'unattributed'; + if (intent.sources.size > 1) return 'competing'; + const source = intent.sources.keys().next().value as RequestIntentSource | undefined; + return source ?? 'unattributed'; + } + + private recordReplacement(record: MutableSlotRecord, cycle: MutableRequestCycle): void { + const currentIndex = record.requests.indexOf(cycle); + if (currentIndex <= 0) return; + const previous = record.requests + .slice(0, currentIndex) + .reverse() + .find((candidate) => candidate.isEmpty === false && candidate.renderAtMs !== undefined); + if (!previous) return; + cycle.replacedRequestNumber = previous.requestNumber; + cycle.previousRenderToRequestMs = validDuration(previous.renderAtMs, cycle.requestedAtMs); + const previousCreativeId = + previous.adManager?.creativeId ?? previous.adManager?.sourceAgnosticCreativeId; + const currentCreativeId = + cycle.adManager?.creativeId ?? cycle.adManager?.sourceAgnosticCreativeId; + if (previousCreativeId !== undefined) cycle.previousCreativeId = previousCreativeId; + if (previousCreativeId !== undefined && currentCreativeId !== undefined) { + cycle.creativeChanged = previousCreativeId !== currentCreativeId; + } + } + private timestamp(observedAtMs?: number): number { this.gptObserved = true; return typeof observedAtMs === 'number' && Number.isFinite(observedAtMs) diff --git a/crates/trusted-server-js/lib/src/integrations/permutive/module.ts b/crates/trusted-server-js/lib/src/integrations/permutive/module.ts index 589e5356d..db4f5dffd 100644 --- a/crates/trusted-server-js/lib/src/integrations/permutive/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/permutive/module.ts @@ -7,7 +7,11 @@ import type { IntegrationLifecycleRuntime } from '../../kernel/lifecycle_module' import { isEmptyIntegrationConfigV1 } from '../../shared/integration_config_validators'; import type { RuntimeCapabilityV1 } from '../../kernel/runtime'; import { log } from '../../core/log'; -import { validatePersistentFirstDisplaySliceAdoptionV1 } from '../../shared/takeover'; +import { + snapshotPersistentFirstDisplaySliceStateV1, + validatePersistentFirstDisplaySliceAdoptionV1, + type PersistentFirstDisplaySliceStateV1, +} from '../../shared/takeover'; import { installPermutiveGuard, resetGuardState } from './script_guard'; import { getPermutiveSegments } from './segments'; @@ -73,6 +77,54 @@ function snapshotSegments(candidate: readonly string[]): readonly string[] { return Object.freeze(segments); } +function snapshotAdoptedSegments( + state: Readonly +): readonly string[] | undefined { + const row = state.values.find(([key]) => key === 'segments'); + if (!row || typeof row[1] !== 'string' || row[1].length > 4_096) return undefined; + try { + const parsed = JSON.parse(row[1]); + if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > 100) return undefined; + const segments = snapshotSegments(parsed); + if ( + segments.length !== parsed.length || + segments.some((segment) => segment.length === 0 || segment.length > 256) || + JSON.stringify(segments) !== row[1] + ) { + return undefined; + } + return segments; + } catch { + return undefined; + } +} + +function validatePermutiveAdoptionState( + state: Readonly +): boolean { + if (state.values.length > 2) return false; + let hasSegments = false; + let hasReadiness = false; + for (const [key, value] of state.values) { + if (key === 'segments') { + if (hasSegments || !snapshotAdoptedSegments(state)) return false; + hasSegments = true; + continue; + } + if ( + hasReadiness || + !( + (key === 'sdk_config' && typeof value === 'string' && value.length > 0) || + (key === 'readiness_timeout' && value === 50) + ) + ) { + return false; + } + hasReadiness = true; + } + return true; +} + /** Own the Permutive guard, auction context, SDK readiness timer, and rewritten config. */ export function createPermutiveRuntime( overrides: Partial = {} @@ -232,6 +284,7 @@ export function createPermutiveIntegrationRegistration(release: string): Integra resetGuard: () => undefined, }); let takeoverActive = false; + let adoptedSegments: readonly string[] | undefined; let releaseContext: (() => void) | undefined; let lifecycleRelease: (() => void) | undefined; const capability: PermutiveContextCapabilityV1 = Object.freeze({ @@ -270,23 +323,24 @@ export function createPermutiveIntegrationRegistration(release: string): Integra if (takeoverActive) throw new Error('Permutive context is already active'); if ( adoption !== undefined && - !validatePersistentFirstDisplaySliceAdoptionV1(adoption, 'permutive_initial', (state) => { - const row = state.values[0]; - return ( - state.values.length === 0 || - (state.values.length === 1 && - (row?.[0] === 'sdk_config' - ? typeof row[1] === 'string' && row[1].length > 0 - : row?.[0] === 'readiness_timeout' && row[1] === 50)) - ); - }) + !validatePersistentFirstDisplaySliceAdoptionV1( + adoption, + 'permutive_initial', + validatePermutiveAdoptionState + ) ) { throw new TypeError('Permutive first-display parser state is invalid'); } + const adoptedState = snapshotPersistentFirstDisplaySliceStateV1( + adoption, + 'permutive_initial' + ); + adoptedSegments = adoptedState ? snapshotAdoptedSegments(adoptedState) : undefined; try { installPermutiveGuard(); releaseContext = runtime.registerAuctionContext(PERMUTIVE_INTEGRATION_ID, () => { - const segments = snapshotSegments(getPermutiveSegments()); + const segments = adoptedSegments ?? snapshotSegments(getPermutiveSegments()); + adoptedSegments = undefined; return segments.length === 0 ? undefined : Object.freeze({ permutive_segments: segments }); @@ -300,6 +354,7 @@ export function createPermutiveIntegrationRegistration(release: string): Integra takeoverActive = true; onActivationDispose(() => { takeoverActive = false; + adoptedSegments = undefined; const release = releaseContext; releaseContext = undefined; release?.(); 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 76ef0020d..7baba131f 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/module.ts @@ -1,12 +1,5 @@ -import { - isRendererReservationIdV1, - ownDataObject, - validBoundedString, - validDimension, -} from '../../core/contracts/auction_projection'; import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; import { - PrebidAdmissionContractError, createBrowserPrebidAdapter, type PrebidAdapter, type PrebidEventFacade, @@ -22,18 +15,7 @@ import { persistentFirstDisplaySliceSelectedV1, snapshotPersistentFirstDisplaySliceStateV1, } from '../../shared/takeover'; -import type { - AuctionBatchScope, - NavigationSession, - RenderAttemptScope, -} from '../../kernel/sessions'; -import type { - RenderAttempt, - RenderAttemptCreationResult, - RenderScheduler, -} from '../../services/render'; -import type { ReservationService } from '../../services/reservations'; -import type { PucGamAttemptInput } from '../../services/puc_bridge'; +import type { NavigationSession } from '../../kernel/sessions'; import type { RuntimeCapabilityV1 } from '../../kernel/runtime'; import { isPrebidIntegrationConfigV1 } from '../../shared/integration_config_validators'; @@ -44,20 +26,59 @@ export type { PreparedTrustedBidV1 } from '../../adapters/prebid'; const arrayIsArrayIntrinsic = Array.isArray; const objectGetOwnPropertyDescriptorIntrinsic = Object.getOwnPropertyDescriptor; -const objectIsFrozenIntrinsic = Object.isFrozen; - export interface PrebidCapabilityV1 { readonly adapter: PrebidAdapter; readonly clientSideBidders: readonly string[]; readonly excludedGamAdUnitPathSuffixes: readonly string[]; } +interface ProductionPrebidSelectionCoordinator { + readonly track: ( + preparedBid: Readonly, + navigation: NavigationSession + ) => boolean; + readonly auctionEnded: (event: unknown, prebid: Readonly) => void; + readonly settlePublicationFailure: ( + navigation: NavigationSession, + auctionId: string, + adUnitCode: string, + reason: 'prebid_admission_failed' | 'prebid_contract_violation' + ) => boolean; + readonly dispose: () => void; +} + +interface ProductionPrebidPublicationInput { + readonly admitTrustedBid: (preparedBid: Readonly) => unknown; + readonly auctionId: string; + readonly adUnitCode: string; + readonly bid: BrowserAuctionBidV1; + readonly generatedBid: unknown; + readonly trackAdmittedBid: ( + preparedBid: Readonly, + navigation: NavigationSession + ) => boolean; +} + +type ProductionPrebidPublicationResult = + | Readonly<{ ok: true; bid: Readonly }> + | Readonly<{ + ok: false; + reason: + | 'descriptor_invalid' + | 'prebid_admission_failed' + | 'prebid_contract_violation' + | 'registry_full' + | 'reservation_collision' + | 'winner_not_renderable'; + }>; + interface ProductionRenderCapability { - readonly createAttempt: (owner: RenderAttemptScope) => RenderAttemptCreationResult; + readonly createPrebidSelectionCoordinator: () => ProductionPrebidSelectionCoordinator; readonly navigation: NavigationSession; + readonly publishPrebidBid: ( + input: ProductionPrebidPublicationInput + ) => ProductionPrebidPublicationResult; readonly projection: Readonly; - readonly registerPucGamAttempt: (input: PucGamAttemptInput) => boolean; - readonly reservations: ReservationService; } function configStringArray(config: unknown, key: string): readonly string[] { @@ -99,9 +120,8 @@ export function createPrebidIntegrationRegistration(releaseId: string): Integrat !runtimeWindow || typeof render.navigation?.isCurrent !== 'function' || !Object.isFrozen(render.projection) || - typeof render.createAttempt !== 'function' || - typeof render.registerPucGamAttempt !== 'function' || - typeof render.reservations?.registerPrebidLease !== 'function' + typeof render.createPrebidSelectionCoordinator !== 'function' || + typeof render.publishPrebidBid !== 'function' ) { throw new TypeError('Prebid capability graph is malformed'); } @@ -116,25 +136,7 @@ export function createPrebidIntegrationRegistration(releaseId: string): Integrat ); let active = false; let runtimeRelease: (() => void) | undefined; - const coordinator = createPrebidSelectionCoordinator({ - activateAttempt: ({ attempt, owner, preparedBid }): boolean => - render.registerPucGamAttempt( - Object.freeze({ - artifact: Object.freeze({ - kind: 'puc' as const, - attemptId: attempt.id, - slot: attempt.slot, - navigationGeneration: attempt.navigationGeneration, - dispose: () => undefined, - }), - attempt, - owner, - reservationId: preparedBid.bid.adId, - }) - ), - createAttempt: render.createAttempt, - reservations: render.reservations, - }); + const coordinator = render.createPrebidSelectionCoordinator(); const completeAuction = (auctionRequest: Readonly): void => { try { auctionRequest.complete(); @@ -168,7 +170,7 @@ export function createPrebidIntegrationRegistration(releaseId: string): Integrat if (bids.length !== 1) continue; const bid = bids[0]; if (!bid) continue; - const publication = publishPrebidBid({ + const publication = render.publishPrebidBid({ admitTrustedBid: adapter.admitTrustedBid, auctionId: auctionRequest.auctionId, adUnitCode: request.adUnitCode, @@ -180,8 +182,6 @@ export function createPrebidIntegrationRegistration(releaseId: string): Integrat width: bid.renderSource.width, height: bid.renderSource.height, }), - navigation, - reservations: render.reservations, trackAdmittedBid: coordinator.track, }); if ( @@ -273,670 +273,3 @@ export type { PrebidSyntheticRefreshRunner, PrebidSyntheticRefreshRunnerOptions, } from './refresh'; - -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 }>; - -export type PrebidPublicationLifecycleFailureReason = Extract< - PrebidBidPublicationFailureReason, - 'prebid_admission_failed' | 'prebid_contract_violation' ->; - -type PrebidPublicationNavigation = NavigationSession; - -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; - readonly trackAdmittedBid: ( - preparedBid: Readonly, - navigation: PrebidPublicationNavigation - ) => boolean; -} - -type OwnedBrowserAuctionBidV1 = Extract< - BrowserAuctionBidV1, - { readonly rendererReservationId: string } ->; - -function isOwnedBrowserAuctionBidV1(bid: BrowserAuctionBidV1): bid is OwnedBrowserAuctionBidV1 { - return 'rendererReservationId' in bid; -} - -type OwnedPrebidBidPublicationInput = PrebidBidPublicationInput & { - readonly bid: OwnedBrowserAuctionBidV1; -}; - -function isCurrentProjectedWinner( - input: PrebidBidPublicationInput -): input is OwnedPrebidBidPublicationInput { - const bid = input.bid; - if (!isOwnedBrowserAuctionBidV1(bid)) return false; - try { - const projection = input.navigation.currentAuctionProjection as - Readonly | undefined; - if ( - !projection || - !objectIsFrozenIntrinsic(projection) || - !objectIsFrozenIntrinsic(projection.auction) || - !objectIsFrozenIntrinsic(projection.auction.results) || - !objectIsFrozenIntrinsic(projection.bids) || - !objectIsFrozenIntrinsic(bid) || - !objectIsFrozenIntrinsic(bid.targeting) || - !objectIsFrozenIntrinsic(bid.renderSource) || - !input.navigation.isCurrent() || - input.auctionId !== projection.auction.auctionId || - input.adUnitCode !== bid.slot || - !isRendererReservationIdV1(bid.rendererReservationId) - ) { - return false; - } - - let bidMatches = 0; - for (let index = 0; index < projection.bids.length; index += 1) { - if (projection.bids[index] === 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 === bid.slot && - result.candidateId === bid.candidateId - ) { - winnerMatches += 1; - } - } - return winnerMatches === 1; - } catch { - return false; - } -} - -function prepareTrustedBid( - input: OwnedPrebidBidPublicationInput -): 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 (input.bid.renderSource.type === 'pbs_cache') { - return Object.freeze({ ok: false, reason: 'winner_not_renderable' }); - } - 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 (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'; - } - - 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', - }); -} - -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 settlePublicationFailure: ( - navigation: NavigationSession, - auctionId: string, - adUnitCode: string, - reason: PrebidPublicationLifecycleFailureReason - ) => boolean; - 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 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); - 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; - } - 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); - 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; - } - 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; - } - 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'); - 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, - settlePublicationFailure, - 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/src/integrations/render_runtime/module.ts b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts index fe58751d7..16318f267 100644 --- a/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts +++ b/crates/trusted-server-js/lib/src/integrations/render_runtime/module.ts @@ -75,6 +75,13 @@ import { import type { SlotRecord, SlotService } from '../../services/slots'; import { trustedDocumentHttpOrigin } from '../../shared/origin'; +import { + createPrebidSelectionCoordinator, + publishPrebidBid, + type PrebidBidPublicationInput, + type PrebidSelectionCoordinator, +} from './prebid_selection'; + interface AcceptedBoot { readonly auctionProjection: unknown; readonly diagnostics: Readonly<{ @@ -1117,6 +1124,36 @@ export function createRenderRuntimeIntegrationRegistration( bootstrapNonces, hostPositions, createAttempt, + createPrebidSelectionCoordinator: (): PrebidSelectionCoordinator => + createPrebidSelectionCoordinator({ + activateAttempt: ({ attempt, owner, preparedBid }): boolean => { + if (!active) return false; + const registrar = pucGamAttemptRegistrar; + if (!registrar) return false; + try { + return ( + registrar( + Object.freeze({ + artifact: Object.freeze({ + kind: 'puc' as const, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: attempt.navigationGeneration, + dispose: () => undefined, + }), + attempt, + owner, + reservationId: preparedBid.bid.adId, + }) + ) === true + ); + } catch { + return false; + } + }, + createAttempt, + reservations, + }), createSlotOperation, commitPageBids: ( owner: NavigationSession, @@ -1136,6 +1173,8 @@ export function createRenderRuntimeIntegrationRegistration( rendererNonces, reservations, publisherOrigin: origin, + publishPrebidBid: (input: Omit) => + publishPrebidBid({ ...input, navigation, reservations }), registerRenderer: (type: RegisteredRenderSource, renderer: RegisteredRenderer) => { if (!active) throw new TypeError('render source provider is inactive'); if (type !== 'aps' || typeof renderer !== 'function' || registeredRenderers.has(type)) { diff --git a/crates/trusted-server-js/lib/src/integrations/render_runtime/prebid_selection.ts b/crates/trusted-server-js/lib/src/integrations/render_runtime/prebid_selection.ts new file mode 100644 index 000000000..99f8c03ab --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/render_runtime/prebid_selection.ts @@ -0,0 +1,726 @@ +import { + isRendererReservationIdV1, + ownDataObject, + validBoundedString, + validDimension, +} from '../../core/contracts/auction_projection'; +import type { BrowserAuctionBidV1, BrowserAuctionProjectionV1 } from '../../core/types'; +import type { + AuctionBatchScope, + NavigationSession, + RenderAttemptScope, +} from '../../kernel/sessions'; +import type { + RenderAttempt, + RenderAttemptCreationResult, + RenderScheduler, +} from '../../services/render'; +import type { ReservationService } from '../../services/reservations'; + +/** Capability-free trusted bid used by the render-owned Prebid selection service. */ +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; + }>; + }>; +} + +/** Read-only Prebid query surface used only during an auction-end callback. */ +export interface PrebidEventFacade { + highestBids(adUnitCode?: string): readonly object[]; +} + +const objectIsFrozenIntrinsic = Object.isFrozen; + +function isPrebidAdmissionContractError(candidate: unknown): boolean { + try { + return ( + candidate instanceof Error && Reflect.get(candidate, 'code') === 'prebid_partial_publication' + ); + } catch { + return false; + } +} + +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 }>; + +export type PrebidPublicationLifecycleFailureReason = Extract< + PrebidBidPublicationFailureReason, + 'prebid_admission_failed' | 'prebid_contract_violation' +>; + +type PrebidPublicationNavigation = NavigationSession; + +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; + readonly trackAdmittedBid: ( + preparedBid: Readonly, + navigation: PrebidPublicationNavigation + ) => boolean; +} + +type OwnedBrowserAuctionBidV1 = Extract< + BrowserAuctionBidV1, + { readonly rendererReservationId: string } +>; + +function isOwnedBrowserAuctionBidV1(bid: BrowserAuctionBidV1): bid is OwnedBrowserAuctionBidV1 { + return 'rendererReservationId' in bid; +} + +type OwnedPrebidBidPublicationInput = PrebidBidPublicationInput & { + readonly bid: OwnedBrowserAuctionBidV1; +}; + +function isCurrentProjectedWinner( + input: PrebidBidPublicationInput +): input is OwnedPrebidBidPublicationInput { + const bid = input.bid; + if (!isOwnedBrowserAuctionBidV1(bid)) return false; + try { + const projection = input.navigation.currentAuctionProjection as + Readonly | undefined; + if ( + !projection || + !objectIsFrozenIntrinsic(projection) || + !objectIsFrozenIntrinsic(projection.auction) || + !objectIsFrozenIntrinsic(projection.auction.results) || + !objectIsFrozenIntrinsic(projection.bids) || + !objectIsFrozenIntrinsic(bid) || + !objectIsFrozenIntrinsic(bid.targeting) || + !objectIsFrozenIntrinsic(bid.renderSource) || + !input.navigation.isCurrent() || + input.auctionId !== projection.auction.auctionId || + input.adUnitCode !== bid.slot || + !isRendererReservationIdV1(bid.rendererReservationId) + ) { + return false; + } + + let bidMatches = 0; + for (let index = 0; index < projection.bids.length; index += 1) { + if (projection.bids[index] === 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 === bid.slot && + result.candidateId === bid.candidateId + ) { + winnerMatches += 1; + } + } + return winnerMatches === 1; + } catch { + return false; + } +} + +function prepareTrustedBid( + input: OwnedPrebidBidPublicationInput +): 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 (input.bid.renderSource.type === 'pbs_cache') { + return Object.freeze({ ok: false, reason: 'winner_not_renderable' }); + } + 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 (error) { + failure = isPrebidAdmissionContractError(error) + ? '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'; + } + + 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', + }); +} + +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 settlePublicationFailure: ( + navigation: NavigationSession, + auctionId: string, + adUnitCode: string, + reason: PrebidPublicationLifecycleFailureReason + ) => boolean; + 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 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); + 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; + } + 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); + 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; + } + 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; + } + 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'); + 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, + settlePublicationFailure, + 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/src/kernel/integration_registry.ts b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts index fbb071d9c..14e221920 100644 --- a/crates/trusted-server-js/lib/src/kernel/integration_registry.ts +++ b/crates/trusted-server-js/lib/src/kernel/integration_registry.ts @@ -137,6 +137,7 @@ export interface IntegrationRegistryOptions { readonly takeoverScript?: HTMLScriptElement; readonly takeoverScriptId?: 'trustedserver-js' | 'trustedserver-js-runtime'; readonly document?: Document; + readonly currentScript?: () => HTMLScriptElement | null; readonly startedAtMs: number; readonly now?: () => number; readonly signal?: AbortSignal; @@ -622,6 +623,7 @@ class IntegrationRegistryOwner { private readonly catalog: readonly IntegrationCatalogEntry[]; private readonly takeoverScript: HTMLScriptElement | undefined; private readonly takeoverScriptId: 'trustedserver-js' | 'trustedserver-js-runtime'; + private readonly currentScript: (() => HTMLScriptElement | null) | undefined; private readonly document: Document | undefined; private readonly registrations = new Map(); private readonly capabilities = new Map>>(); @@ -655,6 +657,7 @@ class IntegrationRegistryOwner { this.releaseId = options.releaseId; this.takeoverScript = options.takeoverScript; this.takeoverScriptId = options.takeoverScriptId ?? 'trustedserver-js'; + this.currentScript = options.currentScript; this.document = options.document; this.startedAtMs = options.startedAtMs; this.now = options.now ?? (() => performance.now()); @@ -817,7 +820,9 @@ class IntegrationRegistryOwner { script.isConnected && matches.length === 1 && matches[0] === script && - document.currentScript === script && + (this.currentScript + ? this.currentScript() === script + : document.currentScript === script) && expected.origin === origin && expected.hash === '' && `${expected.pathname}${expected.search}` === manifest.runtimeSrc && diff --git a/crates/trusted-server-js/lib/src/kernel/phase_loader.ts b/crates/trusted-server-js/lib/src/kernel/phase_loader.ts index 45c63bdcb..6bf8b7846 100644 --- a/crates/trusted-server-js/lib/src/kernel/phase_loader.ts +++ b/crates/trusted-server-js/lib/src/kernel/phase_loader.ts @@ -222,6 +222,8 @@ export interface DeferredPhaseLoader { export interface DeferredPhaseLoaderOptions { readonly runtimeScript: HTMLScriptElement; + /** Bootstrap-captured native current-script observation for registration authentication. */ + readonly currentScript?: () => HTMLScriptElement | null; readonly document: Document; readonly prepare: ( registration: DeferredIntegrationRegistration, @@ -234,6 +236,7 @@ export interface DeferredPhaseLoaderOptions { readonly manifest: BootManifestV1; readonly releaseId: string; readonly scheduler?: Pick; + readonly trustedScriptUrl?: (value: string) => unknown; } interface DeferredTransaction { @@ -310,6 +313,16 @@ export function createDeferredPhaseLoader( let disposed = false; let privatePolicy: Readonly<{ createScriptURL: (value: string) => unknown }> | null | undefined; const exactUrls = new Set(transactions.map(({ expectedUrl }) => expectedUrl)); + const trustedCurrentScript = (): HTMLScriptElement | null => { + try { + if (options.currentScript) return options.currentScript(); + const candidate = options.document.currentScript; + const Script = options.document.defaultView?.HTMLScriptElement; + return Script && candidate instanceof Script ? candidate : null; + } catch { + return null; + } + }; const removeNode = (transaction: DeferredTransaction): void => { const script = transaction.script; @@ -461,6 +474,10 @@ export function createDeferredPhaseLoader( if (privatePolicy !== undefined) return privatePolicy; privatePolicy = null; try { + if (options.trustedScriptUrl) { + privatePolicy = Object.freeze({ createScriptURL: options.trustedScriptUrl }); + return privatePolicy; + } const trustedTypes = options.document.defaultView as | (Window & { trustedTypes?: { @@ -545,19 +562,14 @@ export function createDeferredPhaseLoader( return false; } const transaction = byId.get(registration.id); - if (!transaction || transaction.state !== 'loading' || transaction.registration) { - if (transaction) settleUnavailable(transaction, 'registration_rejected'); + if (!transaction) return false; + const currentScript = trustedCurrentScript(); + if (currentScript !== transaction.script) return false; + if (transaction.state !== 'loading' || transaction.registration) { + settleUnavailable(transaction, 'registration_rejected'); return false; } - const currentScript = options.document.currentScript; - const Script = options.document.defaultView?.HTMLScriptElement; - if ( - !Script || - !(currentScript instanceof Script) || - currentScript !== transaction.script || - !currentScript.isConnected || - currentScript.src !== transaction.expectedUrl - ) { + if (!currentScript.isConnected || currentScript.src !== transaction.expectedUrl) { settleUnavailable(transaction, 'registration_rejected'); return false; } diff --git a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts index aeb6db7d4..3b9c885e0 100644 --- a/crates/trusted-server-js/lib/src/kernel/release_catalog.ts +++ b/crates/trusted-server-js/lib/src/kernel/release_catalog.ts @@ -179,9 +179,20 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object id: 'creative_initial', include: 'creative_guard', allowedImports: [ + 'core/log', + 'shared/async', 'shared/first_display_contracts', + 'shared/globals', + 'shared/origin', + 'shared/scheduler', 'first_display/registration_client', 'first_display/leaf/creative_guard', + 'integrations/creative/click', + 'integrations/creative/dynamic_src_guard', + 'integrations/creative/iframe', + 'integrations/creative/image', + 'integrations/creative/proxy_sign', + 'integrations/creative/startup', ], inputs: ['first_display.control.v1'], outputs: ['creative.initial.v1'], @@ -194,7 +205,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/leaf/browser_route_owner', 'first_display/leaf/route_guard', ], inputs: ['first_display.control.v1'], @@ -221,7 +231,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/leaf/browser_route_owner', 'first_display/leaf/route_guard', ], inputs: ['first_display.control.v1'], @@ -249,7 +258,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/leaf/browser_route_owner', 'first_display/leaf/route_guard', ], inputs: ['first_display.control.v1'], @@ -276,7 +284,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/leaf/browser_route_owner', 'first_display/leaf/context_snapshot', ], inputs: ['first_display.control.v1'], @@ -290,7 +297,6 @@ export const FIRST_DISPLAY_CATALOG: readonly FirstDisplayCatalogEntry[] = Object allowedImports: [ 'shared/first_display_contracts', 'first_display/registration_client', - 'first_display/leaf/browser_route_owner', 'first_display/leaf/consent_snapshot', ], inputs: ['first_display.control.v1'], diff --git a/crates/trusted-server-js/lib/src/kernel/runtime.ts b/crates/trusted-server-js/lib/src/kernel/runtime.ts index 0b1ba5a5f..ccd781e53 100644 --- a/crates/trusted-server-js/lib/src/kernel/runtime.ts +++ b/crates/trusted-server-js/lib/src/kernel/runtime.ts @@ -110,6 +110,8 @@ export interface RuntimeOptions { readonly catalog?: readonly IntegrationCatalogEntry[]; readonly document?: Document; readonly phaseScheduler?: PhaseScheduler; + readonly trustedScriptUrl?: (value: string) => unknown; + readonly currentScript?: () => HTMLScriptElement | null; readonly boot?: unknown; readonly now?: () => number; readonly getBindings?: (id: string) => IntegrationBindings; @@ -284,7 +286,9 @@ class RuntimeOwner implements Runtime { if (!canClaimRuntimeTarget(this.options.target)) return false; const runtimeDocument = this.runtimeDocument(); const Script = runtimeDocument?.defaultView?.HTMLScriptElement; - const currentScript = runtimeDocument?.currentScript; + const currentScript = this.options.currentScript + ? this.options.currentScript() + : runtimeDocument?.currentScript; this.selectedScript = Script && currentScript instanceof Script ? currentScript : undefined; const takeoverMode = this.options.coordinateTakeover !== undefined; const capturedRuntimeSrc = @@ -420,6 +424,7 @@ class RuntimeOwner implements Runtime { ? ('trustedserver-js-runtime' as const) : ('trustedserver-js' as const), document: runtimeDocument, + ...(this.options.currentScript ? { currentScript: this.options.currentScript } : {}), } : {}), startedAtMs, @@ -740,6 +745,7 @@ class RuntimeOwner implements Runtime { if (runtimeScript) { this.phaseLoader = createDeferredPhaseLoader({ runtimeScript, + ...(this.options.currentScript ? { currentScript: this.options.currentScript } : {}), document: runtimeDocument, gate: gate.ready, manifest, @@ -748,6 +754,9 @@ class RuntimeOwner implements Runtime { activate: () => undefined, }, releaseId: EMBEDDED_RELEASE_ID, + ...(this.options.trustedScriptUrl + ? { trustedScriptUrl: this.options.trustedScriptUrl } + : {}), ...(this.options.phaseScheduler ? { scheduler: { diff --git a/crates/trusted-server-js/lib/src/services/slots.ts b/crates/trusted-server-js/lib/src/services/slots.ts index 63e23e66c..a3cf0407b 100644 --- a/crates/trusted-server-js/lib/src/services/slots.ts +++ b/crates/trusted-server-js/lib/src/services/slots.ts @@ -120,11 +120,17 @@ export interface SlotRequestInput { readonly operation: 'display' | 'refresh'; readonly registeredSlotId: string; readonly requestClass: string; + /** Immutable auction evidence captured by the exact request-producing operation. */ + readonly trustedServerOpportunity?: TrustedServerRequestOpportunity; } -/** Refresh-only input accepted by one shared GPT SRA operation. */ -export type SlotBatchRequestInput = Omit & - Readonly<{ operation: 'refresh' }>; +export interface TrustedServerRequestOpportunity { + readonly requestedSlotSizes: readonly (readonly [number, number])[]; + readonly trustedServerAuctionId: string; +} + +/** Input admitted atomically into one shared GPT SRA operation. */ +export type SlotBatchRequestInput = SlotRequestInput; export interface SlotRequestHandle { readonly status: 'active' | 'queued' | 'terminal'; @@ -243,6 +249,13 @@ export interface SlotServiceOptions { ) => void; readonly googletag: GoogletagAdapter; readonly now?: () => number; + readonly onTrustedServerRequest?: ( + input: Readonly<{ + opportunity?: TrustedServerRequestOpportunity; + registeredSlotId: string; + slot: object; + }> + ) => void; readonly reconciliation?: SlotReconciliationBoundary; readonly warnPublisherHandoffMismatch?: ( message: string, @@ -552,6 +565,51 @@ function ownData(event: unknown, key: PropertyKey): unknown { } } +function snapshotTrustedServerOpportunity( + candidate: unknown +): TrustedServerRequestOpportunity | undefined { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return; + const trustedServerAuctionId = ownData(candidate, 'trustedServerAuctionId'); + const sizes = ownData(candidate, 'requestedSlotSizes'); + if ( + typeof trustedServerAuctionId !== 'string' || + !validSlotIdentity(trustedServerAuctionId) || + !Array.isArray(sizes) || + sizes.length === 0 || + sizes.length > 16 + ) { + return; + } + const requestedSlotSizes: Array = []; + for (let index = 0; index < sizes.length; index += 1) { + const size = sizes[index]; + if ( + !Array.isArray(size) || + size.length !== 2 || + !Number.isInteger(size[0]) || + !Number.isInteger(size[1]) || + size[0] < 1 || + size[0] > 4_096 || + size[1] < 1 || + size[1] > 4_096 + ) { + return; + } + requestedSlotSizes.push(Object.freeze([size[0], size[1]])); + } + if ( + Object.isFrozen(candidate) && + Object.isFrozen(sizes) && + sizes.every((size) => Object.isFrozen(size)) + ) { + return candidate as unknown as TrustedServerRequestOpportunity; + } + return Object.freeze({ + requestedSlotSizes: Object.freeze(requestedSlotSizes), + trustedServerAuctionId, + }); +} + function copyReplacementSizes(sizes: unknown): unknown | undefined { try { if (!Array.isArray(sizes)) return undefined; @@ -1458,13 +1516,31 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return; } const state = gpt.serviceState(); + const publishRequest = (): void => { + try { + options.onTrustedServerRequest?.( + Object.freeze({ + ...(intent.input.trustedServerOpportunity === undefined + ? {} + : { opportunity: intent.input.trustedServerOpportunity }), + registeredSlotId: record.view.registeredSlotId, + slot: physical.slot, + }) + ); + } catch { + // Diagnostics request-intent publication cannot alter the admitted GPT request. + } + }; + if (!state.pubadsReady) gpt.enableServices(); if (intent.input.operation === 'display' && state.initialLoadDisabled) { gpt.display(physical.slot); if (intent.terminal || physical.activeCycle) return; + publishRequest(); armRequestDeadline(intent); gpt.refresh([physical.slot], Object.freeze({ changeCorrelator: false })); return; } + publishRequest(); armRequestDeadline(intent); if (intent.input.operation === 'display') gpt.display(physical.slot); else gpt.refresh([physical.slot], Object.freeze({ changeCorrelator: false })); @@ -2488,10 +2564,20 @@ export function createSlotService(options: SlotServiceOptions): SlotService { reconciliation: undefined, reconciliationSuccesses: 0, } as InternalSlotRecord); + const trustedServerOpportunity = snapshotTrustedServerOpportunity( + input.trustedServerOpportunity + ); + const intentInput: SlotRequestInput = + trustedServerOpportunity === input.trustedServerOpportunity + ? input + : Object.freeze({ + ...input, + ...(trustedServerOpportunity === undefined ? {} : { trustedServerOpportunity }), + }); const intent: RequestIntent = { completionTimer: undefined, completionDeadlineAt: undefined, - input, + input: intentInput, invocation: undefined, requestDeadlineAt: undefined, requestStartedAt: undefined, @@ -2623,16 +2709,20 @@ export function createSlotService(options: SlotServiceOptions): SlotService { return Object.freeze([]); } const intentId = ownData(input, 'intentId'); + const expectedSlot = ownData(input, 'expectedSlot'); const navigationGeneration = ownData(input, 'navigationGeneration'); const operation = ownData(input, 'operation'); const registeredSlotId = ownData(input, 'registeredSlotId'); const requestClass = ownData(input, 'requestClass'); + const trustedServerOpportunity = snapshotTrustedServerOpportunity( + ownData(input, 'trustedServerOpportunity') + ); if ( typeof intentId !== 'string' || intentId.length === 0 || typeof navigationGeneration !== 'object' || navigationGeneration === null || - operation !== 'refresh' || + (operation !== 'display' && operation !== 'refresh') || typeof registeredSlotId !== 'string' || registeredSlotId.length === 0 || typeof requestClass !== 'string' || @@ -2658,6 +2748,7 @@ export function createSlotService(options: SlotServiceOptions): SlotService { physical.state !== 'live' || physical.activeCycle || physical.publisherAdmissions.length > 0 || + (expectedSlot !== undefined && expectedSlot !== physical.slot) || setHasValue(admittedRecords, record) || setHasValue(admittedPhysicalSlots, physical.slot) ) { @@ -2667,11 +2758,13 @@ export function createSlotService(options: SlotServiceOptions): SlotService { addSetValue(admittedRecords, record); addSetValue(admittedPhysicalSlots, physical.slot); preparedInputs[preparedInputs.length] = Object.freeze({ + ...(expectedSlot === undefined ? {} : { expectedSlot }), intentId, navigationGeneration, operation, registeredSlotId, requestClass, + ...(trustedServerOpportunity === undefined ? {} : { trustedServerOpportunity }), }); } } catch { @@ -2777,12 +2870,70 @@ export function createSlotService(options: SlotServiceOptions): SlotService { if (!allIntentsAdmitted()) { throw new Error('SRA admission changed before invocation'); } + const serviceState = gpt.serviceState(); + if (!serviceState.pubadsReady) gpt.enableServices(); + const publishRequest = (intent: RequestIntent): void => { + const physical = intent.record.physical; + if (!physical || intent.terminal) return; + try { + options.onTrustedServerRequest?.( + Object.freeze({ + ...(intent.input.trustedServerOpportunity === undefined + ? {} + : { opportunity: intent.input.trustedServerOpportunity }), + registeredSlotId: intent.record.view.registeredSlotId, + slot: physical.slot, + }) + ); + } catch { + // Diagnostics request publication cannot alter the admitted GPT batch. + } + }; + if (serviceState.initialLoadDisabled) { + for (let index = 0; index < intents.length; index += 1) { + const intent = intents[index]; + const physical = intent?.record.physical; + if ( + !intent || + intent.terminal || + intent.input.operation !== 'display' || + !physical + ) { + continue; + } + gpt.display(physical.slot); + } + } + const refreshSlots: object[] = []; for (let index = 0; index < intents.length; index += 1) { const intent = intents[index]; - if (!intent) continue; - if (!intent.terminal) armRequestDeadline(intent); + const physical = intent?.record.physical; + if (!intent || intent.terminal || !physical || physical.activeCycle) continue; + publishRequest(intent); + armRequestDeadline(intent); + if (serviceState.initialLoadDisabled || intent.input.operation === 'refresh') { + refreshSlots[refreshSlots.length] = physical.slot; + } + } + if (!serviceState.initialLoadDisabled) { + for (let index = 0; index < intents.length; index += 1) { + const intent = intents[index]; + const physical = intent?.record.physical; + if ( + !intent || + intent.terminal || + intent.input.operation !== 'display' || + !physical || + physical.activeCycle + ) { + continue; + } + gpt.display(physical.slot); + } + } + if (refreshSlots.length > 0) { + gpt.refresh(refreshSlots, Object.freeze({ changeCorrelator: false })); } - gpt.refresh(slots, Object.freeze({ changeCorrelator: false })); }); } catch (error) { for (let index = 0; index < intents.length; index += 1) { diff --git a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts index 44a5f7195..7a7ec162c 100644 --- a/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts +++ b/crates/trusted-server-js/lib/src/shared/first_display_contracts.ts @@ -775,6 +775,7 @@ function snapshotGptFact(value: unknown): Readonly | unde 'capturedAtMs', 'elementId', 'adUnitPath', + 'requestedSlotSizes', 'isEmpty', 'renderedSize', 'isBackfill', @@ -814,6 +815,27 @@ function snapshotGptFact(value: unknown): Readonly | unde } return Object.freeze([dimensions[0] as number, dimensions[1] as number] as const); })(); + const requestedSlotSizes = (() => { + if (fields.requestedSlotSizes === null) return null; + const values = exactArray(fields.requestedSlotSizes, 16); + if (!values || values.length === 0) return undefined; + const sizes: Array = []; + for (const value of values) { + const dimensions = exactArray(value, 2); + if ( + !dimensions || + dimensions.length !== 2 || + !isU32(dimensions[0], false) || + !isU32(dimensions[1], false) || + dimensions[0] > 4096 || + dimensions[1] > 4096 + ) { + return undefined; + } + sizes.push(Object.freeze([dimensions[0], dimensions[1]])); + } + return Object.freeze(sizes); + })(); const tokenOrdinal = typeof fields.token === 'string' && /^gt1_[1-9a-z][0-9a-z]{0,6}$/.test(fields.token) ? Number.parseInt(fields.token.slice(4), 36) @@ -831,6 +853,8 @@ function snapshotGptFact(value: unknown): Readonly | unde !finiteNonnegative(fields.capturedAtMs) || (fields.elementId !== null && !boundedString(fields.elementId, 256)) || (fields.adUnitPath !== null && !boundedString(fields.adUnitPath, 256)) || + requestedSlotSizes === undefined || + (requestedSlotSizes !== null && (event !== 'slotRequested' || disposition !== 'matched')) || (fields.isEmpty !== null && typeof fields.isEmpty !== 'boolean') || (fields.renderedSize !== null && !renderedSize) || (fields.isBackfill !== null && typeof fields.isBackfill !== 'boolean') || @@ -865,6 +889,7 @@ function snapshotGptFact(value: unknown): Readonly | unde capturedAtMs: fields.capturedAtMs as number, elementId: fields.elementId as string | null, adUnitPath: fields.adUnitPath as string | null, + requestedSlotSizes, isEmpty: fields.isEmpty as boolean | null, renderedSize: fields.renderedSize === null ? null : renderedSize!, isBackfill: fields.isBackfill as boolean | null, diff --git a/crates/trusted-server-js/lib/src/shared/gpt_diagnostics.ts b/crates/trusted-server-js/lib/src/shared/gpt_diagnostics.ts new file mode 100644 index 000000000..bca9887e0 --- /dev/null +++ b/crates/trusted-server-js/lib/src/shared/gpt_diagnostics.ts @@ -0,0 +1,96 @@ +import type { GptDiagnosticsTrustedServerOpportunity, Size } from '../core/types'; + +/** Opaque GPT slot identity safe to pass through the data-only diagnostics capability. */ +export interface GptDiagnosticsSlotIdentityV1 { + readonly token: object; + readonly traceToken?: string | undefined; + readonly runtimeSlotNumber?: number | undefined; + readonly cycleOrdinal?: number | undefined; + readonly elementId?: string | undefined; + readonly adUnitPath?: string | undefined; +} + +/** Release-private Trusted Server request intent consumed only by GPT diagnostics. */ +export interface GptDiagnosticsOpportunityFact { + readonly kind: 'trustedServerOpportunity'; + readonly auctionSlotId: string; + readonly opportunity: GptDiagnosticsTrustedServerOpportunity; + readonly requestedSlotSizes: readonly Readonly[]; + readonly slot: Readonly; + readonly trustedServerAuctionId?: string | undefined; +} + +const opportunityFacts = new WeakSet(); + +/** Copy one immutable request-intent fact before its exact GPT request starts. */ +export function createTrustedServerOpportunityFact(input: { + readonly auctionSlotId: string; + readonly opportunity: GptDiagnosticsTrustedServerOpportunity; + readonly requestedSlotSizes: readonly Readonly[]; + readonly slot: Readonly; + readonly trustedServerAuctionId?: string | undefined; +}): Readonly | undefined { + try { + if ( + typeof input.auctionSlotId !== 'string' || + input.auctionSlotId.length === 0 || + input.auctionSlotId.length > 256 || + (input.opportunity !== 'renderable_candidate' && + input.opportunity !== 'unrenderable_candidate' && + input.opportunity !== 'no_candidate') || + typeof input.slot !== 'object' || + input.slot === null || + !Object.isFrozen(input.slot) || + !Array.isArray(input.requestedSlotSizes) + ) { + return undefined; + } + const requestedSlotSizes: Readonly[] = []; + for (let index = 0; index < input.requestedSlotSizes.length && index < 16; index += 1) { + const size = input.requestedSlotSizes[index]; + if ( + !Array.isArray(size) || + size.length !== 2 || + !Number.isInteger(size[0]) || + !Number.isInteger(size[1]) || + (size[0] ?? 0) < 1 || + (size[0] ?? 0) > 4_096 || + (size[1] ?? 0) < 1 || + (size[1] ?? 0) > 4_096 + ) { + continue; + } + requestedSlotSizes.push(Object.freeze([size[0], size[1]] as Size)); + } + if (requestedSlotSizes.length === 0) return undefined; + if ( + input.trustedServerAuctionId !== undefined && + (typeof input.trustedServerAuctionId !== 'string' || + input.trustedServerAuctionId.length === 0 || + input.trustedServerAuctionId.length > 256) + ) { + return undefined; + } + const fact = Object.freeze({ + kind: 'trustedServerOpportunity' as const, + auctionSlotId: input.auctionSlotId, + opportunity: input.opportunity, + requestedSlotSizes: Object.freeze(requestedSlotSizes), + slot: input.slot, + ...(input.trustedServerAuctionId === undefined + ? {} + : { trustedServerAuctionId: input.trustedServerAuctionId }), + }); + opportunityFacts.add(fact); + return fact; + } catch { + return undefined; + } +} + +/** Verify that an opportunity fact was minted by this release's private producer. */ +export function isTrustedServerOpportunityFact( + candidate: unknown +): candidate is Readonly { + return typeof candidate === 'object' && candidate !== null && opportunityFacts.has(candidate); +} diff --git a/crates/trusted-server-js/lib/src/shared/takeover.ts b/crates/trusted-server-js/lib/src/shared/takeover.ts index eb5ab4cf1..e5d8fe289 100644 --- a/crates/trusted-server-js/lib/src/shared/takeover.ts +++ b/crates/trusted-server-js/lib/src/shared/takeover.ts @@ -30,6 +30,7 @@ export interface FirstDisplayGptFactV1 { readonly capturedAtMs: number; readonly elementId: string | null; readonly adUnitPath: string | null; + readonly requestedSlotSizes: readonly (readonly [number, number])[] | null; readonly isEmpty: boolean | null; readonly renderedSize: readonly [number, number] | null; readonly isBackfill: boolean | null; @@ -281,9 +282,12 @@ export type ClaimedFirstDisplayTakeoverV1 = readonly [ disposeAgent: () => void, onFailure: (reason: BootFailureReason) => void, onCommit: () => void, + trustedScriptUrl: (value: string) => unknown, + currentScript: () => HTMLScriptElement | null, ]; export type FirstDisplayTakeoverClaim = ( + source: unknown, finalize: FirstDisplayAgentCaptureFinalizerV1 ) => ClaimedFirstDisplayTakeoverV1 | undefined; @@ -303,28 +307,31 @@ export function installFirstDisplayTakeoverTransport( ) { return undefined; } + let live = true; + const installed: FirstDisplayTakeoverClaim = (source, finalize) => { + if (!live) return undefined; + try { + const result = claim(source, finalize); + if (result !== undefined) live = false; + return result; + } catch (error) { + live = false; + throw error; + } + }; try { Object.defineProperty(target, FIRST_DISPLAY_TAKEOVER_FIELD, { - configurable: true, + configurable: false, enumerable: false, - value: claim, + value: installed, writable: false, }); } catch { return undefined; } - let live = true; return (): void => { if (!live) return; live = false; - try { - const descriptor = Object.getOwnPropertyDescriptor(target, FIRST_DISPLAY_TAKEOVER_FIELD); - if (descriptor && 'value' in descriptor && descriptor.value === claim) { - Reflect.deleteProperty(target, FIRST_DISPLAY_TAKEOVER_FIELD); - } - } catch { - // Generation invalidation makes a hostile replacement inert. - } }; } @@ -336,7 +343,7 @@ export function consumeFirstDisplayTakeoverTransport( const descriptor = Object.getOwnPropertyDescriptor(target, FIRST_DISPLAY_TAKEOVER_FIELD); if (!descriptor) return Object.freeze({ status: 'absent' }); if ( - !descriptor.configurable || + descriptor.configurable || descriptor.enumerable || descriptor.writable || !('value' in descriptor) || @@ -344,9 +351,6 @@ export function consumeFirstDisplayTakeoverTransport( ) { return Object.freeze({ status: 'invalid' }); } - if (!Reflect.deleteProperty(target, FIRST_DISPLAY_TAKEOVER_FIELD)) { - return Object.freeze({ status: 'invalid' }); - } return Object.freeze({ status: 'accepted', claim: descriptor.value as FirstDisplayTakeoverClaim, 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 9e0cac656..75a1437bf 100644 --- a/crates/trusted-server-js/lib/test/adapters/googletag.test.ts +++ b/crates/trusted-server-js/lib/test/adapters/googletag.test.ts @@ -9,7 +9,11 @@ import { type Command = () => void; function createReadyGoogletag( - options: { readonly deferCommands?: boolean; readonly initialLoadDisabled?: boolean } = {} + options: { + readonly deferCommands?: boolean; + readonly initialLoadDisabled?: boolean; + readonly servicesEnabled?: boolean; + } = {} ) { const commands: Command[] = []; const display = vi.fn(); @@ -25,6 +29,7 @@ function createReadyGoogletag( initialLoad.disabled = true; return 'legacy-result'; }), + enableSingleRequest: vi.fn(() => true), getSlots: vi.fn<() => object[]>(() => []), refresh: vi.fn(), removeEventListener: vi.fn((type: string, listener: (event: unknown) => void) => { @@ -33,7 +38,7 @@ function createReadyGoogletag( }; const googletag = { apiReady: true, - pubadsReady: true, + pubadsReady: options.servicesEnabled !== false, cmd: { push: vi.fn((command: Command): number => { if (options.deferCommands) commands.push(command); @@ -44,6 +49,9 @@ function createReadyGoogletag( defineSlot: vi.fn(), destroySlots: vi.fn(), display, + enableServices: vi.fn(() => { + googletag.pubadsReady = true; + }), getConfig: vi.fn((key: string) => key === 'disableInitialLoad' ? { disableInitialLoad: initialLoad.disabled } : {} ), @@ -2842,6 +2850,25 @@ describe('browser googletag adapter readiness', () => { expect(targeting.has('hb_adid')).toBe(false); }); + it('enables SRA before GPT services and does not reconfigure an enabled publisher service', async () => { + const disabled = createReadyGoogletag({ servicesEnabled: false }); + const order: string[] = []; + disabled.pubads.enableSingleRequest.mockImplementation(() => { + order.push('sra'); + return true; + }); + disabled.googletag.enableServices.mockImplementation(() => { + order.push('services'); + disabled.googletag.pubadsReady = true; + }); + const adapter = createBrowserGoogletagAdapter({ googletag: disabled.googletag }); + + await expect(adapter.run((gpt) => gpt.enableServices()).result).resolves.toBeUndefined(); + await expect(adapter.run((gpt) => gpt.enableServices()).result).resolves.toBeUndefined(); + + expect(order).toEqual(['sra', 'services']); + }); + it('exposes one reversible publisher-call observer without changing ordinary calls', () => { const ready = createReadyGoogletag(); const nativeDisplay = ready.googletag.display; diff --git a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs index a8ff3456f..88adc2934 100644 --- a/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs +++ b/crates/trusted-server-js/lib/test/build/generated-fallback.test.mjs @@ -14,11 +14,15 @@ const artifactById = new Map(manifest.artifacts.map((artifact) => [artifact.id, const selectedSrc = `/static/tsjs=tsjs-first-display.min.js?m=0001&v=${'c'.repeat(64)}`; const selectedGptSrc = `/static/tsjs=tsjs-first-display.min.js?m=0081&v=${'c'.repeat(64)}`; const runtimeSrc = `/static/tsjs=tsjs-unified.min.js?v=${'d'.repeat(64)}`; +const runtimeBody = ['core', 'render_runtime'] + .map((id) => readFileSync(path.join(dist, artifactById.get(id).file), 'utf8')) + .join(';\n'); const EMPTY_INTEGRATION_CONFIGS = Object.freeze({ version: 1, entries: Object.freeze([]) }); const EMPTY_INTEGRATION_CONFIG_DIGEST = createHash('sha256') .update(JSON.stringify(EMPTY_INTEGRATION_CONFIGS)) .digest('hex'); const plain = (value) => JSON.parse(JSON.stringify(value)); +const setCurrentScriptByDom = new WeakMap(); function createDocument(firstDisplaySrc = selectedSrc) { const dom = new JSDOM( @@ -40,11 +44,22 @@ function createDocument(firstDisplaySrc = selectedSrc) { }, }); const selected = dom.window.document.querySelector('script#trustedserver-js'); - Object.defineProperty(dom.window.document, 'currentScript', { + let currentScript = selected; + Object.defineProperty(dom.window.Document.prototype, 'currentScript', { configurable: true, - value: selected, + get: () => currentScript, }); - return { animationFrames, dom, selected }; + setCurrentScriptByDom.set(dom, (script) => { + currentScript = script; + }); + return { + animationFrames, + dom, + selected, + setCurrentScript: (script) => { + currentScript = script; + }, + }; } function boot() { @@ -144,6 +159,7 @@ function evaluateTransport(dom, value) { dom.window.eval( `const __TSJS_SERVER_BOOT_TRANSPORT_V1__=${JSON.stringify(JSON.stringify(value))};${source}` ); + setCurrentScriptByDom.get(dom)?.(null); } function evaluateWithInput(dom) { @@ -164,6 +180,35 @@ test('generated bootstrap bytes are stamped exactly once and expose no callable dom.window.close(); }); +test('generated bootstrap seals the TSJS namespace handoff without mutating currentScript', () => { + const { dom, selected, setCurrentScript } = createDocument(); + const target = {}; + dom.window.tsjs = target; + selected.src = runtimeSrc; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + directBoot.manifest.integrations = [{ id: 'render_runtime', phase: 'takeover' }]; + + evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(null); + + assert.equal(Object.getOwnPropertyDescriptor(dom.window.document, 'currentScript'), undefined); + const descriptor = Object.getOwnPropertyDescriptor(dom.window, 'tsjs'); + assert.equal(descriptor?.configurable, false); + assert.equal(descriptor?.enumerable, true); + assert.equal(typeof descriptor?.get, 'function'); + assert.throws(() => + Object.defineProperty(dom.window, 'tsjs', { + configurable: true, + value: { publisher: 'replacement' }, + }) + ); + assert.equal(dom.window.tsjs, target); + assert.equal(typeof selected._claimRuntimeV1, 'function'); + assert.equal(selected._claimRuntimeV1(selected), undefined); + dom.window.close(); +}); + test('generated bootstrap leaves the namespace untouched without exact server input', () => { for (const input of ['', 'const __TSJS_SERVER_BOOT_TRANSPORT_V1__={};']) { const { dom } = createDocument(); @@ -179,7 +224,7 @@ test('generated bootstrap leaves the namespace untouched without exact server in }); test('generated bootstrap transfers the direct persistent watchdog to the selected runtime', () => { - const { dom, selected } = createDocument(); + const { dom, selected, setCurrentScript } = createDocument(); selected.src = runtimeSrc; const target = { que: [] }; dom.window.tsjs = target; @@ -187,19 +232,130 @@ test('generated bootstrap transfers the direct persistent watchdog to the select directBoot.manifest.firstDisplay = null; evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(selected); + const claim = selected._claimRuntimeV1; + const originalQuerySelectorAll = dom.window.document.querySelectorAll; + let nestedResult; + let nestedCancelCalls = 0; + let reentered = false; + dom.window.document.querySelectorAll = function (...args) { + if (!reentered) { + reentered = true; + nestedResult = claim(selected); + } + return Reflect.apply(originalQuerySelectorAll, this, args); + }; const cancel = () => assert.fail('committed direct runtime must not be cancelled'); - const complete = target._claimDirectRuntime(selected, cancel); + const claimed = claim(selected); + assert.equal(nestedResult, undefined); + assert.equal(nestedCancelCalls, 0); + assert.equal(claimed.mode, 'direct'); + const complete = claimed.bind(cancel); assert.equal(typeof complete, 'function'); complete('kernel'); - assert.equal(Object.hasOwn(target, '_claimDirectRuntime'), false); + assert.equal(selected._claimRuntimeV1(selected), undefined); assert.equal(Object.hasOwn(target, '_internal'), false); assert.equal(target.boot.manifest.firstDisplay, null); dom.window.close(); }); +test('generated runtime reserves the node claim before mutable realm initializers can reenter', () => { + const { dom, selected, setCurrentScript } = createDocument(); + selected.src = runtimeSrc; + const target = { que: [] }; + dom.window.tsjs = target; + const directBoot = boot(); + directBoot.manifest.firstDisplay = null; + directBoot.manifest.integrations = [{ id: 'render_runtime', phase: 'takeover' }]; + + evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(selected); + const claim = selected._claimRuntimeV1; + const nativeGetOwnPropertyDescriptor = dom.window.Object.getOwnPropertyDescriptor; + const nativeReflectApply = dom.window.Reflect.apply; + let descriptorReentry; + let applyReentry; + dom.window.Object.getOwnPropertyDescriptor = function (...args) { + descriptorReentry ??= claim(selected); + return nativeGetOwnPropertyDescriptor(...args); + }; + dom.window.Reflect.apply = function (targetFunction, thisArgument, argumentsList) { + applyReentry ??= claim(selected); + return nativeReflectApply(targetFunction, thisArgument, argumentsList); + }; + + dom.window.eval(runtimeBody); + + assert.equal(descriptorReentry, undefined); + assert.equal(applyReentry, undefined); + assert.equal(target._internal.state, 'kernel'); + dom.window.close(); +}); + +test('generated takeover claim reserves authentication against publisher reentry', () => { + const { animationFrames, dom, setCurrentScript } = createDocument(); + const target = { que: [] }; + dom.window.tsjs = target; + + evaluateWithInput(dom); + const nativeFreeze = dom.window.Object.freeze; + const nativeDefineProperty = dom.window.Object.defineProperty; + let capabilityRecordExposed = false; + let claimDescriptorExposed = false; + dom.window.Object.freeze = function (value) { + if ( + value && + typeof value === 'object' && + Object.prototype.hasOwnProperty.call(value, 'bind') && + Object.prototype.hasOwnProperty.call(value, 'complete') + ) { + capabilityRecordExposed = true; + } + return nativeFreeze(value); + }; + dom.window.Object.defineProperty = function (owner, key, descriptor) { + if (key === '_claimRuntimeV1' && typeof descriptor?.value === 'function') { + claimDescriptorExposed = true; + } + return nativeDefineProperty(owner, key, descriptor); + }; + while (animationFrames.length > 0) animationFrames.shift()(dom.window.performance.now()); + + const runtime = dom.window.document.querySelector('script#trustedserver-js-runtime'); + const claim = runtime._claimRuntimeV1; + assert.equal(typeof claim, 'function'); + setCurrentScript(runtime); + const originalQuerySelectorAll = dom.window.document.querySelectorAll; + let nestedResult; + let maliciousFinalizeCalls = 0; + let reentered = false; + dom.window.document.querySelectorAll = function (...args) { + if (!reentered) { + reentered = true; + nestedResult = claim(runtime); + } + return Reflect.apply(originalQuerySelectorAll, this, args); + }; + const claimed = claim(runtime); + assert.equal(claimed.mode, 'takeover'); + let outerFinalizeCalls = 0; + assert.throws(() => + claimed.bind(() => { + outerFinalizeCalls += 1; + return undefined; + }) + ); + assert.equal(nestedResult, undefined); + assert.equal(maliciousFinalizeCalls, 0); + assert.equal(outerFinalizeCalls, 1); + assert.equal(capabilityRecordExposed, false); + assert.equal(claimDescriptorExposed, false); + dom.window.close(); +}); + test('generated bootstrap classifies a rejected claimed boot as an ABI mismatch', () => { - const { dom, selected } = createDocument(); + const { dom, selected, setCurrentScript } = createDocument(); selected.src = runtimeSrc; const target = { que: [] }; dom.window.tsjs = target; @@ -207,8 +363,9 @@ test('generated bootstrap classifies a rejected claimed boot as an ABI mismatch' directBoot.manifest.firstDisplay = null; evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(selected); - const claim = target._claimBootSnapshot; + const claim = selected._claimRuntimeV1; assert.equal(typeof claim, 'function'); const claimed = claim(selected); assert.equal(claimed.boot, target.boot); @@ -226,7 +383,7 @@ test('generated bootstrap classifies a rejected claimed boot as an ABI mismatch' }); test('generated bootstrap reserves the boot claim across reentrant DOM authentication', () => { - const { dom, selected } = createDocument(); + const { dom, selected, setCurrentScript } = createDocument(); selected.src = runtimeSrc; const target = { que: [] }; dom.window.tsjs = target; @@ -234,8 +391,9 @@ test('generated bootstrap reserves the boot claim across reentrant DOM authentic directBoot.manifest.firstDisplay = null; evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(selected); - const claim = target._claimBootSnapshot; + const claim = selected._claimRuntimeV1; const querySelectorAll = dom.window.document.querySelectorAll; let nested; let reentered = false; @@ -268,7 +426,7 @@ test('generated bootstrap reserves the boot claim across reentrant DOM authentic }); test('generated bootstrap completes a claimed boot without a reentrant realm callback', () => { - const { dom, selected } = createDocument(); + const { dom, selected, setCurrentScript } = createDocument(); selected.src = runtimeSrc; const target = { que: [] }; dom.window.tsjs = target; @@ -276,8 +434,9 @@ test('generated bootstrap completes a claimed boot without a reentrant realm cal directBoot.manifest.firstDisplay = null; evaluateTransport(dom, transport(directBoot, null)); + setCurrentScript(selected); - const claimed = target._claimBootSnapshot(selected); + const claimed = selected._claimRuntimeV1(selected); const includes = dom.window.Array.prototype.includes; let reentered = false; Object.defineProperty(dom.window.Array.prototype, 'includes', { @@ -307,7 +466,7 @@ test('generated bootstrap completes a claimed boot without a reentrant realm cal }); test('generated base-only takeover failure leaves terminal fallback ownership with bootstrap', () => { - const { animationFrames, dom } = createDocument(); + const { animationFrames, dom, setCurrentScript } = createDocument(); const target = { que: [] }; dom.window.tsjs = target; evaluateWithInput(dom); @@ -318,11 +477,8 @@ test('generated base-only takeover failure leaves terminal fallback ownership wi while (animationFrames.length > 0) animationFrames.shift()(dom.window.performance.now()); const runtime = dom.window.document.querySelector('script#trustedserver-js-runtime'); assert.ok(runtime, 'the bootstrap-owned base must reach protected paint without a marker'); - Object.defineProperty(dom.window.document, 'currentScript', { - configurable: true, - value: runtime, - }); - const claimed = target._claimBootSnapshot(runtime); + setCurrentScript(runtime); + const claimed = runtime._claimRuntimeV1(runtime); assert.equal(claimed.boot, target.boot); claimed.complete('bundle_partial'); @@ -340,8 +496,50 @@ test('generated base-only takeover failure leaves terminal fallback ownership wi dom.window.close(); }); +test('generated bootstrap admits the exact post-paint runtime through one private Trusted Types policy', () => { + const { animationFrames, dom, setCurrentScript } = createDocument(); + const target = { que: [] }; + const policies = []; + Object.defineProperty(dom.window, 'trustedTypes', { + configurable: true, + value: { + createPolicy(name, rules) { + assert.equal(name, 'trusted-server#tsjs-v1'); + if (policies.length !== 0) throw new TypeError('duplicate policy'); + policies.push({ name, rules }); + return { createScriptURL: rules.createScriptURL }; + }, + }, + }); + dom.window.tsjs = target; + + evaluateWithInput(dom); + while (animationFrames.length > 0) animationFrames.shift()(dom.window.performance.now()); + + assert.equal(policies.length, 1); + assert.equal(policies[0].name, 'trusted-server#tsjs-v1'); + assert.throws(() => policies[0].rules.createScriptURL('https://attacker.example/core.js')); + const runtime = dom.window.document.querySelector('script#trustedserver-js-runtime'); + assert.equal(runtime?.src, new URL(runtimeSrc, dom.window.location.origin).href); + assert.equal(Object.hasOwn(runtime, '_tsjsTrustedTypesPolicyV1'), false); + const takeover = runtime._claimRuntimeV1; + assert.equal(typeof takeover, 'function'); + const attacker = dom.window.document.createElement('script'); + attacker.id = 'publisher-script'; + dom.window.document.head.append(attacker); + setCurrentScript(attacker); + let finalizeCalls = 0; + assert.equal(takeover(runtime), undefined); + assert.equal(finalizeCalls, 0); + assert.equal(target._claimRuntimeV1, undefined); + assert.equal(runtime._claimRuntimeV1, takeover); + setCurrentScript(runtime); + runtime._claimRuntimeV1(runtime).complete('bundle_partial'); + dom.window.close(); +}); + test('generated bootstrap stops optional activation after a component installer fails', () => { - const { dom, selected } = createDocument(selectedGptSrc); + const { dom, selected, setCurrentScript } = createDocument(selectedGptSrc); const target = { que: [] }; const events = []; let bindingKeys; @@ -351,6 +549,7 @@ test('generated bootstrap stops optional activation after a component installer bootValue.integrations = { version: 1, entries: [{ id: 'gpt', config: {} }] }; dom.window.tsjs = target; evaluateTransport(dom, transport(bootValue, outlineValue)); + setCurrentScript(selected); const gpt = Object.freeze([ 1, @@ -370,6 +569,50 @@ test('generated bootstrap stops optional activation after a component installer dom.window.close(); }); +test('generated first-display registration reserves authentication against DOM reentry', () => { + const { dom, selected, setCurrentScript } = createDocument(selectedGptSrc); + const target = { que: [] }; + const bootValue = boot(); + const outlineValue = outline(); + selectGpt(bootValue, outlineValue); + bootValue.integrations = { version: 1, entries: [{ id: 'gpt', config: {} }] }; + dom.window.tsjs = target; + evaluateTransport(dom, transport(bootValue, outlineValue)); + setCurrentScript(selected); + + const maliciousInstall = () => assert.fail('a reentrant installer must never activate'); + let genuineInstallCalls = 0; + const genuineInstall = () => { + genuineInstallCalls += 1; + }; + const originalQuerySelectorAll = dom.window.document.querySelectorAll; + let nestedResult; + let reentered = false; + dom.window.document.querySelectorAll = function (...args) { + if (!reentered) { + reentered = true; + nestedResult = target._registerFirstDisplay.call( + target, + Object.freeze([1, 'gpt_initial', manifest.releaseId, maliciousInstall]), + selected + ); + } + return Reflect.apply(originalQuerySelectorAll, this, args); + }; + + const accepted = target._registerFirstDisplay.call( + target, + Object.freeze([1, 'gpt_initial', manifest.releaseId, genuineInstall]), + selected + ); + + assert.equal(nestedResult, false); + assert.equal(accepted, false); + assert.equal(genuineInstallCalls, 1); + assert.equal(Object.getOwnPropertyDescriptor(target, '_internal')?.value.reason, 'abi_mismatch'); + dom.window.close(); +}); + test('generated optional slices receive their exact parser-time browser bindings', () => { const fixtures = [ ['datadome_initial', {}], @@ -385,7 +628,7 @@ test('generated optional slices receive their exact parser-time browser bindings const bootValue = boot(); const outlineValue = outline(); const { body, src } = selectInitialSlice(bootValue, outlineValue, id, config); - const { dom } = createDocument(src); + const { dom, selected, setCurrentScript } = createDocument(src); const callback = () => undefined; if (id === 'lockr_initial') dom.window.identityLockr = { host: 'vendor.example' }; if (id === 'osano_initial') { @@ -427,6 +670,7 @@ test('generated optional slices receive their exact parser-time browser bindings dom.window.tsjs = { que: [] }; evaluateTransport(dom, transport(bootValue, outlineValue)); + setCurrentScript(selected); dom.window.eval(body); assert.equal( @@ -468,7 +712,7 @@ test('generated optional slices receive their exact parser-time browser bindings }); test('generated bootstrap commits one non-rendering terminal shell after registration failure', async () => { - const { dom, selected } = createDocument(selectedGptSrc); + const { dom, selected, setCurrentScript } = createDocument(selectedGptSrc); const drained = []; const target = { que: [ @@ -483,6 +727,7 @@ test('generated bootstrap commits one non-rendering terminal shell after registr const outlineValue = outline(); selectGpt(bootValue, outlineValue); evaluateTransport(dom, transport(bootValue, outlineValue)); + setCurrentScript(selected); assert.equal(dom.window.tsjs._registerFirstDisplay.call(target, {}, selected), false); assert.deepEqual(Object.keys(target).sort(), [ @@ -564,7 +809,7 @@ test('generated bootstrap commits one non-rendering terminal shell after registr }); test('generated bootstrap does not overwrite a conflicting non-configurable namespace field', () => { - const { dom, selected } = createDocument(selectedGptSrc); + const { dom, selected, setCurrentScript } = createDocument(selectedGptSrc); const target = { que: [] }; const publisherApi = () => undefined; Object.defineProperty(target, 'publisherApi', { @@ -579,6 +824,7 @@ test('generated bootstrap does not overwrite a conflicting non-configurable name const outlineValue = outline(); selectGpt(bootValue, outlineValue); evaluateTransport(dom, transport(bootValue, outlineValue)); + setCurrentScript(selected); assert.equal(dom.window.tsjs._registerFirstDisplay.call(target, {}, selected), false); assert.equal(target.publisherApi, publisherApi); 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 1643880c4..3a83c4bbe 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 @@ -23,6 +23,7 @@ import * as bundleMetrics from '../../scripts/bundle-metrics.mjs'; import { findCutoverTextViolations, findVendorBoundaryViolations, + generatedTsjsArtifactFiles, } from '../../scripts/check-hard-cutover-absence.mjs'; const testDirectory = path.dirname(fileURLToPath(import.meta.url)); @@ -189,10 +190,12 @@ function executeGeneratedArtifact(window, file, registrations, { preserveTarget return true; }, }); - Object.defineProperty(window, 'tsjs', { - configurable: true, - value: target, - }); + if (!preserveTarget) { + Object.defineProperty(window, 'tsjs', { + configurable: true, + value: target, + }); + } window.eval(fs.readFileSync(path.resolve(libDirectory, '../dist', file), 'utf8')); } @@ -250,15 +253,15 @@ test('generated optional first-display slices self-register through one authenti url: 'https://publisher.example/article', } ); - const script = dom.window.document.querySelector('script'); const registrations = []; const target = {}; Object.defineProperty(target, '_registerFirstDisplay', { configurable: true, enumerable: false, - value(registration, source) { + value(registration) { assert.equal(this, target); - assert.equal(source, script); + assert.equal(arguments.length, 1); + dom.window.Object.freeze(registration); registrations.push(registration); return true; }, @@ -268,11 +271,6 @@ test('generated optional first-display slices self-register through one authenti configurable: true, value: target, }); - Object.defineProperty(dom.window.document, 'currentScript', { - configurable: true, - value: script, - }); - try { dom.window.eval( firstDisplay @@ -693,27 +691,45 @@ test('co-bundled render_runtime and independent GPT start one branded display fl .digest('hex'), }); dom.window.Object.freeze(integrity); - const claimedBoot = dom.window.Object.create(dom.window.Object.prototype); - dom.window.Object.assign(claimedBoot, { boot, integrity, complete: () => undefined }); - dom.window.Object.freeze(claimedBoot); - Object.defineProperty(dom.window, 'tsjs', { configurable: true, value: {} }); - Object.defineProperties(dom.window.tsjs, { - boot: { configurable: true, enumerable: true, value: boot, writable: true }, - que: { configurable: true, enumerable: true, value: [], writable: true }, - _claimBootSnapshot: { - configurable: true, - enumerable: false, - value: (source) => { - if (source !== runtimeScript) return undefined; - Reflect.deleteProperty(dom.window.tsjs, '_claimBootSnapshot'); - return claimedBoot; - }, - writable: false, + const target = dom.window.Object.create(dom.window.Object.prototype); + dom.window.Object.assign(target, { boot, que: dom.window.Array() }); + const currentScript = () => runtimeScript; + const claimedRuntime = dom.window.Object.create(dom.window.Object.prototype); + dom.window.Object.assign(claimedRuntime, { + boot, + integrity, + complete: () => undefined, + currentScript, + source: runtimeScript, + target, + mode: 'direct', + bind: (cancel) => (typeof cancel === 'function' ? () => undefined : undefined), + }); + dom.window.Object.freeze(claimedRuntime); + let claimLive = true; + Object.defineProperty(runtimeScript, '_claimRuntimeV1', { + configurable: false, + enumerable: false, + value: (source) => { + if (!claimLive || source !== runtimeScript) return undefined; + claimLive = false; + return claimedRuntime; }, + writable: false, }); - Object.defineProperty(dom.window.document, 'currentScript', { + Object.defineProperty(dom.window.Document.prototype, 'currentScript', { configurable: true, - value: runtimeScript, + get: currentScript, + }); + let runtimeReadLive = true; + Object.defineProperty(dom.window, 'tsjs', { + configurable: false, + enumerable: true, + get: () => { + if (!runtimeReadLive) return target; + runtimeReadLive = false; + return runtimeScript; + }, }); let publisherMicrotaskRan = false; dom.window.queueMicrotask(() => { @@ -1314,13 +1330,7 @@ test('capture provenance rejects an invalid source or missing captured build inp () => bundleBudgets.validateFrozenCaptureProvenance(invalidIntermediate, capture), /capture source SHA/ ); - assert.throws( - () => - bundleBudgets.validateCaptureSourceProvenance(capture, { - head: `${capture.source.sha}^`, - }), - /not an ancestor/ - ); + assert.doesNotThrow(() => bundleBudgets.validateCaptureSourceProvenance(capture)); assert.throws( () => bundleBudgets.validateCaptureSourceProvenance(capture, { @@ -2055,6 +2065,28 @@ test('vendor boundary rejects APS, GPT, and PUC artifacts but permits conformanc ); }); +test('hard-cutover scan traverses the complete generated release inventory', () => { + const release = JSON.parse( + fs.readFileSync(path.resolve(libDirectory, '../dist/tsjs-release-v1.json'), 'utf8') + ); + const generated = new Set( + generatedTsjsArtifactFiles(libDirectory).map((file) => path.resolve(file)) + ); + + for (const { file } of release.artifacts) { + assert.equal( + generated.has(path.resolve(libDirectory, '../dist', file)), + true, + `hard-cutover scan must traverse generated artifact ${file}` + ); + } + assert.equal( + generated.has(path.resolve(libDirectory, '../dist/tsjs-release-v1.json')), + true, + 'hard-cutover scan must traverse its generated release manifest' + ); +}); + test('registered integration dispatch selects post-switch evidence without changing the instrument', () => { const workflow = fs.readFileSync( path.join(repositoryRoot, '.github/workflows/integration-tests.yml'), 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 5b0dc3a77..a248f68b3 100644 --- a/crates/trusted-server-js/lib/test/composition/browser.test.ts +++ b/crates/trusted-server-js/lib/test/composition/browser.test.ts @@ -319,6 +319,7 @@ function synchronousGptAdapter(initialSlots: readonly object[] = []) { if (key === undefined) values?.clear(); else values?.delete(key); }), + enableServices: () => undefined, transactionalDefine, display, getTargeting: vi.fn((slot: object, key: string) => diff --git a/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs b/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs index 9e90c7afa..6b90eb245 100644 --- a/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs +++ b/crates/trusted-server-js/lib/test/contract/retired-concept-audit.test.mjs @@ -33,7 +33,7 @@ const historicalPerformanceFixturePath = path.join( 'crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json' ); const mainAuditSha = 'f6a2fb85ce623bf8a574e3941e1ee349acc3412d'; -const rcBaselineSha = 'f0825604ec6740111e99dd8a178e3b880e7d772b'; +const rcBaselineSha = '985ff22987d80cc729f45f702e0c3548e429b2cf'; const parsedAuditFixture = JSON.parse(readFileSync(auditFixturePath, 'utf8')); const auditedClassifications = Array.isArray(parsedAuditFixture) ? parsedAuditFixture @@ -457,8 +457,8 @@ test('rc-baseline authority and final pass-gap classifications are pinned', () = }); assertRetiredConceptAudit(result); assert.deepEqual(result.rcBaseline.classificationCounts, { - baselineOwned: 18, - implementationGap: 5, + baselineOwned: 19, + implementationGap: 4, }); assert.deepEqual( result.rcBaseline.classifications.map(({ id }) => id).sort(), @@ -554,7 +554,7 @@ git mv crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs crat test('the implementation plan contains no executable retired-source command', () => { const planSource = readFileSync(planPath, 'utf8'); - assert.equal(countExecutableShellFences(planSource), 61); + assert.equal(countExecutableShellFences(planSource), 62); assert.deepEqual(auditRetiredPlanCommands(planSource), []); const mutatedPlanSource = planSource.replace( 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 71405ab1e..0cd4f6d1d 100644 --- a/crates/trusted-server-js/lib/test/core/index.test.ts +++ b/crates/trusted-server-js/lib/test/core/index.test.ts @@ -9,6 +9,9 @@ import type { TsjsApi, TsjsBootV1 } from '../../src/core/types'; const RELEASE = 'a'.repeat(64); const RUNTIME_SRC = `/static/tsjs=tsjs-unified.min.js?v=${'c'.repeat(64)}`; +let pendingDirectBind: ((input: unknown) => unknown) | undefined; +let namespaceTarget: unknown; +let namespaceSourceLive = false; function boot() { return snapshotTsjsBootV1( @@ -152,7 +155,15 @@ function installRuntimeScript(): void { script.id = 'trustedserver-js'; script.src = new URL(RUNTIME_SRC, window.location.origin).href; document.head.append(script); - Object.defineProperty(document, 'currentScript', { configurable: true, value: script }); + Object.defineProperty(window.Document.prototype, 'currentScript', { + configurable: true, + get: () => script, + }); +} + +function installNamespaceGate(target: object): void { + namespaceTarget = target; + namespaceSourceLive = true; } function bootIntegrity(acceptedBoot: Readonly) { @@ -164,18 +175,34 @@ function bootIntegrity(acceptedBoot: Readonly) { } function installBootClaim( - target: object, + _target: object, acceptedBoot: Readonly, integrity = bootIntegrity(acceptedBoot) ): ReturnType { const completed = vi.fn(); const claim = (source: unknown) => { if (source !== document.currentScript) return undefined; - Reflect.deleteProperty(target, '_claimBootSnapshot'); - return Object.freeze({ boot: acceptedBoot, integrity, complete: completed }); + const takeover = Object.getOwnPropertyDescriptor(_target, '_firstDisplayTakeover'); + const mode = takeover ? 'takeover' : 'direct'; + return Object.freeze({ + source, + target: _target, + boot: acceptedBoot, + integrity, + complete: completed, + currentScript: () => document.currentScript, + mode, + bind: + mode === 'direct' + ? (input: unknown) => pendingDirectBind?.(input) + : (input: unknown) => + takeover && 'value' in takeover && typeof takeover.value === 'function' + ? takeover.value(document.currentScript, input) + : undefined, + }); }; - Object.defineProperty(target, '_claimBootSnapshot', { - configurable: true, + Object.defineProperty(document.currentScript as HTMLScriptElement, '_claimRuntimeV1', { + configurable: false, enumerable: false, value: claim, writable: false, @@ -183,12 +210,31 @@ function installBootClaim( return completed; } +function installDirectRuntimeClaim(_target: object): ReturnType { + const completed = vi.fn(); + pendingDirectBind = () => completed; + return completed; +} + describe('core production bootstrap', () => { beforeEach(async () => { await vi.resetModules(); document.head.replaceChildren(); document.body.innerHTML = ''; - delete (window as unknown as { tsjs?: unknown }).tsjs; + if (!Object.getOwnPropertyDescriptor(window, 'tsjs')) { + Object.defineProperty(window, 'tsjs', { + configurable: false, + enumerable: true, + get: () => { + if (!namespaceSourceLive) return namespaceTarget; + namespaceSourceLive = false; + return document.currentScript; + }, + }); + } + namespaceTarget = undefined; + namespaceSourceLive = false; + pendingDirectBind = undefined; installRuntimeScript(); }); @@ -203,7 +249,8 @@ describe('core production bootstrap', () => { bids: { legacy: true }, }; installBootClaim(preload, preload.boot); - (window as unknown as { tsjs?: unknown }).tsjs = preload; + installDirectRuntimeClaim(preload); + installNamespaceGate(preload); await loadMinimalProductionRuntime(); await vi.waitFor(() => @@ -234,7 +281,8 @@ describe('core production bootstrap', () => { const readyState = vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading'); const preload = { boot: boot(), que: [] }; installBootClaim(preload, preload.boot); - (window as unknown as { tsjs?: unknown }).tsjs = preload; + installDirectRuntimeClaim(preload); + installNamespaceGate(preload); try { await loadMinimalProductionRuntime(); @@ -255,7 +303,7 @@ describe('core production bootstrap', () => { preload.boot, Object.freeze({ ...bootIntegrity(preload.boot), [field]: 'f'.repeat(64) }) ); - (window as unknown as { tsjs?: unknown }).tsjs = preload; + installNamespaceGate(preload); await loadMinimalProductionRuntime(); await Promise.resolve(); @@ -284,7 +332,7 @@ describe('core production bootstrap', () => { preload.boot, Object.freeze({ ...bootIntegrity(preload.boot), projectionDigest: 'f'.repeat(64) }) ); - (window as unknown as { tsjs?: unknown }).tsjs = preload; + installNamespaceGate(preload); await loadMinimalProductionRuntime(); await Promise.resolve(); @@ -326,7 +374,7 @@ describe('core production bootstrap', () => { }); } const rejected = installBootClaim(preload, candidate, integrity); - (window as unknown as { tsjs?: unknown }).tsjs = preload; + installNamespaceGate(preload); const { startProductionRuntime } = await import('../../src/core/index'); const createComposition = vi.fn(() => ({ runtime: { start: vi.fn(() => true) }, @@ -349,7 +397,7 @@ describe('core production bootstrap', () => { enumerable: true, value: () => preload.boot, }); - (window as unknown as { tsjs?: unknown }).tsjs = preload; + installNamespaceGate(preload); await import('../../src/composition/runtime_transport'); await Promise.resolve(); @@ -359,6 +407,27 @@ describe('core production bootstrap', () => { expect(preload).not.toHaveProperty('requestAds'); }); + it('disposes prepared runtime state and completes abi_mismatch when the direct claim rejects', async () => { + const preload = { boot: boot(), que: [] }; + const rejected = installBootClaim(preload, preload.boot); + pendingDirectBind = () => undefined; + installNamespaceGate(preload); + const { startProductionRuntime } = await import('../../src/core/index'); + const dispose = vi.fn(); + const start = vi.fn(() => true); + const createComposition = vi.fn(() => ({ + runtime: { dispose, start }, + })) as unknown as Parameters[0]; + + startProductionRuntime(createComposition); + + expect(createComposition).toHaveBeenCalledOnce(); + expect(start).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledOnce(); + expect(rejected).toHaveBeenCalledOnce(); + expect(rejected).toHaveBeenCalledWith('abi_mismatch'); + }); + it('does not publish a fallback API over a non-configurable boot claim', async () => { const preload = { boot: boot(), que: [] }; Object.defineProperty(preload, '_claimBootSnapshot', { @@ -367,7 +436,7 @@ describe('core production bootstrap', () => { value: () => preload.boot, writable: false, }); - (window as unknown as { tsjs?: unknown }).tsjs = preload; + installNamespaceGate(preload); await import('../../src/composition/runtime_transport'); await Promise.resolve(); 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 0c60a8b1e..a3afddf1c 100644 --- a/crates/trusted-server-js/lib/test/core/request.test.ts +++ b/crates/trusted-server-js/lib/test/core/request.test.ts @@ -59,24 +59,39 @@ function installRuntimeScript(): void { } function installBootClaim(target: object, acceptedBoot: Readonly): void { - Object.defineProperty(target, '_claimBootSnapshot', { - configurable: true, + const source = document.currentScript as HTMLScriptElement; + Object.defineProperty(source, '_claimRuntimeV1', { + configurable: false, enumerable: false, - value: (source: unknown) => { - if (source !== document.currentScript) return undefined; - Reflect.deleteProperty(target, '_claimBootSnapshot'); + value: (candidate: unknown) => { + if (candidate !== source || candidate !== document.currentScript) return undefined; return Object.freeze({ + source, + target, boot: acceptedBoot, complete: () => undefined, + currentScript: () => document.currentScript, integrity: Object.freeze({ version: 1, projectionDigest: sha256HexUtf8V1(JSON.stringify(acceptedBoot.auctionProjection)), integrationConfigDigest: canonicalIntegrationConfigDigestV1(acceptedBoot.integrations), }), + mode: 'direct', + bind: () => () => undefined, }); }, writable: false, }); + let sourceLive = true; + Object.defineProperty(window, 'tsjs', { + configurable: true, + enumerable: true, + get: () => { + if (!sourceLive) return target; + sourceLive = false; + return source; + }, + }); } describe('hard-cutover requestAds API', () => { @@ -95,7 +110,6 @@ describe('hard-cutover requestAds API', () => { requestAds: legacyCallback, }; installBootClaim(preload, preload.boot); - (window as unknown as { tsjs?: unknown }).tsjs = preload; await loadMinimalProductionRuntime(); await vi.waitFor(() => diff --git a/crates/trusted-server-js/lib/test/first_display/agent.test.ts b/crates/trusted-server-js/lib/test/first_display/agent.test.ts index d6596ac15..81856e587 100644 --- a/crates/trusted-server-js/lib/test/first_display/agent.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/agent.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { createFirstDisplayAgent, + prepareFirstDisplayBase, type FirstDisplayAgentOptions, + type FirstDisplayAgentRegistrationHostV1, type FirstDisplayBatchOutcomeV1, type FirstDisplayDriver, type FirstDisplayTerminalResult, @@ -343,6 +345,38 @@ function production( } describe('bounded first-display agent', () => { + it.each(['gpt', 'render_owner'] as const)( + 'accepts the exact two-field %s installer receipt after registering its full protocol', + (protocolId) => { + const sliceId = `${protocolId}_initial` as const; + const releases: Array<() => void> = []; + const prepared = prepareFirstDisplayBase({ + options: { + handoff: { slices: [sliceId] }, + }, + sliceBindings: ( + _id: string, + _observe: (key: unknown, value: unknown) => void, + register: ((protocol: unknown) => () => void) | undefined + ) => Object.freeze([Object.freeze({ register }), undefined]), + } as unknown as FirstDisplayAgentRegistrationHostV1); + + expect(() => + prepared.sliceHost.activate( + sliceId, + (release) => releases.push(release), + (bindings) => { + const register = (bindings as Readonly<{ register: (protocol: unknown) => () => void }>) + .register; + releases.push(register(Object.freeze([1, protocolId, Object.freeze({})] as const))); + return Object.freeze([1, protocolId] as const); + } + ) + ).not.toThrow(); + expect(releases).toHaveLength(1); + } + ); + it('derives the protected action from the immutable projection and rejects outcome summaries', () => { const accepted = harness(); const received: FirstDisplayBatchOutcomeV1[][] = []; @@ -664,6 +698,62 @@ describe('bounded first-display agent', () => { expect(revision.failures).toEqual(['bundle_partial']); }); + it('runs every independent driver disposer when an earlier disposer throws', () => { + const h = harness(); + const cleanup: string[] = []; + const gpt: FirstDisplayGptProtocolV1 = Object.freeze([ + 1, + 'gpt', + () => + Object.freeze([ + (callbacks: FirstDisplayGoogletagBatchCallbacks) => callbacks[2](), + () => true, + () => undefined, + () => undefined, + () => true, + () => { + cleanup.push('gpt'); + throw new Error('fictional GPT cleanup failure'); + }, + ] as const), + ]); + const renderer: FirstDisplayRenderBridgeCapabilityV1 = Object.freeze([ + () => true, + () => true, + () => true, + () => true, + () => 0, + () => undefined, + () => true, + () => undefined, + () => true, + () => cleanup.push('renderer'), + ]); + const agent = createFirstDisplayAgent({ + batch: batch(['gpt_adm']), + ...h.owner, + production: Object.freeze({ + gpt, + gptInput: Object.freeze([ + window, + (handle: unknown) => window.clearTimeout(handle as number), + document, + (callback: () => void, delayMs: number) => window.setTimeout(callback, delayMs), + ] as const), + renderer, + }), + performance: h.performance, + paint: h.paint, + onProtectedPaint: () => undefined, + onFailure: (reason) => h.failures.push(reason), + }); + + expect(agent.start()).toBe(true); + expect(() => agent.dispose()).not.toThrow(); + expect(cleanup).toEqual(['gpt', 'renderer']); + expect(agent.state).toBe('disposed'); + }); + it('mints the final immutable handoff only after paint and closes ingress before capture', () => { const h = harness(); const events: string[] = []; diff --git a/crates/trusted-server-js/lib/test/first_display/browser_route_owner.test.ts b/crates/trusted-server-js/lib/test/first_display/browser_route_owner.test.ts index 30cdeab0f..06f8d0f2e 100644 --- a/crates/trusted-server-js/lib/test/first_display/browser_route_owner.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/browser_route_owner.test.ts @@ -1,8 +1,42 @@ import { describe, expect, it, vi } from 'vitest'; -import { registerFirstDisplayBrowserRoute } from '../../src/first_display/leaf/browser_route_owner'; +import { + createFirstDisplayBrowserRouteOwner, + registerFirstDisplayBrowserRoute, +} from '../../src/first_display/leaf/browser_route_owner'; describe('first-display browser route owner', () => { + it('shares one DOM interceptor across rules and releases each rule independently', () => { + const nativeAppendChild = Element.prototype.appendChild; + const releaseAlpha = registerFirstDisplayBrowserRoute({ + matches: (_kind, url) => url.includes('alpha.example'), + rewrite: () => 'https://publisher.example/alpha', + }); + const sharedAppendChild = Element.prototype.appendChild; + const releaseBeta = registerFirstDisplayBrowserRoute({ + matches: (_kind, url) => url.includes('beta.example'), + rewrite: () => 'https://publisher.example/beta', + }); + const appendChildAfterSecondRegistration = Element.prototype.appendChild; + + releaseAlpha(); + const alpha = document.createElement('script'); + alpha.src = 'https://alpha.example/sdk.js'; + document.head.appendChild(alpha); + const beta = document.createElement('script'); + beta.src = 'https://beta.example/sdk.js'; + document.head.appendChild(beta); + releaseBeta(); + const appendChildAfterFinalRelease = Element.prototype.appendChild; + alpha.remove(); + beta.remove(); + + expect(appendChildAfterSecondRegistration).toBe(sharedAppendChild); + expect(alpha.src).toBe('https://alpha.example/sdk.js'); + expect(beta.src).toBe('https://publisher.example/beta'); + expect(appendChildAfterFinalRelease).toBe(nativeAppendChild); + }); + it('rewrites matching scripts and preloads before native insertion and restores exactly once', () => { const appendChild = Element.prototype.appendChild; const insertBefore = Element.prototype.insertBefore; @@ -38,11 +72,48 @@ describe('first-display browser route owner', () => { later.remove(); }); + it('rewrites variadic and fragment insertion paths synchronously and compare-restores them', () => { + const append = Element.prototype.append; + const prepend = Element.prototype.prepend; + const replaceChildren = Element.prototype.replaceChildren; + const release = registerFirstDisplayBrowserRoute({ + matches: (kind, url) => kind === 'script' && url.includes('vendor.example'), + rewrite: (url) => `/integrations/proxy?source=${encodeURIComponent(url)}`, + }); + const appended = document.createElement('script'); + appended.src = 'https://vendor.example/append.js'; + const prepended = document.createElement('script'); + prepended.src = 'https://vendor.example/prepend.js'; + const nested = document.createElement('script'); + nested.src = 'https://vendor.example/fragment.js'; + const fragment = document.createDocumentFragment(); + fragment.appendChild(nested); + + document.head.append(appended); + document.head.prepend(prepended); + document.head.replaceChildren(fragment); + const restoredAfterRelease = { + append: Element.prototype.append, + prepend: Element.prototype.prepend, + replaceChildren: Element.prototype.replaceChildren, + }; + release(); + nested.remove(); + + expect(appended.getAttribute('src')).toContain('/integrations/proxy?source='); + expect(prepended.getAttribute('src')).toContain('/integrations/proxy?source='); + expect(nested.getAttribute('src')).toContain('/integrations/proxy?source='); + expect(restoredAfterRelease.append).not.toBe(append); + expect(Element.prototype.append).toBe(append); + expect(Element.prototype.prepend).toBe(prepend); + expect(Element.prototype.replaceChildren).toBe(replaceChildren); + }); + it('owns matching beacon and fetch routes without changing publisher replacements on release', async () => { const originalBeacon = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); const originalFetch = Object.getOwnPropertyDescriptor(window, 'fetch'); const beacon = vi.fn(() => true); - const fetch = vi.fn(async () => new Response()); + const fetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response()); Object.defineProperty(navigator, 'sendBeacon', { configurable: true, value: beacon, @@ -82,4 +153,97 @@ describe('first-display browser route owner', () => { if (originalFetch) Object.defineProperty(window, 'fetch', originalFetch); else Reflect.deleteProperty(window, 'fetch'); }); + + it('preserves Request identity when unmatched and metadata when rewriting its URL', async () => { + const originalFetch = Object.getOwnPropertyDescriptor(window, 'fetch'); + const fetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response()); + Object.defineProperty(window, 'fetch', { + configurable: true, + value: fetch, + writable: true, + }); + const release = registerFirstDisplayBrowserRoute( + { + matches: (kind, url) => kind === 'fetch' && url.includes('analytics.example'), + rewrite: () => 'https://publisher.example/integrations/analytics', + }, + true + ); + const unmatched = new Request('https://publisher.example/content', { + body: 'unmatched-body', + headers: { 'x-owner': 'publisher' }, + method: 'POST', + }); + const matched = new Request('https://analytics.example/collect', { + body: 'matched-body', + headers: { 'x-owner': 'publisher' }, + method: 'POST', + }); + + await window.fetch(unmatched); + await window.fetch(matched); + const unmatchedInput = fetch.mock.calls[0]?.[0]; + const matchedInput = fetch.mock.calls[1]?.[0] as Request; + release(); + if (originalFetch) Object.defineProperty(window, 'fetch', originalFetch); + else Reflect.deleteProperty(window, 'fetch'); + + expect(unmatchedInput).toBe(unmatched); + expect(matchedInput).toBeInstanceOf(Request); + expect(matchedInput.url).toBe('https://publisher.example/integrations/analytics'); + expect(matchedInput.method).toBe('POST'); + expect(matchedInput.headers.get('x-owner')).toBe('publisher'); + await expect(matchedInput.text()).resolves.toBe('matched-body'); + }); + + it('continues restoring every owned surface when one browser property rejects restoration', () => { + const appendChildDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'appendChild'); + const insertBefore = Element.prototype.insertBefore; + const append = Element.prototype.append; + const prepend = Element.prototype.prepend; + const replaceChildren = Element.prototype.replaceChildren; + const beaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const fetchDescriptor = Object.getOwnPropertyDescriptor(window, 'fetch'); + const beacon = vi.fn(() => true); + const fetch = vi.fn(async () => new Response()); + Object.defineProperty(navigator, 'sendBeacon', { + configurable: true, + value: beacon, + writable: true, + }); + Object.defineProperty(window, 'fetch', { + configurable: true, + value: fetch, + writable: true, + }); + + try { + const owner = createFirstDisplayBrowserRouteOwner(document, window); + const release = owner.register({ matches: () => false, rewrite: (url) => url }, true); + const installedAppendChild = Element.prototype.appendChild; + Object.defineProperty(Element.prototype, 'appendChild', { + configurable: true, + get: () => installedAppendChild, + set: () => { + throw new TypeError('hostile browser surface'); + }, + }); + + expect(() => release()).not.toThrow(); + expect(Element.prototype.insertBefore).toBe(insertBefore); + expect(Element.prototype.append).toBe(append); + expect(Element.prototype.prepend).toBe(prepend); + expect(Element.prototype.replaceChildren).toBe(replaceChildren); + expect(navigator.sendBeacon).toBe(beacon); + expect(window.fetch).toBe(fetch); + } finally { + if (appendChildDescriptor) { + Object.defineProperty(Element.prototype, 'appendChild', appendChildDescriptor); + } + if (beaconDescriptor) Object.defineProperty(navigator, 'sendBeacon', beaconDescriptor); + else Reflect.deleteProperty(navigator, 'sendBeacon'); + if (fetchDescriptor) Object.defineProperty(window, 'fetch', fetchDescriptor); + else Reflect.deleteProperty(window, 'fetch'); + } + }); }); diff --git a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts index 5e7c99fa8..f8ab2c066 100644 --- a/crates/trusted-server-js/lib/test/first_display/contracts.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/contracts.test.ts @@ -102,6 +102,7 @@ function handoff(overrides: Record = {}): Record gptCallbacks!, renderer, + setCapturedCycle: (cycle: FirstDisplayGptHandoffCycleV1) => { + cycleOwner.value = cycle; + }, getRenderTerminal: () => renderTerminal!, terminals, }; @@ -298,6 +301,36 @@ describe('projected first-display driver', () => { expect(h.renderer.detachCommittedArtifacts).toHaveBeenCalledOnce(); }); + it('captures live publisher ownership and a hydration replacement from late GPT handoff', () => { + const h = harness(); + h.getGptCallbacks()[0](h.cycle); + h.getGptCallbacks()[2](); + h.getGptCallbacks()[3](h.cycle, 'nonempty_gam'); + h.getRenderTerminal()('accepted', null); + h.driver.sealTsAdmission(); + const replacement = document.createElement('div'); + replacement.id = 'slot-1-hydrated'; + document.body.append(replacement); + h.setCapturedCycle( + Object.freeze([ + h.cycle[0], + replacement, + h.cycle[2], + 'publisher', + h.cycle[4], + h.cycle[5], + h.cycle[6], + h.cycle[7], + Object.freeze([]), + ]) + ); + + expect(h.driver.closeIngress()).toBe(true); + const captured = h.driver.captureHandoff(); + expect(captured?.cycles[0]?.[1]).toBe(replacement); + expect(captured?.cycles[0]?.[3]).toBe('publisher'); + }); + it('rejects an action list that does not exactly match the immutable batch', () => { const value = batch(); const driver = createFirstDisplayProjectedDriver({ diff --git a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts index af1b57252..bd55feecb 100644 --- a/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/gpt_adapter.test.ts @@ -250,6 +250,7 @@ describe('first-display GPT adapter', () => { removeEventListener: () => undefined, }; const binding = { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot: vi.fn(() => slot), destroySlots: vi.fn(() => true), @@ -325,6 +326,10 @@ describe('first-display GPT adapter', () => { addEventListener: vi.fn((name: string, listener: (event: unknown) => void) => { listeners.set(name, listener); }), + enableSingleRequest: vi.fn(() => { + events.push('sra'); + return true; + }), getSlots: vi.fn(() => []), refresh: vi.fn(() => events.push('refresh')), removeEventListener: vi.fn(), @@ -332,9 +337,11 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: false, cmd: { push: (command: () => void) => command() }, defineSlot: vi.fn(() => slot), display: vi.fn(() => events.push('display')), + enableServices: vi.fn(() => events.push('services')), getConfig: vi.fn(() => ({ disableInitialLoad: false })), pubads: vi.fn(() => service), }, @@ -380,6 +387,8 @@ describe('first-display GPT adapter', () => { 'target:hb_pb', 'target:placement', 'first-action', + 'sra', + 'services', 'display', ]); expect(slot.setTargeting.mock.calls).toEqual([ @@ -400,6 +409,144 @@ describe('first-display GPT adapter', () => { ]); }); + it.each(['enableSingleRequest', 'enableServices'] as const)( + 'fails the entire batch without a request when %s throws', + (failure) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const slots = new Map(); + const display = vi.fn(); + const refresh = vi.fn(); + const service = { + addEventListener: vi.fn(), + enableSingleRequest: vi.fn(() => { + if (failure === 'enableSingleRequest') throw new Error('fictional SRA failure'); + return true; + }), + getSlots: () => [], + refresh, + removeEventListener: vi.fn(), + }; + const binding = { + pubadsReady: false, + cmd: { push: (command: () => void) => command() }, + defineSlot: (_path: string, _sizes: unknown, elementId: string) => { + const targeting = new Map(); + const slot = { + addService: () => slot, + clearTargeting: (key: string) => targeting.delete(key), + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: (key: string, value: string) => { + targeting.set(key, [value]); + return slot; + }, + }; + slots.set(elementId, slot); + return slot; + }, + display, + enableServices: () => { + if (failure === 'enableServices') throw new Error('fictional service failure'); + }, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { configurable: true, value: binding }); + const failures: unknown[] = []; + const firstAction = vi.fn(() => true); + const batch = snapshotFirstDisplayBatchV1(twoSlotFixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + + startAdapter(adapter, { + onBound: () => undefined, + onFailure: (slotId, reason) => failures.push([slotId, reason]), + onFirstAction: firstAction, + onRenderEnded: () => undefined, + }); + + expect(firstAction).toHaveBeenCalledOnce(); + expect(failures).toEqual([ + ['slot-1', 'gpt_request_failed'], + ['slot-2', 'gpt_request_failed'], + ]); + expect(display).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + } + ); + + it('arms every synchronous SRA display cycle before the first display requests all slots', () => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const listeners = new Map void>(); + const slots: object[] = []; + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [], + refresh: vi.fn(), + removeEventListener: vi.fn(), + }; + const display = vi.fn(() => { + for (const slot of slots) listeners.get('slotRequested')?.({ slot }); + for (const slot of slots) listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + }); + const binding = { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: (_path: string, _sizes: unknown, _elementId: string) => { + const targeting = new Map(); + const slot = { + addService: () => slot, + clearTargeting: (key: string) => targeting.delete(key), + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: (key: string, value: string) => { + targeting.set(key, [value]); + return slot; + }, + }; + slots.push(slot); + return slot; + }, + display, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { configurable: true, value: binding }); + const rendered: string[] = []; + const failures: unknown[] = []; + const firstAction = vi.fn(() => true); + const batch = snapshotFirstDisplayBatchV1(twoSlotFixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + + startAdapter(adapter, { + onBound: () => undefined, + onFailure: (slotId, reason) => failures.push([slotId, reason]), + onFirstAction: firstAction, + onRenderEnded: (cycle) => rendered.push(cycle[6]), + }); + + expect(display).toHaveBeenCalledOnce(); + expect(firstAction).toHaveBeenCalledOnce(); + expect(rendered).toEqual(['slot-1', 'slot-2']); + expect(failures).toEqual([]); + }); + it('invalidates the delayed-owner binding when a publisher replaces the physical slot', () => { const dom = new JSDOM('
', { url: 'https://publisher.example/', @@ -430,6 +577,7 @@ describe('first-display GPT adapter', () => { return true; }); const binding = { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot, destroySlots, @@ -545,6 +693,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot, display, @@ -588,6 +737,204 @@ describe('first-display GPT adapter', () => { expect(targeting.has('hb_adid')).toBe(false); }); + it.each(['reentrant_refresh', 'slot_object_display'] as const)( + 'does not attribute a competing publisher %s to the pending TS request', + (competition) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const listeners = new Map void>(); + const slot = { + getSlotElementId: () => 'slot-1', + getTargeting: () => [], + setTargeting: () => slot, + clearTargeting: () => slot, + }; + let reentered = false; + const refresh = vi.fn((_slots?: readonly object[]) => { + if (competition === 'reentrant_refresh' && !reentered) { + reentered = true; + service.refresh([slot]); + } + }); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => [slot], + refresh, + removeEventListener: () => undefined, + }; + const display = vi.fn((_slot?: unknown) => undefined); + const binding = { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot: () => undefined, + display, + getConfig: () => ({ disableInitialLoad: false }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: binding, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const failure = vi.fn(); + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + diagnosticsActive: true, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: failure, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + + if (competition === 'slot_object_display') binding.display(slot as never); + listeners.get('slotRequested')?.({ slot }); + + expect(refresh).toHaveBeenCalledTimes(competition === 'reentrant_refresh' ? 2 : 1); + expect(display).toHaveBeenCalledTimes(competition === 'slot_object_display' ? 1 : 0); + expect(failure).toHaveBeenCalledWith('slot-1', 'cycle_unattributable'); + expect(adapter.closeIngress([])).toBe(true); + expect(adapter.captureDiagnosticsHandoff()?.[1][0]).toMatchObject({ + event: 'slotRequested', + requestedSlotSizes: null, + }); + adapter.dispose(); + } + ); + + it.each(['exact', 'hydration'] as const)( + 'hands off a %s late publisher definition without creating a second GPT slot', + (mode) => { + const dom = new JSDOM('
', { + url: 'https://publisher.example/', + }); + const warning = vi.spyOn(dom.window.console, 'warn').mockImplementation(() => undefined); + const listeners = new Map void>(); + const targeting = new Map(); + const slot = { + addService: () => slot, + clearTargeting: (key: string) => targeting.delete(key), + getAdUnitPath: () => '/123/example', + getSlotElementId: () => 'slot-1', + getTargeting: (key: string) => targeting.get(key) ?? [], + setTargeting: (key: string, value: string | readonly string[]) => { + targeting.set(key, typeof value === 'string' ? [value] : [...value]); + return slot; + }, + }; + let defined = false; + let disabled = false; + const refresh = vi.fn((_slots?: readonly object[], _options?: unknown) => undefined); + const service = { + addEventListener: (name: string, listener: (event: unknown) => void) => + listeners.set(name, listener), + getSlots: () => (defined ? [slot] : []), + refresh, + removeEventListener: () => undefined, + }; + const defineSlot = vi.fn((_path?: unknown, _sizes?: unknown, _elementId?: unknown) => { + defined = true; + return slot; + }); + const display = vi.fn((_target?: unknown) => undefined); + const binding = { + pubadsReady: true, + cmd: { push: (command: () => void) => command() }, + defineSlot, + destroySlots: vi.fn(() => true), + display, + getConfig: () => ({ disableInitialLoad: disabled }), + pubads: () => service, + }; + Object.defineProperty(dom.window, 'googletag', { + configurable: true, + value: binding, + }); + const batch = snapshotFirstDisplayBatchV1(fixture())!; + const adapter = createFirstDisplayGoogletagBatch({ + browser: dom.window as unknown as Window, + clearTimer: () => undefined, + document: dom.window.document, + projection: batch.projection, + protocol: protocol(), + setTimer: (callback) => callback, + }); + startAdapter(adapter, { + onBound: () => undefined, + onFailure: () => undefined, + onFirstAction: () => true, + onRenderEnded: () => undefined, + }); + listeners.get('slotRequested')?.({ slot }); + listeners.get('slotRenderEnded')?.({ slot, isEmpty: false }); + disabled = true; + + let elementId = 'slot-1'; + let path: string = '/publisher/mismatch'; + let sizes: readonly (readonly [number, number])[] = [[728, 90]]; + if (mode === 'hydration') { + dom.window.document.getElementById('slot-1')?.remove(); + const replacement = dom.window.document.createElement('div'); + replacement.id = 'slot-1-hydrated'; + dom.window.document.body.append(replacement); + elementId = replacement.id; + path = '/123/example'; + sizes = [[300, 250]]; + } + expect(binding.defineSlot(path, sizes, elementId)).toBe(slot); + expect(defineSlot).toHaveBeenCalledTimes(1); + if (mode === 'exact') { + expect(warning).toHaveBeenCalledExactlyOnceWith('GPT publisher handoff metadata mismatch', { + formatsMismatch: true, + pathMismatch: true, + }); + slot.setTargeting('hb_pb', 'publisher'); + } else { + expect(warning).not.toHaveBeenCalled(); + } + + const displayCalls = display.mock.calls.length; + const refreshCalls = refresh.mock.calls.length; + binding.display(elementId); + service.refresh([slot], { changeCorrelator: false } as never); + expect(display).toHaveBeenCalledTimes(displayCalls); + expect(refresh).toHaveBeenCalledTimes(refreshCalls); + binding.display(elementId); + service.refresh([slot], { changeCorrelator: false } as never); + expect(display).toHaveBeenCalledTimes(displayCalls + 1); + expect(refresh).toHaveBeenCalledTimes(refreshCalls + 1); + + expect(adapter.closeIngress(['slot-1'])).toBe(true); + expect(adapter.captureHandoff()?.[0]?.slice(0, 3)).toEqual([ + 'slot-1', + elementId, + 'publisher', + ]); + expect(adapter.captureHandoff()?.[0]?.[3]).toEqual( + mode === 'exact' + ? [ + { installed: RESERVATION_ID, key: 'hb_adid', prior: [] }, + { installed: 'article', key: 'placement', prior: [] }, + ] + : [ + { installed: RESERVATION_ID, key: 'hb_adid', prior: [] }, + { installed: '1.25', key: 'hb_pb', prior: [] }, + { installed: 'article', key: 'placement', prior: [] }, + ] + ); + adapter.dispose(); + expect(binding.destroySlots).not.toHaveBeenCalled(); + } + ); + it('rejects targeting handoff after a publisher replaces an observed method', () => { const dom = new JSDOM('
', { url: 'https://publisher.example/', @@ -613,6 +960,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot: () => undefined, display: () => undefined, @@ -684,6 +1032,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot: () => undefined, display: () => undefined, @@ -766,6 +1115,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot: () => undefined, display: () => undefined, @@ -836,6 +1186,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot: () => undefined, display: () => undefined, @@ -906,6 +1257,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot: () => failedSlot, destroySlots, @@ -989,6 +1341,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot: () => undefined, display: () => undefined, @@ -1052,6 +1405,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot: () => slot, display: () => events.push('display'), @@ -1140,6 +1494,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot: () => slot, display: () => undefined, @@ -1194,6 +1549,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot, display, @@ -1246,6 +1602,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot: () => slot, destroySlots, @@ -1303,6 +1660,7 @@ describe('first-display GPT adapter', () => { Object.defineProperty(dom.window, 'googletag', { configurable: true, value: { + pubadsReady: true, cmd: { push: (command: () => void) => command() }, defineSlot: () => slot, display: () => undefined, @@ -1390,6 +1748,7 @@ describe('first-display GPT adapter', () => { capturedAtMs: 12.5, elementId: 'slot-1', adUnitPath: '/123/example', + requestedSlotSizes: [[300, 250]], isEmpty: null, renderedSize: null, isBackfill: null, @@ -1398,6 +1757,7 @@ describe('first-display GPT adapter', () => { }); expect(diagnostics?.[1][2]).toMatchObject({ event: 'slotRenderEnded', + requestedSlotSizes: null, isEmpty: false, renderedSize: [300, 250], isBackfill: false, diff --git a/crates/trusted-server-js/lib/test/first_display/slices.test.ts b/crates/trusted-server-js/lib/test/first_display/slices.test.ts index 5416466ec..efcbd3de4 100644 --- a/crates/trusted-server-js/lib/test/first_display/slices.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/slices.test.ts @@ -25,7 +25,6 @@ import type { import type { FirstDisplaySliceActivationContext } from '../../src/shared/first_display_transaction'; import type { FirstDisplayRouteRuleV1 } from '../../src/first_display/leaf/route_guard'; import { installDidomiInitial } from '../../src/first_display/leaf/config_guard'; -import type { FirstDisplayCreativeGuardV1 } from '../../src/first_display/leaf/creative_guard'; import { installApsInitial, type FirstDisplayApsProtocolV1, @@ -56,7 +55,6 @@ import { type FirstDisplayComponentRegistrationV1, } from '../../src/shared/first_display_registration'; import { createDidomiRuntime } from '../../src/integrations/didomi/module'; -import { shouldProxyExternalUrl } from '../../src/integrations/creative/proxy_sign'; import { getPermutiveSegments } from '../../src/integrations/permutive/segments'; import { mirrorSourcepointConsent } from '../../src/integrations/sourcepoint/consent_mirror'; import { @@ -813,22 +811,17 @@ describe('first-display initial slice definitions', () => { }; const previous = { ...sdk.config }; const disposers: Array<() => void> = []; - let contributor: (() => Readonly> | undefined) | undefined; let route: FirstDisplayContextRouteRuleV1 | undefined; - const unregisterContext = vi.fn(); const unregisterRoute = vi.fn(); + const observations: Array = []; const bindings = Object.freeze({ clearTimer: () => undefined, getSdk: () => sdk, host: 'publisher.example', - observe: () => undefined, + observe: (name: string, value: string | number) => observations.push([name, value]), origin: 'https://publisher.example', protocol: 'https:', readStorage: (key: string) => localStorage.getItem(key), - registerContext: (candidate: typeof contributor) => { - contributor = candidate; - return unregisterContext; - }, registerRoute: (candidate: FirstDisplayContextRouteRuleV1) => { route = candidate; return unregisterRoute; @@ -855,10 +848,10 @@ describe('first-display initial slice definitions', () => { }) ); - expect(contributor?.()).toEqual({ - permutive_segments: getPermutiveSegments(), - }); - expect((contributor?.()?.permutive_segments as readonly string[]).length).toBe(100); + const segmentObservation = observations.find(([name]) => name === 'segments'); + expect(segmentObservation).toBeDefined(); + expect(JSON.parse(String(segmentObservation?.[1]))).toEqual(getPermutiveSegments()); + expect(JSON.parse(String(segmentObservation?.[1]))).toHaveLength(100); expect(route?.matches('script', 'https://cdn.permutive.com/example-web.js')).toBe(true); expect( route?.matches('script', 'https://cdn.permutive.com.attacker.example/example-web.js') @@ -881,7 +874,6 @@ describe('first-display initial slice definitions', () => { disposers.reverse().forEach((release) => release()); expect(sdk.config).toEqual(previous); - expect(unregisterContext).toHaveBeenCalledOnce(); expect(unregisterRoute).toHaveBeenCalledOnce(); localStorage.clear(); }); @@ -1030,7 +1022,6 @@ describe('first-display initial slice definitions', () => { const timers: Array<() => void> = []; const disposers: Array<() => void> = []; - const unregisterContext = vi.fn(); const unregisterRoute = vi.fn(); installPermutiveInitial( Object.freeze({ @@ -1044,7 +1035,6 @@ describe('first-display initial slice definitions', () => { origin: 'https://publisher.example', protocol: 'https:', readStorage: () => raw, - registerContext: () => unregisterContext, registerRoute: () => unregisterRoute, setTimer: (callback: () => void) => { timers.push(callback); @@ -1057,7 +1047,6 @@ describe('first-display initial slice definitions', () => { disposers.reverse().forEach((release) => release()); expect(timers).toEqual([]); - expect(unregisterContext).toHaveBeenCalledOnce(); expect(unregisterRoute).toHaveBeenCalledOnce(); }); @@ -1123,11 +1112,15 @@ describe('first-display initial slice definitions', () => { expect(document.cookie).toBe(''); }); - it('registers the exact creative parser guard policy and owns its rollback', () => { - const release = vi.fn(); + it('installs the selected creative parser guards and owns their rollback', () => { const observations: Array = []; - let guard: FirstDisplayCreativeGuardV1 | undefined; const disposers: Array<() => void> = []; + const clickHandle = Object.freeze({ dispose: vi.fn(), scan: vi.fn() }); + const imageHandle = Object.freeze({ dispose: vi.fn(), scan: vi.fn() }); + const iframeHandle = Object.freeze({ dispose: vi.fn(), scan: vi.fn() }); + const installClickGuard = vi.fn(() => clickHandle); + const installDynamicImageProxy = vi.fn(() => imageHandle); + const installDynamicIframeProxy = vi.fn(() => iframeHandle); const bindings = Object.freeze({ config: Object.freeze({ version: 1, @@ -1135,15 +1128,11 @@ describe('first-display initial slice definitions', () => { clickGuard: true, renderGuard: true, }), - location: Object.freeze({ - href: 'https://publisher.example/page', - origin: 'https://publisher.example', - }), + document, + installClickGuard, + installDynamicIframeProxy, + installDynamicImageProxy, observe: (name: string, value: number) => observations.push([name, value]), - register: (candidate: FirstDisplayCreativeGuardV1) => { - guard = candidate; - return release; - }, }); const host = Object.freeze({ activate: ( @@ -1164,34 +1153,18 @@ describe('first-display initial slice definitions', () => { afterActivate: () => undefined, }) ); - expect(guard).toBeDefined(); - expect(guard).toMatchObject({ - clickGuard: true, - id: 'creative', - renderGuard: true, - version: 1, - }); - expect(Object.isFrozen(guard)).toBe(true); - expect(guard?.normalizeNavigation('/landing')).toBe('https://publisher.example/landing'); - expect(guard?.normalizeNavigation('javascript:alert(1)')).toBeUndefined(); - expect(guard?.normalizeNavigation('https://user:pass@example.com/')).toBeUndefined(); - expect(guard?.shouldProxyResource('https://cdn.example/pixel.gif')).toBe(true); - expect(guard?.shouldProxyResource('/publisher.png')).toBe(false); - expect(guard?.shouldProxyResource('data:image/gif;base64,a')).toBe(false); - for (const url of [ - 'https://cdn.example/pixel.gif', - 'https://user:pass@cdn.example/pixel.gif', - 'data:image/gif;base64,a', - 'javascript:alert(1)', - 'blob:https://publisher.example/id', - 'not a valid URL', - ]) { - expect(guard?.shouldProxyResource(url)).toBe(shouldProxyExternalUrl(url)); - } + expect(installClickGuard).toHaveBeenCalledOnce(); + expect(installDynamicImageProxy).toHaveBeenCalledOnce(); + expect(installDynamicIframeProxy).toHaveBeenCalledOnce(); + expect(clickHandle.scan).not.toHaveBeenCalled(); + expect(imageHandle.scan).not.toHaveBeenCalled(); + expect(iframeHandle.scan).not.toHaveBeenCalled(); expect(observations).toEqual([['guard_count', 3]]); disposers.reverse().forEach((dispose) => dispose()); - expect(release).toHaveBeenCalledOnce(); + expect(clickHandle.dispose).toHaveBeenCalledOnce(); + expect(imageHandle.dispose).toHaveBeenCalledOnce(); + expect(iframeHandle.dispose).toHaveBeenCalledOnce(); }); it('rejects malformed creative config before installing any initial guard', () => { @@ -1206,7 +1179,7 @@ describe('first-display initial slice definitions', () => { extra: true, }), ]) { - const register = vi.fn(); + const installClickGuard = vi.fn(); const host = Object.freeze({ activate: ( _id: string, @@ -1216,12 +1189,11 @@ describe('first-display initial slice definitions', () => { install?.( Object.freeze({ config, - location: Object.freeze({ - href: 'https://publisher.example/page', - origin: 'https://publisher.example', - }), + document, + installClickGuard, + installDynamicIframeProxy: vi.fn(), + installDynamicImageProxy: vi.fn(), observe: () => undefined, - register, }), own, config @@ -1234,7 +1206,7 @@ describe('first-display initial slice definitions', () => { Object.freeze({ own: () => undefined, afterActivate: () => undefined }) ) ).toThrow(); - expect(register).not.toHaveBeenCalled(); + expect(installClickGuard).not.toHaveBeenCalled(); } }); diff --git a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts index cdf4e43cc..9db761fb3 100644 --- a/crates/trusted-server-js/lib/test/first_display/takeover.test.ts +++ b/crates/trusted-server-js/lib/test/first_display/takeover.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { coordinatePreparedFirstDisplayTakeoverV1, @@ -7,6 +7,10 @@ import { type FinalizedFirstDisplayHandoffV1, } from '../../src/shared/first_display_handoff'; import { snapshotOutlinedFirstDisplayHandoffV1 } from '../../src/shared/first_display_contracts'; +import { + consumeFirstDisplayTakeoverTransport, + installFirstDisplayTakeoverTransport, +} from '../../src/shared/takeover'; const RELEASE_ID = 'a'.repeat(64); const DIGEST = 'b'.repeat(64); @@ -166,6 +170,33 @@ function finalized( } describe('atomic first-display takeover', () => { + it('uses a non-replaceable one-shot rendezvous on the runtime script', () => { + const runtimeScript = {}; + const lease = Object.freeze([]); + const claim = vi.fn(() => lease); + const release = installFirstDisplayTakeoverTransport(runtimeScript, claim as never); + + expect(release).toBeTypeOf('function'); + expect(Object.getOwnPropertyDescriptor(runtimeScript, '_firstDisplayTakeover')).toMatchObject({ + configurable: false, + enumerable: false, + writable: false, + }); + expect(() => + Object.defineProperty(runtimeScript, '_firstDisplayTakeover', { + configurable: true, + value: () => undefined, + }) + ).toThrow(); + + const transport = consumeFirstDisplayTakeoverTransport(runtimeScript); + expect(transport.status).toBe('accepted'); + if (transport.status !== 'accepted') throw new Error('should accept transport'); + expect(transport.claim({}, (() => undefined) as never)).toBe(lease); + expect(transport.claim({}, (() => undefined) as never)).toBeUndefined(); + expect(claim).toHaveBeenCalledOnce(); + }); + it('binds the exact handoff and one-use identities to the prepared persistent barrier', () => { const physicalSlot = {}; const artifact = {}; diff --git a/crates/trusted-server-js/lib/test/fixtures/contracts/current-main-concept-audit.json b/crates/trusted-server-js/lib/test/fixtures/contracts/current-main-concept-audit.json index eec28200b..8f9fd467b 100644 --- a/crates/trusted-server-js/lib/test/fixtures/contracts/current-main-concept-audit.json +++ b/crates/trusted-server-js/lib/test/fixtures/contracts/current-main-concept-audit.json @@ -290,11 +290,11 @@ ] }, "rcBaseline": { - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "rows": [ { "id": "RCJ-CORE-01", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": [ "crates/trusted-server-js/lib/src/core/auction.ts", @@ -308,7 +308,7 @@ }, { "id": "RCJ-CORE-02", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": [ "crates/trusted-server-js/lib/src/core/config.ts", @@ -323,7 +323,7 @@ }, { "id": "RCJ-BOOT-01", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": [ "crates/trusted-server-core/src/html_processor.rs", @@ -336,7 +336,7 @@ }, { "id": "RCJ-TRACE-01", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "implementation-gap", "ownerPaths": ["crates/trusted-server-js/lib/src/core/index.ts"], "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", @@ -346,7 +346,7 @@ }, { "id": "RCJ-GPT-01", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts", @@ -356,7 +356,7 @@ }, { "id": "RCJ-GPT-02", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts", @@ -366,7 +366,7 @@ }, { "id": "RCJ-GPT-03", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], "testPath": "crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts", @@ -376,17 +376,17 @@ }, { "id": "RCJ-GPT-04", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", - "classification": "implementation-gap", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", + "classification": "baseline-owned", "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt/index.ts"], "testPath": "crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", "command": "TSJS_CONCEPT_PROOF_LIB_ROOT=\"$TSJS_RC_BASELINE_ROOT/crates/trusted-server-js/lib\" node --test --test-name-pattern='RCJ-GPT-04' crates/trusted-server-js/lib/test/contract/current-main-concept-gaps.proof.mjs", - "result": "fail", + "result": "pass", "disposition": "rebuild" }, { "id": "RCJ-PREBID-01", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": [ "crates/trusted-server-js/lib/build-prebid-external.mjs", @@ -399,7 +399,7 @@ }, { "id": "RCJ-PREBID-02", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/prebid/index.ts"], "testPath": "crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts", @@ -409,7 +409,7 @@ }, { "id": "RCJ-PREBID-03", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": [ "crates/trusted-server-js/lib/build-prebid-external.mjs", @@ -422,7 +422,7 @@ }, { "id": "RCJ-PREBID-04", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/prebid/index.ts"], "testPath": "crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts", @@ -432,7 +432,7 @@ }, { "id": "RCJ-APS-01", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": [ "crates/trusted-server-core/src/integrations/aps.rs", @@ -445,7 +445,7 @@ }, { "id": "RCJ-APS-02", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": [ "crates/trusted-server-js/lib/src/integrations/aps/render.ts", @@ -459,7 +459,7 @@ }, { "id": "RCJ-APS-03", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "implementation-gap", "ownerPaths": [ "crates/trusted-server-core/src/integrations/aps.rs", @@ -472,7 +472,7 @@ }, { "id": "RCJ-APS-04", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "implementation-gap", "ownerPaths": [ "crates/trusted-server-core/src/integrations/aps.rs", @@ -485,7 +485,7 @@ }, { "id": "RCJ-CREATIVE-01", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": ["crates/trusted-server-core/src/publisher.rs"], "testPath": "crates/trusted-server-core/src/publisher.rs", @@ -495,7 +495,7 @@ }, { "id": "RCJ-CREATIVE-02", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": [ "crates/trusted-server-js/lib/src/integrations/creative/click.ts", @@ -508,7 +508,7 @@ }, { "id": "RCJ-CREATIVE-03", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/creative/index.ts"], "testPath": "crates/trusted-server-js/lib/test/integrations/creative/image.test.ts", @@ -518,7 +518,7 @@ }, { "id": "RCJ-DIAG-01", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": ["crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts"], "testPath": "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts", @@ -528,7 +528,7 @@ }, { "id": "RCJ-INT-01", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": [ "crates/trusted-server-core/src/integrations/testlight.rs", @@ -547,7 +547,7 @@ }, { "id": "RCJ-INT-02", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "baseline-owned", "ownerPaths": [ "crates/trusted-server-js/lib/src/shared/async.ts", @@ -564,7 +564,7 @@ }, { "id": "RCJ-QUAL-01", - "baselineSha": "f0825604ec6740111e99dd8a178e3b880e7d772b", + "baselineSha": "985ff22987d80cc729f45f702e0c3548e429b2cf", "classification": "implementation-gap", "ownerPaths": [ "crates/trusted-server-js/lib/eslint.config.js", 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 dc65c8573..747d5149f 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 @@ -10,20 +10,6 @@ import { validateApsRenderer, } from '../../../src/integrations/aps/render'; -function nativeRunnerState(frame: HTMLIFrameElement): { - runner: HTMLScriptElement; - event: CustomEvent<{ aaxResponse: string; seatBidId: string }>; -} { - const runner = frame.contentDocument?.querySelector('script'); - const frameWindow = frame.contentWindow as unknown as { - _aps: Map> }>; - }; - const account = frameWindow._aps.get('example-account-id'); - expect(runner).not.toBeNull(); - expect(account?.queue).toHaveLength(1); - return { runner: runner!, event: account!.queue[0] }; -} - function encodeBytes(bytes: Uint8Array): string { let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); 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 3000fa96b..024f24475 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 @@ -112,37 +112,27 @@ describe('creative/click.ts', () => { }); expect(anchor.getAttribute('href')).toBe(absolute(PROXY_RESPONSE)); - // data-tsclick keeps the server's root-relative shape: it is echoed back as - // the rebuild payload's `tsclick`, which the server parses as a click path. expect(anchor.getAttribute('data-tsclick')).toBe(PROXY_RESPONSE); }); it('sends a root-relative tsclick on a second rebuild after a successful one', async () => { - // Regression: persisting an absolute canonical click made the next rebuild - // POST a value the server rejects as an invalid click path, so the second - // mutation was silently lost on non-opaque consumers. 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', FIRST_PARTY_CLICK); document.body.appendChild(anchor); - await importCreativeModule(); - + await activateCreativeRuntime(); anchor.setAttribute('href', MUTATED_CLICK); await Promise.resolve(); await vi.runAllTimersAsync(); - expect(anchor.getAttribute('data-tsclick')).toBe(PROXY_RESPONSE); - // A second mutation now diffs against the rebuilt canonical click. anchor.setAttribute('href', 'https://example.com/landing?baz=3'); await Promise.resolve(); await vi.runAllTimersAsync(); @@ -151,11 +141,9 @@ describe('creative/click.ts', () => { const payloads = fetchMock.mock.calls.map( (call) => JSON.parse(call[1]?.body as string) as { tsclick: string } ); - // Every payload must carry the server's root-relative click form: an - // absolute one is rejected as an invalid click path. - for (const payload of payloads) { - expect(payload.tsclick.startsWith('/first-party/click?')).toBe(true); - } + expect(payloads.every((payload) => payload.tsclick.startsWith('/first-party/click?'))).toBe( + true + ); expect(payloads.some((payload) => payload.tsclick === PROXY_RESPONSE)).toBe(true); }); @@ -350,6 +338,82 @@ describe('creative/click.ts', () => { } }); + it('navigates an over-long opaque-origin rebuild through a form POST', async () => { + vi.useFakeTimers(); + const originDescriptor = Object.getOwnPropertyDescriptor(window, 'origin'); + Object.defineProperty(window, 'origin', { value: 'null', configurable: true }); + global.fetch = undefined as unknown as typeof fetch; + const submits: HTMLFormElement[] = []; + const originalSubmit = HTMLFormElement.prototype.submit; + HTMLFormElement.prototype.submit = function patched(this: HTMLFormElement) { + submits.push(this); + }; + + try { + const filler = 'a'.repeat(6800); + const longClick = `/first-party/click?tsurl=https%3A%2F%2Fexample.com%2Flanding&foo=1&pad=${filler}&tstoken=token123`; + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', longClick); + anchor.setAttribute('href', longClick); + document.body.appendChild(anchor); + + await activateCreativeRuntime(); + anchor.setAttribute('href', `https://example.com/landing?pad=${filler}&bar=2`); + await Promise.resolve(); + await vi.runAllTimersAsync(); + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(submits.length).toBeGreaterThan(0); + const form = submits[0]; + expect(form?.method.toLowerCase()).toBe('post'); + expect(form?.action).toContain('/first-party/proxy-rebuild'); + expect(form?.rel).toBe('noopener noreferrer'); + expect(form?.querySelector('input[name="tsclick"]')?.value).toBe(longClick); + expect(form?.querySelector('input[name="add"]')?.value).toContain('bar'); + } finally { + HTMLFormElement.prototype.submit = originalSubmit; + if (originDescriptor) { + Object.defineProperty(window, 'origin', originDescriptor); + } else { + delete (window as { origin?: string }).origin; + } + } + }); + + it('does not form-submit a long non-rebuild URL containing the rebuild path', async () => { + vi.useFakeTimers(); + const submits: HTMLFormElement[] = []; + const originalSubmit = HTMLFormElement.prototype.submit; + HTMLFormElement.prototype.submit = function patched(this: HTMLFormElement) { + submits.push(this); + }; + const openMock = vi.fn(); + const originalOpen = window.open; + window.open = openMock as unknown as typeof window.open; + + try { + const targetUrl = `/first-party/landing?next=/first-party/proxy-rebuild&tsclick=fictional&pad=${'a'.repeat(7000)}`; + const anchor = document.createElement('a'); + anchor.setAttribute('data-tsclick', targetUrl); + anchor.setAttribute('href', targetUrl); + anchor.setAttribute('target', '_blank'); + document.body.appendChild(anchor); + + await activateCreativeRuntime(); + anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + expect(submits).toHaveLength(0); + expect(openMock).toHaveBeenCalledWith(absolute(targetUrl), '_blank', 'noopener,noreferrer'); + } finally { + HTMLFormElement.prototype.submit = originalSubmit; + window.open = originalOpen; + } + }); + 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/gpt/diagnostics_facts.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/diagnostics_facts.test.ts index eae4eae45..e6a5144e5 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 @@ -10,7 +10,10 @@ import { activateGptDiagnosticsEventListeners, activateGptDiagnosticsFactCapture, createGptDiagnosticsFactBuffer, + createTrustedServerOpportunityFact, + publishTrustedServerRequestFact, projectGptTraceFact, + type GptDiagnosticsFact, } from '../../../src/integrations/gpt/diagnostics_facts'; function fact(index: number): Readonly { @@ -25,6 +28,126 @@ function fact(index: number): Readonly { } describe('GPT diagnostics fact transport', () => { + it('publishes one production request-intent fact from the exact projection and slot identity', () => { + const physicalSlot = Object.freeze({}); + const identity = Object.freeze({ + token: Object.freeze({}), + traceToken: 'gt1_1' as never, + runtimeSlotNumber: 1, + elementId: 'slot-1', + adUnitPath: '/123/slot-1', + }); + const publish = vi.fn(() => true); + const projection = Object.freeze({ + version: 1 as const, + auction: Object.freeze({ + version: 1 as const, + auctionId: 'auction-1', + results: Object.freeze([ + Object.freeze({ + slot: 'slot-1', + outcome: 'winner' as const, + candidateId: 'candidate001', + }), + ]), + }), + slots: Object.freeze([ + Object.freeze({ + slot: 'slot-1', + gamUnitPath: '/123/slot-1', + divId: 'slot-1', + formats: Object.freeze([ + Object.freeze([300, 250] as const), + Object.freeze([728, 90] as const), + ]), + targeting: Object.freeze({}), + }), + ]), + bids: Object.freeze([ + Object.freeze({ + candidateId: 'candidate001', + slot: 'slot-1', + provider: 'gpt', + upstreamBidId: 'upstream-1', + cpm: 1, + currency: 'USD' as const, + targeting: Object.freeze({}), + rendererReservationId: `r1_${'a'.repeat(22)}`, + renderSource: Object.freeze({ + type: 'adm' as const, + version: 1 as const, + adm: '
creative
', + width: 300, + height: 250, + }), + }), + ]), + }); + + expect( + publishTrustedServerRequestFact({ + adapter: { diagnosticsIdentity: (slot) => (slot === physicalSlot ? identity : undefined) }, + buffer: { publish }, + physicalSlot, + projection, + registeredSlotId: 'slot-1', + }) + ).toBe(true); + expect(publish).toHaveBeenCalledExactlyOnceWith({ + kind: 'trustedServerOpportunity', + auctionSlotId: 'slot-1', + opportunity: 'renderable_candidate', + requestedSlotSizes: [ + [300, 250], + [728, 90], + ], + slot: identity, + trustedServerAuctionId: 'auction-1', + }); + }); + + it('copies and delivers exact Trusted Server requested-size evidence before GPT callbacks', () => { + const slot = Object.freeze({ + token: Object.freeze(Object.create(null) as object), + traceToken: 'gt1_1' as never, + runtimeSlotNumber: 1, + cycleOrdinal: 1 as never, + elementId: 'fictional-slot', + }); + const formats: Array<[number, number]> = [ + [300, 250], + [728, 90], + ]; + const opportunity = createTrustedServerOpportunityFact({ + auctionSlotId: 'fictional-slot', + opportunity: 'renderable_candidate', + requestedSlotSizes: formats, + slot, + trustedServerAuctionId: 'fictional-auction', + }); + formats[0]![0] = 1; + const received: Readonly[] = []; + const buffer = createGptDiagnosticsFactBuffer(); + + expect(opportunity).toEqual({ + kind: 'trustedServerOpportunity', + auctionSlotId: 'fictional-slot', + opportunity: 'renderable_candidate', + requestedSlotSizes: [ + [300, 250], + [728, 90], + ], + slot, + trustedServerAuctionId: 'fictional-auction', + }); + expect(Object.isFrozen(opportunity)).toBe(true); + expect(Object.isFrozen(opportunity?.requestedSlotSizes)).toBe(true); + expect(Object.isFrozen(opportunity?.requestedSlotSizes?.[0])).toBe(true); + expect(buffer.publish(opportunity!)).toBe(true); + buffer.activate((candidate) => received.push(candidate)); + expect(received).toEqual([opportunity]); + }); + it('projects only the data-safe exact trace identity and preserves event fields', () => { const opaqueToken = Object.freeze(Object.create(null) as object); const projected = projectGptTraceFact( @@ -134,6 +257,7 @@ describe('GPT diagnostics fact transport', () => { capturedAtMs: 1, elementId: 'slot-1', adUnitPath: '/example/slot-1', + requestedSlotSizes: Object.freeze([Object.freeze([300, 250] as const)]), isEmpty: null, renderedSize: null, isBackfill: null, @@ -147,6 +271,7 @@ describe('GPT diagnostics fact transport', () => { ...base, event: 'slotRenderEnded' as const, capturedAtMs: 2, + requestedSlotSizes: null, isEmpty: false, renderedSize: Object.freeze([300, 250] as const), isBackfill: false, @@ -156,25 +281,41 @@ describe('GPT diagnostics fact transport', () => { overflowCount: 7, dropCount: 3, }); - const received: Readonly[] = []; + const received: Readonly[] = []; expect( - buffer.adoptFirstDisplay(adopted, (traceToken) => - traceToken === 'gt1_5' - ? Object.freeze({ - token: transferredToken, - traceToken: 'gt1_5' as never, - runtimeSlotNumber: 5, - cycleOrdinal: 1 as never, - elementId: 'slot-1', - adUnitPath: '/example/slot-1', - }) - : undefined + buffer.adoptFirstDisplay( + adopted, + (traceToken) => + traceToken === 'gt1_5' + ? Object.freeze({ + token: transferredToken, + traceToken: 'gt1_5' as never, + runtimeSlotNumber: 5, + cycleOrdinal: 1 as never, + elementId: 'slot-1', + adUnitPath: '/example/slot-1', + }) + : undefined, + (traceToken, slot) => + traceToken === 'gt1_5' + ? createTrustedServerOpportunityFact({ + auctionSlotId: 'slot-1', + opportunity: 'renderable_candidate', + requestedSlotSizes: [[300, 250]], + slot, + }) + : undefined ) ).toBe(true); const release = buffer.activate((value) => received.push(value)); - expect(received).toHaveLength(2); - expect(received.map((value) => projectGptTraceFact(value))).toEqual([ + expect(received).toHaveLength(3); + expect(received[0]).toMatchObject({ + kind: 'trustedServerOpportunity', + auctionSlotId: 'slot-1', + requestedSlotSizes: [[300, 250]], + }); + expect(received.slice(1).map((value) => projectGptTraceFact(value as never))).toEqual([ { kind: 'slotRequested', observedAtMs: 1, @@ -189,6 +330,7 @@ describe('GPT diagnostics fact transport', () => { ]); expect(typeof received[0]?.slot.token).toBe('object'); expect(received[0]?.slot.token).toBe(received[1]?.slot.token); + expect(received[0]?.slot.token).toBe(received[2]?.slot.token); expect(received[0]?.slot.token).toBe(transferredToken); expect(received[0]?.slot.runtimeSlotNumber).toBe(5); expect(Object.isFrozen(received[0])).toBe(true); @@ -203,6 +345,83 @@ describe('GPT diagnostics fact transport', () => { ).toBe(false); }); + it('rehydrates separate cycles for one transferred physical GPT slot', () => { + const buffer = createGptDiagnosticsFactBuffer(); + const transferredToken = Object.freeze(Object.create(null) as object); + const transferredFact = ( + cycleOrdinal: number, + event: 'slotRequested' | 'slotRenderEnded', + capturedAtMs: number + ) => + Object.freeze({ + version: 1 as const, + event, + token: 'gt1_5', + runtimeSlotNumber: 5, + cycleOrdinal, + disposition: 'matched' as const, + issueReason: null, + capturedAtMs, + elementId: 'slot-1', + adUnitPath: '/example/slot-1', + requestedSlotSizes: + event === 'slotRequested' ? Object.freeze([Object.freeze([300, 250] as const)]) : null, + isEmpty: event === 'slotRenderEnded' ? false : null, + renderedSize: event === 'slotRenderEnded' ? Object.freeze([300, 250] as const) : null, + isBackfill: null, + slotContentChanged: null, + visibilityPercent: null, + }); + const received: Readonly[] = []; + + expect( + buffer.adoptFirstDisplay( + Object.freeze({ + facts: Object.freeze([ + transferredFact(1, 'slotRequested', 1), + transferredFact(1, 'slotRenderEnded', 2), + transferredFact(2, 'slotRequested', 3), + transferredFact(2, 'slotRenderEnded', 4), + ]), + overflowCount: 0, + dropCount: 0, + }), + () => + Object.freeze({ + token: transferredToken, + traceToken: 'gt1_5' as never, + runtimeSlotNumber: 5, + cycleOrdinal: 2 as never, + elementId: 'slot-1', + adUnitPath: '/example/slot-1', + }), + (_traceToken, slot) => + createTrustedServerOpportunityFact({ + auctionSlotId: 'slot-1', + opportunity: 'renderable_candidate', + requestedSlotSizes: [[300, 250]], + slot, + }) + ) + ).toBe(true); + buffer.activate((value) => received.push(value)); + + expect( + received.map((value) => ({ + kind: value.kind, + cycleOrdinal: value.slot.cycleOrdinal, + })) + ).toEqual([ + { kind: 'trustedServerOpportunity', cycleOrdinal: 1 }, + { kind: 'slotRequested', cycleOrdinal: 1 }, + { kind: 'slotRenderEnded', cycleOrdinal: 1 }, + { kind: 'trustedServerOpportunity', cycleOrdinal: 2 }, + { kind: 'slotRequested', cycleOrdinal: 2 }, + { kind: 'slotRenderEnded', cycleOrdinal: 2 }, + ]); + expect(received.every((value) => value.slot.token === transferredToken)).toBe(true); + }); + it('rejects malformed first-display facts without consuming adoption', () => { const buffer = createGptDiagnosticsFactBuffer(); const malformed = Object.freeze({ @@ -218,6 +437,7 @@ describe('GPT diagnostics fact transport', () => { capturedAtMs: 1, elementId: null, adUnitPath: null, + requestedSlotSizes: null, isEmpty: null, renderedSize: null, isBackfill: null, 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 84209ac41..d9f307a74 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 @@ -96,14 +96,12 @@ describe('GPT first-display diagnostics adoption', () => { }); it('restores diagnostics facts only when the diagnostics owner is selected', () => { - const adoptFirstDisplay = vi.fn( - (_diagnostics: unknown, _resolve?: (token: string) => unknown) => true - ); + const adoptFirstDisplay = vi.fn((..._arguments: unknown[]) => true); const physicalSlot = {}; const diagnosticsToken = Object.freeze(Object.create(null) as object); const fact = Object.freeze({ version: 1 as const, - event: 'slotRenderEnded' as const, + event: 'slotRequested' as const, token: 'gt1_1', runtimeSlotNumber: 1, cycleOrdinal: 1, @@ -112,10 +110,11 @@ describe('GPT first-display diagnostics adoption', () => { capturedAtMs: 5, elementId: 'slot-1', adUnitPath: '/123/slot-1', - isEmpty: false, - renderedSize: Object.freeze([300, 250] as const), - isBackfill: false, - slotContentChanged: true, + requestedSlotSizes: Object.freeze([Object.freeze([300, 250] as const)]), + isEmpty: null, + renderedSize: null, + isBackfill: null, + slotContentChanged: null, visibilityPercent: null, }); const diagnostics = Object.freeze({ @@ -139,19 +138,42 @@ describe('GPT first-display diagnostics adoption', () => { adoptInitialDisplay: true as const, handoff: Object.freeze({ artifacts: Object.freeze([]), - cycles: Object.freeze([Object.freeze({ token: 'gt1_1' })]), + cycles: Object.freeze([Object.freeze({ slotId: 'slot-1', token: 'gt1_1' })]), gptDiagnostics: diagnostics, + slots: Object.freeze([ + Object.freeze({ id: 'slot-1', formats: Object.freeze([Object.freeze([300, 250])]) }), + ]), }), identities: Object.freeze([physicalSlot]), }); - expect(adoptInitialGptFactsFromHandoff(adoption, { adoptFirstDisplay }, adapter)).toBe( - adoption + const projection = Object.freeze({ + auction: Object.freeze({ auctionId: 'initial-auction' }), + }) as unknown as Readonly; + expect( + adoptInitialGptFactsFromHandoff(adoption, { adoptFirstDisplay }, adapter, projection) + ).toBe(adoption); + expect(adoptFirstDisplay).toHaveBeenCalledWith( + diagnostics, + expect.any(Function), + expect.any(Function) ); - expect(adoptFirstDisplay).toHaveBeenCalledWith(diagnostics, expect.any(Function)); const resolve = adoptFirstDisplay.mock.calls[0]?.[1] as (token: string) => unknown; expect(resolve('gt1_1')).toMatchObject({ token: diagnosticsToken, runtimeSlotNumber: 1 }); - expect(adoptInitialGptFactsFromHandoff(adoption, undefined, adapter)).toBeUndefined(); + const resolveOpportunity = adoptFirstDisplay.mock.calls[0]?.[2] as ( + token: string, + slot: Readonly + ) => unknown; + expect(resolveOpportunity('gt1_1', resolve('gt1_1') as Readonly)).toMatchObject({ + kind: 'trustedServerOpportunity', + auctionSlotId: 'slot-1', + opportunity: 'renderable_candidate', + requestedSlotSizes: [[300, 250]], + trustedServerAuctionId: 'initial-auction', + }); + expect( + adoptInitialGptFactsFromHandoff(adoption, undefined, adapter, projection) + ).toBeUndefined(); }); it('transfers only lifecycle-ticket tombstones to the persistent PUC owner', () => { @@ -1573,14 +1595,17 @@ describe('transactional GPT integration module', () => { : undefined, }); const physical = new Map(); - const request = vi.fn(() => + const requestHandle = () => Object.freeze({ status: 'active' as const, result: Promise.resolve( Object.freeze({ status: 'failed' as const, reason: 'gpt_request_failed' as const }) ), dispose: vi.fn(), - }) + }); + const request = vi.fn(requestHandle); + const requestBatch = vi.fn((inputs: readonly unknown[]) => + Object.freeze(inputs.map(() => requestHandle())) ); const slots = Object.freeze({ adoptGptSlot: ( @@ -1595,6 +1620,7 @@ describe('transactional GPT integration module', () => { physical.get(registeredSlotId) === slot, recordPublisherDestruction: vi.fn(() => true), request, + requestBatch, }); const targeting = Object.freeze({ observePublisherMutations: () => @@ -1631,6 +1657,7 @@ describe('transactional GPT integration module', () => { expect(Object.isFrozen(latches)).toBe(true); expect(latches).toHaveLength(2); expect(request).not.toHaveBeenCalled(); + expect(requestBatch).not.toHaveBeenCalled(); protectedLatches = latches; return true; }); @@ -1665,7 +1692,19 @@ describe('transactional GPT integration module', () => { }); expect(protect).toHaveBeenCalledOnce(); - expect(request).toHaveBeenCalledTimes(2); + expect(request).not.toHaveBeenCalled(); + expect(requestBatch).toHaveBeenCalledOnce(); + const requestInputs = requestBatch.mock.calls[0]?.[0] as readonly Readonly<{ + operation: string; + registeredSlotId: string; + }>[]; + expect(Object.isFrozen(requestInputs)).toBe(true); + expect( + requestInputs.map(({ operation, registeredSlotId }) => [registeredSlotId, operation]) + ).toEqual([ + ['slot-1', 'display'], + ['slot-2', 'display'], + ]); await Promise.allSettled([...(protectedLatches ?? [])]); artifacts.dispose(); reservations.dispose(); @@ -2121,6 +2160,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), + enableServices: () => undefined, transactionalDefine: () => Object.freeze({ status: 'discarded' as const }), display: vi.fn(), getTargeting: (target: object, key: string) => (target as typeof slot).getTargeting(key), @@ -2160,7 +2200,17 @@ describe('ordered GPT winner publication', () => { }), request: vi.fn((input: unknown) => { order.push('request'); - expect(input).toMatchObject({ registeredSlotId: bid.slot }); + expect(input).toMatchObject({ + registeredSlotId: bid.slot, + trustedServerOpportunity: { + requestedSlotSizes: [[300, 250]], + trustedServerAuctionId: 'gpt-publication', + }, + }); + const opportunity = (input as { trustedServerOpportunity: object }) + .trustedServerOpportunity; + expect(Object.isFrozen(opportunity)).toBe(true); + expect(Object.isFrozen(Reflect.get(opportunity, 'requestedSlotSizes'))).toBe(true); expect(harness.reservations.recognize(RESERVATION_ID)).toMatchObject({ recognized: true, state: 'renderable', 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 0bded1747..c7e31c86e 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 @@ -41,6 +41,41 @@ function readBlob(blob: Blob): Promise { }); } +function emptyCoverage() { + return { + slotRequested: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + slotResponseReceived: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + slotRenderEnded: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + slotOnload: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + impressionViewable: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + slotVisibilityChanged: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, + }; +} + +function fakeApiStore() { + return { + snapshot: vi.fn(() => ({ + gptObserved: false, + slots: [], + callbackIssues: [], + attributionIssues: [], + coverage: emptyCoverage(), + metadata: { + droppedCallbacks: 0, + droppedAttributionIssues: 0, + evictedSlots: 0, + evictedRequestCycles: 0, + }, + })), + subscribeCommits: vi.fn(() => () => undefined), + recordTrustedServerOpportunity: vi.fn(), + recordPrebidRefresh: vi.fn(), + recordTrustedServerCreativeRequest: vi.fn((_auctionSlotId: string) => 41), + recordTrustedServerCreativeResponse: vi.fn(), + recordTrustedServerCreativeFailure: vi.fn(), + }; +} + function scheduleInto(tasks: Array<() => void>): (callback: () => void) => () => void { return (callback) => { tasks.push(callback); 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 eceb6db73..17cfbe7a7 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,8 @@ function runFrame(frames: Array<() => void>): void { frame(); } +const gptDiagnosticsBadgeTextForTest = formatGptDiagnosticsBadgeText; + function queueFrame(frames: Array<() => void>): (callback: () => void) => () => void { return (callback) => { frames.push(callback); @@ -279,7 +281,7 @@ describe('GptDiagnosticsBadgeManager', () => { 'Filled · Req 728×90, 970×250 · Fill 728×90 · Box 980×270\nResponse 276 ms · Render 42 ms\nViewable after 1 s' ); expect( - gptDiagnosticsBadgeTextForTest({ + formatGptDiagnosticsBadgeText({ requestNumber: 1, isEmpty: false, requestedSlotSizes: [ @@ -293,7 +295,7 @@ describe('GptDiagnosticsBadgeManager', () => { }) ).toBe('Filled · Req 300×250, 320×50, 728×90 +1'); expect( - formatGptDiagnosticsBadgeText({ + gptDiagnosticsBadgeTextForTest({ requestNumber: 1, isEmpty: true, incompleteSequence: false, 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 deleted file mode 100644 index db827cbb5..000000000 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import { describe, expect, it, vi } from 'vitest'; - -const bootstrapPath = resolve( - process.cwd(), - '../../trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js' -); -const bootstrapSource = readFileSync(bootstrapPath, 'utf8'); -describe('GPT diagnostics activation ownership', () => { - 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(/__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); - }); -}); 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 0128d2238..0c25e1ea9 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 @@ -7,19 +7,19 @@ import type { import { GptDiagnosticsObserver, type GptDiagnosticsObserverStore, - type GptObserverWindow, } from '../../../src/integrations/gpt_diagnostics/observer'; +import type { GptDiagnosticsFact } from '../../../src/integrations/gpt/diagnostics_facts'; function fakeStore(): GptDiagnosticsObserverStore { return { markGptObserved: vi.fn(), + recordTrustedServerOpportunity: vi.fn(), recordSlotRequested: vi.fn(), recordSlotResponseReceived: vi.fn(), recordSlotRenderEnded: vi.fn(), recordSlotOnload: vi.fn(), recordImpressionViewable: vi.fn(), recordSlotVisibilityChanged: vi.fn(), - recordPublisherRefresh: vi.fn(), }; } @@ -40,6 +40,31 @@ function fact( } describe('GptDiagnosticsObserver', () => { + it('records requested-size evidence without claiming a GPT callback observation', () => { + const store = fakeStore(); + const observer = new GptDiagnosticsObserver(store); + const slot = fakeSlot(); + const opportunity = Object.freeze({ + kind: 'trustedServerOpportunity' as const, + auctionSlotId: 'fictional-slot', + opportunity: 'renderable_candidate' as const, + requestedSlotSizes: Object.freeze([Object.freeze([300, 250] as const)]), + slot, + trustedServerAuctionId: 'fictional-auction', + }); + + observer.consume(opportunity as Readonly); + + expect(store.markGptObserved).not.toHaveBeenCalled(); + expect(store.recordTrustedServerOpportunity).toHaveBeenCalledWith( + slot, + 'fictional-slot', + 'renderable_candidate', + 'fictional-auction', + [[300, 250]] + ); + }); + it('does not claim GPT observation merely because the diagnostics module activated', () => { const store = fakeStore(); const observer = new GptDiagnosticsObserver(store); 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 17096f8be..b468308d4 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 @@ -62,6 +62,19 @@ function runNextFrame(frames: Array<() => void>): void { frame(); } +function requireAttempt(attemptId: number | undefined): number { + if (attemptId === undefined) throw new Error('Missing Trusted Server creative attempt'); + return attemptId; +} + +function slotArticle(root: ShadowRoot, slotElementId: string): HTMLElement { + const article = Array.from(root.querySelectorAll('.tsgd-slot')).find((candidate) => + candidate.textContent?.includes(slotElementId) + ); + if (!article) throw new Error(`Missing ${slotElementId} diagnostics article`); + return article; +} + function queueFrame(frames: Array<() => void>): (callback: () => void) => () => void { return (callback) => { frames.push(callback); @@ -232,7 +245,7 @@ describe('GptDiagnosticsOverlay', () => { let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, bindings, { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, @@ -372,7 +385,7 @@ describe('GptDiagnosticsOverlay', () => { let root: ShadowRoot | undefined; const overlay = new GptDiagnosticsOverlay(store, new FakeBindings(), { - scheduleFrame: (callback) => frames.push(callback), + scheduleFrame: queueFrame(frames), onShadowRoot: (createdRoot) => { root = createdRoot; }, diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/presentation.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/presentation.test.ts index 5bd66abb2..c1fa38212 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/presentation.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/presentation.test.ts @@ -170,6 +170,11 @@ describe('deferred GPT diagnostics presentation integration', () => { drainFrames(); expect(controller.api.snapshot().slots[0]?.binding).toEqual({ status: 'bound' }); }); + store.recordSlotRenderEnded(slotToken, { isEmpty: false, size: [300, 250] }, 2); + await vi.waitFor(() => { + drainFrames(); + expect(controller.api.snapshot().slots[0]?.requests[0]?.observedSlotSize).toEqual([300, 250]); + }); const badgeRenderCount = (): number => replaceChildren.mock.calls.filter((nodes) => nodes.some( diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts index e2d84ada1..7b958fbb1 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/slot_size_observer.test.ts @@ -90,10 +90,10 @@ describe('GptDiagnosticsSlotSizeObserver', () => { scheduleFrame: (callback) => callback(), }); - expect(store.recordObservedSlotSize).toHaveBeenCalledWith(1, 1, [728, 90]); + expect(store.recordObservedSlotSize).toHaveBeenCalledWith(1, 1, [728, 91]); expect(requests[0]!.size).toEqual([1, 1]); - getBoundingClientRect.mockReturnValue({ width: 970, height: 250 } as DOMRect); + getBoundingClientRect.mockReturnValue({ width: 969.6, height: 250.2 } as DOMRect); ResizeObserverMock.instances[ResizeObserverMock.instances.length - 1]!.emit(element); expect(store.recordObservedSlotSize).toHaveBeenLastCalledWith(1, 1, [970, 250]); observer.destroy(); 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 e90d4f3cc..992938a55 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 @@ -10,7 +10,6 @@ import { MAX_REQUESTED_SLOT_SIZES, MAX_REQUEST_CYCLES_PER_SLOT, MAX_TRUSTED_SERVER_ASSOCIATIONS, - REQUEST_PATH_ATTRIBUTION_WINDOW_MS, TRUSTED_SERVER_ATTRIBUTION_WINDOW_MS, type GptDiagnosticsSlotLike, } from '../../../src/integrations/gpt_diagnostics/store'; @@ -30,32 +29,6 @@ function associateSlot( store.recordTrustedServerOpportunity(slot, auctionSlotId, 'renderable_candidate'); } -function recordCompletedAttempts( - store: GptDiagnosticsStore, - count: number, - prefix: string -): number[] { - const attemptIds: number[] = []; - let remaining = count; - - for (let slotIndex = 0; remaining > 0; slotIndex += 1) { - const slot = fakeSlot(`${prefix}-slot-${slotIndex}`); - const auctionSlotId = `${prefix}-auction-${slotIndex}`; - associateSlot(store, slot, auctionSlotId); - const cycles = Math.min(MAX_REQUEST_CYCLES_PER_SLOT, remaining); - for (let cycleIndex = 0; cycleIndex < cycles; cycleIndex += 1) { - store.recordSlotRequested(slot); - const attemptId = store.recordTrustedServerCreativeRequest(auctionSlotId); - expect(attemptId).toEqual(expect.any(Number)); - attemptIds.push(attemptId!); - store.recordTrustedServerCreativeResponse(attemptId!); - remaining -= 1; - } - } - - return attemptIds; -} - function assertCoverageEquation(store: GptDiagnosticsStore): void { for (const counters of Object.values(store.snapshot().coverage)) { expect(counters.observed).toBe(counters.matched + counters.unmatched + counters.ambiguous); @@ -517,7 +490,7 @@ describe('GptDiagnosticsStore', () => { expect(() => store.recordPublisherRefresh([null, 7, undefined, slot] as never)).not.toThrow(); store.recordSlotRequested(slot); - expect(store.snapshot().slots[0].requests[0].requestPath).toBe('publisher_refresh'); + expect(store.snapshot().slots[0]!.requests[0]!.requestPath).toBe('publisher_refresh'); expect(store.snapshot().slots).toHaveLength(1); }); @@ -577,7 +550,7 @@ describe('GptDiagnosticsStore', () => { now = 1; record(store, slot); - expect(store.snapshot().slots[0].requests[0].incompleteSequence).toBe(true); + expect(store.snapshot().slots[0]!.requests[0]!.incompleteSequence).toBe(true); expect(store.snapshot().callbackIssues).toContainEqual( expect.objectContaining({ kind, disposition: 'matched', reason: 'invalid_event_order' }) ); @@ -594,8 +567,8 @@ describe('GptDiagnosticsStore', () => { store.recordSlotVisibilityChanged(slot, percentage); const snapshot = store.snapshot(); - expect(snapshot.slots[0].currentVisibilityPercentage).toBeUndefined(); - expect(snapshot.slots[0].maximumVisibilityPercentage).toBeUndefined(); + expect(snapshot.slots[0]!.currentVisibilityPercentage).toBeUndefined(); + expect(snapshot.slots[0]!.maximumVisibilityPercentage).toBeUndefined(); expect(snapshot.callbackIssues).toContainEqual( expect.objectContaining({ kind: 'slotVisibilityChanged', @@ -615,7 +588,7 @@ describe('GptDiagnosticsStore', () => { store.recordTrustedServerCreativeFailure(4242, 'cache_fetch_failed'); - expect(store.snapshot().slots[0].requests[0].trustedServerCreativeFailures).toBeUndefined(); + expect(store.snapshot().slots[0]!.requests[0]!.trustedServerCreativeFailures).toBeUndefined(); expect(last(store.snapshot().attributionIssues)?.reason).toBe('creative_attempt_unknown'); }); @@ -649,7 +622,7 @@ describe('GptDiagnosticsStore', () => { deferred.shift()!.callback(); expect(deferred, 'no boundary remains once every candidate crossed it').toHaveLength(0); for (const slot of store.snapshot().slots) { - expect(slot.requests[0].delivery).toBe('candidate_unconfirmed'); + expect(slot.requests[0]!.delivery).toBe('candidate_unconfirmed'); } }); @@ -683,6 +656,36 @@ describe('GptDiagnosticsStore', () => { expect(cycles[1]?.requestedSlotSizes).toBeUndefined(); }); + it('correlates release-private snapshots through their shared opaque slot token', () => { + const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); + const token = Object.freeze(Object.create(null) as object); + const opportunitySlot = Object.freeze({ + token, + elementId: 'token-correlated-slot', + adUnitPath: '/example/token-correlated-slot', + }); + const callbackSlot = Object.freeze({ + token, + elementId: 'token-correlated-slot', + adUnitPath: '/example/token-correlated-slot', + }); + + store.recordTrustedServerOpportunity( + opportunitySlot, + 'auction-slot', + 'renderable_candidate', + 'fictional-auction', + [[300, 250]] + ); + store.recordSlotRequested(callbackSlot, 11); + + expect(store.snapshot().slots[0]?.requests[0]).toMatchObject({ + requestedSlotSizes: [[300, 250]], + trustedServerAuctionId: 'fictional-auction', + trustedServerOpportunity: 'renderable_candidate', + }); + }); + it('bounds and validates requested sizes before retaining them', () => { const store = new GptDiagnosticsStore({ now: () => 10, defer: () => undefined }); const slot = fakeSlot('validated-requested-sizes'); @@ -692,6 +695,8 @@ describe('GptDiagnosticsStore', () => { ); formats[0] = [0, 250]; formats[1] = [300, Number.NaN]; + formats[2] = [1.5, 250]; + formats[3] = [4_097, 250]; store.recordTrustedServerOpportunity( slot, @@ -703,9 +708,11 @@ describe('GptDiagnosticsStore', () => { store.recordSlotRequested(slot); const requested = store.snapshot().slots[0]!.requests[0]!.requestedSlotSizes; - expect(requested).toHaveLength(MAX_REQUESTED_SLOT_SIZES - 2); + expect(requested).toHaveLength(MAX_REQUESTED_SLOT_SIZES - 4); expect(requested).not.toContainEqual([0, 250]); expect(requested).not.toContainEqual([300, Number.NaN]); + expect(requested).not.toContainEqual([1.5, 250]); + expect(requested).not.toContainEqual([4_097, 250]); expect(requested).not.toContainEqual([MAX_REQUESTED_SLOT_SIZES + 1, 250]); }); }); diff --git a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts index 5a70995c8..2d1d89899 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts @@ -12,7 +12,6 @@ import type { GptDiagnosticsResponseClass, GptDiagnosticsSlotExport, Size, - TsjsApi, } from '../../../src/core/types'; describe('GPT diagnostics public types', () => { @@ -49,31 +48,6 @@ describe('GPT diagnostics public types', () => { expectTypeOf(readOnlyApi).toEqualTypeOf(); }); - it('accepts legacy V1 snapshots without attribution evidence', () => { - const legacySnapshot: GptDiagnosticsExportV1 = { - version: 1, - capturedAt: '2026-08-04T00:00:00.000Z', - page: { origin: 'https://example.com', pathname: '/' }, - slots: [], - callbackIssues: [], - coverage: { - slotRequested: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotResponseReceived: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotRenderEnded: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotOnload: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - impressionViewable: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - slotVisibilityChanged: { observed: 0, matched: 0, unmatched: 0, ambiguous: 0 }, - }, - metadata: { - droppedCallbacks: 0, - evictedSlots: 0, - evictedRequestCycles: 0, - }, - }; - - expect(legacySnapshot.version).toBe(1); - }); - it('keeps evidence writers off the operator API and on the internal channel', () => { expectTypeOf().toEqualTypeOf< 'snapshot' | 'export' | 'subscribe' | 'show' | 'hide' @@ -85,10 +59,6 @@ describe('GPT diagnostics public types', () => { | 'recordTrustedServerCreativeResponse' | 'recordTrustedServerCreativeFailure' >(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf< - GptDiagnosticsRecorder | undefined - >(); }); it('represents the versioned allowlist schema', () => { @@ -182,6 +152,7 @@ describe('GPT diagnostics public types', () => { expectTypeOf(evidenceSnapshot.attributionIssues).toEqualTypeOf< readonly Readonly[] >(); + expectTypeOf(evidenceSnapshot.metadata.droppedAttributionIssues).toEqualTypeOf(); expectTypeOf().toEqualTypeOf< | 'creative_request_without_slot' | 'creative_request_without_cycle' 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 index 3408ac40d..bf89c3e1c 100644 --- a/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/permutive/module.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createPermutiveRuntime } from '../../../src/integrations/permutive/module'; +import { + createPermutiveIntegrationRegistration, + createPermutiveRuntime, +} from '../../../src/integrations/permutive/module'; +import type { RuntimeCapabilityV1 } from '../../../src/kernel/runtime'; + +const RELEASE_ID = 'a'.repeat(64); describe('transactional Permutive integration module', () => { afterEach(() => vi.useRealTimers()); @@ -34,6 +40,60 @@ describe('transactional Permutive integration module', () => { expect(order).toEqual(['guard:install', 'context:register', 'context:release', 'guard:reset']); }); + it('uses the adopted parser-time segments for the first persistent auction', () => { + localStorage.removeItem('permutive-app'); + let contributor: (() => Readonly> | undefined) | undefined; + const runtime = Object.freeze({ + registerAuctionContext: (_id: string, candidate: typeof contributor) => { + contributor = candidate; + return vi.fn(); + }, + }) as unknown as RuntimeCapabilityV1; + const preparationDisposers: Array<() => void> = []; + const activationDisposers: Array<() => void> = []; + const controller = new AbortController(); + const registration = createPermutiveIntegrationRegistration(RELEASE_ID); + if (registration.phase !== 'takeover') throw new TypeError('Expected takeover registration'); + const prepared = registration.prepareSync({ + config: Object.freeze({}), + interfaces: Object.freeze({ 'runtime.v1': runtime }), + signal: controller.signal, + onDispose: (callback: () => void) => preparationDisposers.push(callback), + }); + const adoption = Object.freeze({ + version: 1 as const, + adoptInitialDisplay: true as const, + handoff: Object.freeze({ + slices: Object.freeze(['first_display', 'permutive_initial']), + parserState: Object.freeze([ + Object.freeze({ + sliceId: 'permutive_initial', + observations: Object.freeze(['segments']), + values: Object.freeze([ + Object.freeze(['segments', JSON.stringify(['initial-one', 'initial-two'])] as const), + ]), + }), + ]), + }), + identities: Object.freeze([]), + }); + + prepared.activate({ + adoption, + afterCommit: vi.fn(), + signal: controller.signal, + onDispose: (callback: () => void) => activationDisposers.push(callback), + }); + + expect(contributor?.()).toEqual({ + permutive_segments: ['initial-one', 'initial-two'], + }); + expect(contributor?.()).toBeUndefined(); + + activationDisposers.reverse().forEach((release) => release()); + preparationDisposers.reverse().forEach((release) => release()); + }); + it('bounds a context-service segment snapshot even when an injected reader overproduces', () => { let contributor: (() => Readonly> | undefined) | undefined; const runtime = createPermutiveRuntime({ diff --git a/crates/trusted-server-js/lib/test/integrations/phase-slices.test.ts b/crates/trusted-server-js/lib/test/integrations/phase-slices.test.ts index 5d6eeacae..6ecdcbfb6 100644 --- a/crates/trusted-server-js/lib/test/integrations/phase-slices.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/phase-slices.test.ts @@ -275,7 +275,10 @@ describe('canonical takeover and deferred product slices', () => { ).toBe(false); expect( [...coreSources].filter((source) => source.startsWith('src/integrations/')).sort() - ).toEqual(['src/integrations/render_runtime/module.ts']); + ).toEqual([ + 'src/integrations/render_runtime/module.ts', + 'src/integrations/render_runtime/prebid_selection.ts', + ]); for (const [, request] of DEFERRED_FACTORIES) { const entry = `${request.replace('../../', 'src/').replace(/^src\/src\//, 'src/')}.ts`; @@ -287,4 +290,12 @@ describe('canonical takeover and deferred product slices', () => { expect([...sources].some((source) => source.endsWith('kernel/runtime.ts'))).toBe(false); } }); + + it('keeps the shared parser route owner in bootstrap instead of duplicating it in product slices', () => { + const routeOwner = 'src/first_display/leaf/browser_route_owner.ts'; + expect(transitiveSources('src/core/bootstrap.ts')).toContain(routeOwner); + for (const slice of ['datadome', 'google_tag_manager', 'lockr', 'permutive', 'sourcepoint']) { + expect(transitiveSources(`src/first_display/slices/${slice}.ts`)).not.toContain(routeOwner); + } + }); }); 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 171076cce..920e4061f 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 @@ -8,11 +8,13 @@ import { } from '../../../src/adapters/prebid'; import { createPrebidIntegrationRegistration, + type PreparedTrustedBidV1, +} from '../../../src/integrations/prebid/module'; +import { createPrebidSelectionCoordinator, publishPrebidBid, type PrebidBidPublicationInput, - type PreparedTrustedBidV1, -} from '../../../src/integrations/prebid/module'; +} from '../../../src/integrations/render_runtime/prebid_selection'; import { createPrebidRefreshPolicy, createPrebidSyntheticRefreshRunner, @@ -269,7 +271,7 @@ function initialProductionPrebidHarness(userIdModules: readonly object[]) { : undefined, }); const artifacts = createCommittedArtifactStore(); - const registerPucGamAttempt = vi.fn(() => true); + const registerPucGamAttempt = vi.fn((_input: unknown) => true); const createAttempt = (owner: RenderAttemptScope) => createRenderAttempt({ artifacts, @@ -280,6 +282,32 @@ function initialProductionPrebidHarness(userIdModules: readonly object[]) { : undefined, reservations, }); + const renderCapability = Object.freeze({ + createPrebidSelectionCoordinator: () => + createPrebidSelectionCoordinator({ + activateAttempt: ({ attempt, owner, preparedBid }) => + registerPucGamAttempt( + Object.freeze({ + artifact: Object.freeze({ + kind: 'puc' as const, + attemptId: attempt.id, + slot: attempt.slot, + navigationGeneration: attempt.navigationGeneration, + dispose: () => undefined, + }), + attempt, + owner, + reservationId: preparedBid.bid.adId, + }) + ), + createAttempt, + reservations, + }), + navigation, + projection, + publishPrebidBid: (input: Omit) => + publishPrebidBid({ ...input, navigation, reservations }), + }); const preparationDisposers: Array<() => void> = []; const activationDisposers: Array<() => void> = []; const afterCommit: Array<() => void> = []; @@ -309,13 +337,7 @@ function initialProductionPrebidHarness(userIdModules: readonly object[]) { interfaces: Object.freeze({ 'runtime.v1': Object.freeze({ document }), 'slots.v1': Object.freeze({}), - 'render.v1': Object.freeze({ - createAttempt, - navigation, - projection, - registerPucGamAttempt, - reservations, - }), + 'render.v1': renderCapability, 'messages.v1': Object.freeze({}), 'aps.v1': Object.freeze({}), }), @@ -325,13 +347,7 @@ function initialProductionPrebidHarness(userIdModules: readonly object[]) { interfaces: Object.freeze({ 'runtime.v1': Object.freeze({ document }), 'slots.v1': Object.freeze({}), - 'render.v1': Object.freeze({ - createAttempt, - navigation, - projection, - registerPucGamAttempt, - reservations, - }), + 'render.v1': renderCapability, 'messages.v1': Object.freeze({}), 'aps.v1': Object.freeze({}), }), diff --git a/crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts b/crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts index a50c5834d..a0fa52fd8 100644 --- a/crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts +++ b/crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts @@ -298,6 +298,41 @@ describe('authenticated deferred module loading', () => { expect(loader.register(registration)).toBe(false); }); + it('ignores a publisher currentScript shadow and still admits the genuine deferred artifact', async () => { + const prepare = vi.fn(() => + Object.freeze({ activate: () => undefined }) + ); + let trustedCurrentScript: HTMLScriptElement | null = null; + const loader = createDeferredPhaseLoader({ + runtimeScript: document.createElement('script'), + currentScript: () => trustedCurrentScript, + document, + prepare, + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + }); + await Promise.resolve(); + const expected = document.head.querySelector('script'); + expect(expected).not.toBeNull(); + Object.defineProperty(document, 'currentScript', { + configurable: true, + value: expected, + }); + const publisherPrepare = vi.fn(() => Object.freeze({ activate: () => undefined })); + + expect(loader.register(deferredRegistration('gpt_later', publisherPrepare))).toBe(false); + expect(loader.state('gpt_later')).toBe('loading'); + expect(loader.reason('gpt_later')).toBeUndefined(); + expect(publisherPrepare).not.toHaveBeenCalled(); + + trustedCurrentScript = expected; + expect(loader.register(deferredRegistration('gpt_later'))).toBe(true); + expected?.dispatchEvent(new Event('load')); + await vi.waitFor(() => expect(loader.state('gpt_later')).toBe('ready')); + expect(prepare).toHaveBeenCalledOnce(); + }); + it('isolates a failed module while a sibling reaches ready', async () => { const prepare = vi.fn(async (registration: { readonly id: string }) => { if (registration.id === 'gpt_later') throw new Error('fictional module failure'); @@ -387,7 +422,7 @@ describe('authenticated deferred module loading', () => { const expected = document.head.querySelector('script'); const replacement = document.createElement('script'); expected?.replaceWith(replacement); - Object.defineProperty(document, 'currentScript', { configurable: true, value: replacement }); + Object.defineProperty(document, 'currentScript', { configurable: true, value: expected }); expect(loader.register(deferredRegistration('gpt_later'))).toBe(false); expect(loader.reason('gpt_later')).toBe('registration_rejected'); @@ -539,6 +574,33 @@ describe('authenticated deferred module loading', () => { expect(() => rules?.createScriptURL('https://attacker.example/x.js')).toThrow(); }); + it('reuses the bootstrap Trusted Types capability without creating the fixed policy twice', async () => { + const runtimeScript = document.createElement('script'); + const createScriptURL = vi.fn((value: string) => value); + const createPolicy = vi.fn(() => { + throw new TypeError('duplicate policy'); + }); + Object.defineProperty(window, 'trustedTypes', { + configurable: true, + value: Object.freeze({ createPolicy }), + }); + createDeferredPhaseLoader({ + runtimeScript, + document, + prepare: vi.fn(), + gate: Promise.resolve(), + manifest: deferredManifest(['gpt_later']), + releaseId: RELEASE_ID, + trustedScriptUrl: createScriptURL, + }); + await Promise.resolve(); + + expect(createPolicy).not.toHaveBeenCalled(); + expect(createScriptURL).toHaveBeenCalledWith( + `${window.location.origin}/static/tsjs=tsjs-gpt_later.min.js?v=${HASH}` + ); + }); + it('copies no nonce when the takeover script has no nonempty nonce', async () => { createDeferredPhaseLoader({ runtimeScript: document.createElement('script'), 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 3325098f7..27e9353e8 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 @@ -102,7 +102,7 @@ describe('tsjs-prebid shim artifact', () => { // the shim size; retain a margin above the normal compact shim output. expect(bundleCode.length).toBeGreaterThan(200_000); expect(shimCode.length).toBeLessThan(30_000); - expect(shimCode).toContain('markWinningBidAsUsed'); + expect(shimCode).toContain('registerTrustedServerBidder'); }); }); 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 a4f936660..3592932b4 100644 --- a/crates/trusted-server-js/lib/test/services/slots.test.ts +++ b/crates/trusted-server-js/lib/test/services/slots.test.ts @@ -64,6 +64,7 @@ function createGptHarness( orphanOnReplace?: object; returnOldOnReplace?: boolean; synchronousRun?: boolean; + servicesEnabled?: boolean; } = {} ) { const listeners = new Map void>>(); @@ -92,6 +93,7 @@ function createGptHarness( const facade: GoogletagFacade = Object.freeze({ bindingToken: () => bindingToken, clearTargeting: vi.fn(), + enableServices: vi.fn(), transactionalDefine: () => Object.freeze({ status: 'discarded' as const }), display, getTargeting: vi.fn(() => []), @@ -103,7 +105,7 @@ function createGptHarness( Object.freeze({ apiReady: true, initialLoadDisabled: options.initialLoadDisabled === true, - pubadsReady: true, + pubadsReady: options.servicesEnabled !== false, }), setTargeting: vi.fn(), slotElementId: () => undefined, @@ -546,6 +548,92 @@ describe('slot registry', () => { expect(gpt.display).toHaveBeenCalledExactlyOnceWith(slot); }); + it('publishes the exact TS request intent before GPT starts and isolates observer failure', async () => { + const gpt = createGptHarness(); + const observed = vi.fn((input: Readonly<{ registeredSlotId: string; slot: object }>) => { + expect(input).toEqual({ registeredSlotId: 'slot', slot }); + expect(Object.isFrozen(input)).toBe(true); + expect(gpt.display).not.toHaveBeenCalled(); + throw new Error('fictional diagnostics failure'); + }); + const service = createSlotService({ + googletag: gpt.adapter, + onTrustedServerRequest: observed, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + + const request = service.request({ + expectedSlot: slot, + intentId: 'diagnostic-publication', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(observed).toHaveBeenCalledExactlyOnceWith({ registeredSlotId: 'slot', slot }); + expect(gpt.display).toHaveBeenCalledExactlyOnceWith(slot); + expect(request.status).toBe('active'); + }); + + it('does not publish a request fact when disabled-load display is claimed synchronously', async () => { + const gpt = createGptHarness({ initialLoadDisabled: true }); + const observed = vi.fn(); + const service = createSlotService({ + googletag: gpt.adapter, + onTrustedServerRequest: observed, + }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + gpt.display.mockImplementationOnce(() => gpt.emit('slotRequested', { slot })); + + const request = service.request({ + expectedSlot: slot, + intentId: 'synchronous-publisher-claim', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await expect(request.result).resolves.toEqual({ + status: 'failed', + reason: 'cycle_unattributable', + }); + + expect(observed).not.toHaveBeenCalled(); + expect(gpt.refresh).not.toHaveBeenCalled(); + }); + + it('enables GPT services at the shared request boundary before display', async () => { + const gpt = createGptHarness({ servicesEnabled: false }); + const service = createSlotService({ googletag: gpt.adapter }); + const navigation = createNavigation(); + const slot = bindTrustedSlot(service, navigation); + service.activate(); + + const request = service.request({ + expectedSlot: slot, + intentId: 'enable-services-before-display', + navigationGeneration: navigation.generation, + operation: 'display', + registeredSlotId: 'slot', + requestClass: 'primary', + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(gpt.facade.enableServices).toHaveBeenCalledOnce(); + expect(vi.mocked(gpt.facade.enableServices).mock.invocationCallOrder[0]).toBeLessThan( + gpt.display.mock.invocationCallOrder[0]! + ); + expect(request.status).toBe('active'); + }); + it('recognizes the exact live GPT binding regardless of who defined the slot', () => { const service = createSlotService({ googletag: createGptHarness().adapter }); const { navigation, runtime } = createRuntimeWithNavigation(); @@ -2641,40 +2729,54 @@ describe('physical GPT cycles', () => { ]); }); - it('rejects display batches at the type and runtime boundaries before mutation', () => { + it('arms every display intent before a synchronous SRA request can emit sibling events', async () => { const harness = createGptHarness(); const service = createSlotService({ googletag: harness.adapter }); const navigation = createNavigation(); - bindTrustedSlot(service, navigation); + const first = bindTrustedSlot(service, navigation, 'display-first'); + const second = bindTrustedSlot(service, navigation, 'display-second'); + harness.display.mockImplementation(() => { + harness.emit('slotRequested', { slot: first }); + harness.emit('slotRequested', { slot: second }); + harness.emit('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'display-first-response', + slot: first, + }); + harness.emit('slotRenderEnded', { + isEmpty: false, + responseIdentifier: 'display-second-response', + slot: second, + }); + }); const displayBatch = [ { - intentId: 'valid-before-display', + expectedSlot: first, + intentId: 'display-first-intent', navigationGeneration: navigation.generation, - operation: 'refresh' as const, - registeredSlotId: 'slot', + operation: 'display' as const, + registeredSlotId: 'display-first', requestClass: 'primary', }, { - intentId: 'display-batch', + expectedSlot: second, + intentId: 'display-second-intent', navigationGeneration: navigation.generation, operation: 'display' as const, - registeredSlotId: 'slot', + registeredSlotId: 'display-second', requestClass: 'primary', }, ] as const; - const compileOnly = (): void => { - // @ts-expect-error requestBatch is refresh-only; single request retains display support. - service.requestBatch(displayBatch); - }; - expect(compileOnly).toBeTypeOf('function'); - const inventory = service.snapshotForTest(); const runtimeRequestBatch = service.requestBatch as unknown as ( - inputs: readonly object[] - ) => unknown; + inputs: typeof displayBatch + ) => readonly ReturnType[]; - expect(runtimeRequestBatch(displayBatch)).toEqual([]); - expect(service.snapshotForTest()).toEqual(inventory); - expect(harness.display).not.toHaveBeenCalled(); + const requests = runtimeRequestBatch(displayBatch); + await expect(Promise.all(requests.map(({ result }) => result))).resolves.toEqual([ + { responseIdentifier: 'display-first-response', status: 'rendered' }, + { responseIdentifier: 'display-second-response', status: 'rendered' }, + ]); + expect(harness.display).toHaveBeenCalledOnce(); expect(harness.refresh).not.toHaveBeenCalled(); }); diff --git a/docs/.gitignore b/docs/.gitignore index 57a09c39d..097c22936 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,3 +1,4 @@ node_modules .vitepress/dist .vitepress/cache +.vitepress/.temp diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 9624e8bec..eca67a728 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -699,7 +699,7 @@ Each proxied URL includes a `tstoken` HMAC signature for tamper protection. See ### Full example -```toml +````toml [auction] enabled = true sanitize_creatives = false # Opt-in; blanks script-based creatives when enabled @@ -734,9 +734,6 @@ account_id = "example-aps-account" debug = false allow_script_creatives = false -[integrations.aps] -enabled = true -rendering_mode = "trusted_server" ### Configuration Reference @@ -767,18 +764,14 @@ environment overrides to apply; see | `debug` | bool | `false` | Enable Prebid debug mode (sets `ext.prebid.debug` and `ext.prebid.returnallbidstatus`) | | `test_mode` | bool | `false` | Set OpenRTB `test: 1` for non-billable test traffic | -#### `[integrations.aps]` +#### APS provider profile -| 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 | +APS is enabled only by an `[auction.providers.]` row with +`profile = "aps"`. Its common `endpoint` and `timeout_ms` fields belong on that +provider row. Put `account_id`, `debug`, `inventory_domain`, +`inventory_page_origin`, and `allow_script_creatives` under the provider's +`profile_config` table. The compiled plan also owns browser renderer and runner +route registration; there is no separate browser-mode switch. #### `[integrations.adserver_mock]` @@ -811,7 +804,7 @@ allowed_domains = ["assets.example.com"] enabled = true endpoint = "https://mediator.example.com/mediate" timeout_ms = 500 -``` +```` `[auction.providers]` is a map, not a provider-name list. Each provider ID owns endpoint/backend correlation and telemetry. `[auction.bidders]` maps each diff --git a/docs/guide/integrations/aps.md b/docs/guide/integrations/aps.md index eee2815b0..208f2db9d 100644 --- a/docs/guide/integrations/aps.md +++ b/docs/guide/integrations/aps.md @@ -25,18 +25,11 @@ The integration does not implement: ## Configuration -APS server ownership is entirely under an auction provider. The optional -`[integrations.aps]` table controls browser renderer ownership only. It does not -own the APS account, endpoint, timeout, debug behavior, or script policy. APS -renderer support is registered whenever the compiled auction plan contains an -`aps` profile, even if `[integrations.aps]` is absent or disabled. - -```toml -[integrations.aps] -enabled = true -# Default. Use publisher_native only for the controlled experiment below. -rendering_mode = "trusted_server" -``` +APS server and browser ownership are both derived from the compiled auction +plan. Selecting `profile = "aps"` registers the provider, the opaque renderer, +and the live runner proxy as one unit. Do not add a separate +`[integrations.aps]` table; account, endpoint, timeout, debug behavior, and +script policy all belong to the APS provider profile. ```toml [auction] @@ -47,7 +40,7 @@ timeout_ms = 2000 `account_id` is required. It accepts a non-empty string or integer account identifier; no alternate field name is supported. This is a hard config cutover. Before deploying the new binary, rename the former -APS publisher-identifier field to the canonical `account_id`, quote any numeric +APS account field to the canonical `account_id`, quote any numeric identifier so it is a TOML string, and remove legacy or unknown APS keys. Run `ts config validate`, then push the canonical configuration while the old binary that accepts `account_id` is still serving. Deploy the new binary only after that @@ -58,27 +51,9 @@ compatibility parser. `allow_script_creatives` defaults to `false`. While disabled, APS script bids are rejected before per-impression reduction, floors, mediation, and winner selection. Enable it only for a controlled cohort after the browser-security checks in [Rollout](#rollout) pass. -`integrations.aps.rendering_mode` is a strict enum. `trusted_server` is the default and retains the opaque static renderer route. `publisher_native` disables that route and adds `data-ts-aps-rendering-mode="publisher_native"` to the server-generated TSJS bundle tag. TSJS captures this server-owned attribute when the bundle executes, so markup added later cannot change the mode. The attribute works under a publisher CSP that blocks inline scripts. Unknown values fail configuration deserialization. - -### Publisher-native runner experiment - -`publisher_native` is an opt-in browser experiment, **not** general APS compatibility proof. No public `apstag` API was found that accepts an externally selected OpenRTB `aaxResponse`. In controlled browser testing, `apstag.renderImp(document, bidId)` did not render the Trusted Server bid because that bid was absent from the SDK's browser-auction state. Trusted Server therefore does not call `apstag`, `fetchBids`, or `setDisplayBids`, mutate the publisher's APS SDK, or start a second auction. Instead, this mode reuses the same `prebid/creative/render` runner contract already used by `trusted_server` mode, but inside a publisher-origin frame; that observed vendor contract still requires APS account-team validation. - -No publisher JavaScript change is required. After validating and freezing the exact selected descriptor, Trusted Server JS: - -1. resolves the direct-auction slot or its injected GAM div mapping; -2. creates a hidden, publisher-origin friendly iframe sized to the winner; -3. initializes only that fresh frame's account-scoped `_aps` event queue; -4. queues `prebid/creative/render` with the selected `aaxResponse` and bid ID; and -5. loads the fixed `https://client.aps.amazon-adsystem.com/prebid-creative.js` runner. - -The existing publisher content remains visible until the runner script loads. A runner error, a blocked script, a missing slot, a superseding dispatch, or a load taking longer than 10 seconds removes the pending frame and visibly declines the bid. It never falls back to `/integrations/aps/renderer` or sends a Universal Creative renderer response. Trusted Server treats runner load as successful handoff; the runner owns subsequent creative completion and resource loading. - -Unlike `trusted_server` mode, this friendly frame deliberately has no opaque-origin sandbox. Its initial document inherits the publisher CSP, so the publisher policy controls whether the APS runner and required creative resources can load. Trusted Server sets the frame document's referrer policy to `no-referrer`, matching the static renderer's existing protection. The fixed APS runner and its creative otherwise execute with publisher-origin privileges, so `publisher_native` has a larger security surface, especially when `allow_script_creatives = true`. Use only a controlled cohort. - -For a client-side Prebid APS capability, Trusted Server consumes the one-shot capability before starting the runner and calls `markWinningBidAsUsed` only after the runner loads. For server/GPT ownership, it similarly claims the slot/ad ID first. This prevents native and Trusted Server rendering from both owning the same response. - -Disable or coordinate existing publisher-native APS demand for every `publisher_native` cohort. Otherwise the publisher's normal APS auction and this server-selected bid can duplicate demand. Validate the exact account, inventory, CSP, iframe/script creative behavior, impression reporting, and click-through behavior with the APS account team before any production rollout. +There is one browser rendering mode: the Trusted Server-owned opaque renderer. +Publisher-origin friendly-frame rendering and runtime mode switches are not part +of the hard-cutover contract. Set `inventory_domain` and `inventory_page_origin` together only when the public deployment hostname differs from the inventory identity authorized by APS. The domain becomes `site.domain`. The HTTPS page origin replaces the current page's scheme and host while preserving its path; query and fragment data are removed before forwarding. The origin must be the inventory domain or one of its subdomains and cannot include credentials, a port, path, query, or fragment. These values come only from operator configuration; Trusted Server never accepts APS inventory identity from the client auction payload. @@ -117,7 +92,7 @@ rejected. `timeout_ms` belongs beside `endpoint`; when omitted, the `aps` profile default is 800 ms. Runtime caps it by the remaining auction budget. `profile_config.account_id` is required, nonempty, and at most 1024 bytes. It is -the canonical field; integration-owned `account_id`, `pub_id`, endpoint, and +the canonical field; integration-owned account, endpoint, and timeout fields are not part of the public schema. `debug` and `allow_script_creatives` both default to `false`. @@ -276,7 +251,10 @@ It deliberately omits `allow-same-origin` during bootstrap, so APS and bidder ex ### Direct `/auction` -In `trusted_server` mode, the TSJS auction client validates the typed renderer descriptor, creates the opaque renderer iframe, and sends the minimized envelope after the frame loads. In `publisher_native` mode it creates the injected friendly iframe and queues the response for the fixed APS Prebid creative runner. Ordinary non-APS `adm` continues through the existing sanitizer and generic creative iframe. +The TSJS auction client validates the typed renderer descriptor, creates the +opaque renderer iframe, and sends the minimized envelope after the frame loads. +Ordinary non-APS `adm` continues through the existing sanitizer and generic +creative iframe. ### GAM and Universal Creative @@ -315,7 +293,7 @@ If script rendering requires weakening the outer sandbox, leave `allow_script_cr This release is a direct configuration and protocol cutover: -1. In the operator configuration, rename the former APS publisher-identifier field +1. In the operator configuration, rename the former APS account field to `account_id`, quote numeric identifiers, and remove legacy or unknown APS keys. 2. Run `ts config validate`, then push that canonical configuration while the old binary that accepts `account_id` is still serving. @@ -326,8 +304,11 @@ This release is a direct configuration and protocol cutover: 6. Prepare GAM line items and Universal Creative for `hb_bidder=aps` and the selected APS `hb_adid`. 7. Disable publisher-native APS demand for the Trusted Server test cohort. -There is no legacy runtime switch. Roll back by disabling `[integrations.aps]`, restoring native APS for the cohort, or deploying the prior binary. -Disabling `[integrations.aps]` is also the containment stop for renderer or live-runner incidents: it removes APS auction and browser materialization ownership without adding a cached or vendored fallback. +There is no legacy runtime switch. To stop new APS admission and disable both +reserved browser routes, remove the APS-profile provider from the effective +auction plan and deploy that configuration. Restore publisher-native APS for the +cohort only after the Trusted Server plan no longer admits APS. Deploying the +prior binary remains the binary rollback path. ## Rollout @@ -335,12 +316,13 @@ Use fictional values in source-controlled configuration and fixtures. Supply con 1. Obtain APS account-team confirmation for edge-originated OpenRTB traffic. 2. Enable Trusted Server APS only for an isolated cohort and disable native APS demand there. -3. Keep the default `trusted_server` mode and `allow_script_creatives = false`; observe iframe bids through direct and GAM paths. +3. Keep `allow_script_creatives = false`; observe iframe bids through direct and GAM paths. 4. Confirm outbound privacy fields, aggregate diagnostics, decoded-price competition, line-item targeting, dimensions, click-throughs, and opaque-origin isolation. -5. In a still-smaller cohort, set `rendering_mode = "publisher_native"` and confirm the fixed runner request, friendly-frame dimensions, iframe creatives, impression reporting, and click-throughs without a request to `/integrations/aps/renderer`. -6. Purge or expire cached HTML and reload active test sessions when changing modes. Confirm the publisher CSP permits the runner but does not need to permit inline Trusted Server scripts. -7. Only after reviewing the friendly-frame security tradeoff, enable script creatives for the isolated native cohort and validate them in a real browser. -8. Expand traffic only after APS confirmation and successful controlled validation. +5. Confirm the fixed live-runner proxy request, opaque-frame dimensions, iframe + creatives, impression reporting, and click-through behavior. +6. Enable script creatives only for a smaller isolated cohort and validate them + in a real browser without weakening the outer sandbox. +7. Expand traffic only after APS confirmation and successful controlled validation. ## Troubleshooting diff --git a/docs/guide/integrations/gpt.md b/docs/guide/integrations/gpt.md index b55f65f7e..e5d6481b3 100644 --- a/docs/guide/integrations/gpt.md +++ b/docs/guide/integrations/gpt.md @@ -55,7 +55,6 @@ Add GPT configuration to `trusted-server.toml`: enabled = true gam_attribution_enabled = false script_url = "https://securepubads.g.doubleclick.net/tag/js/gpt.js" -cache_ttl_seconds = 3600 rewrite_script = true ``` @@ -66,7 +65,6 @@ rewrite_script = true | `enabled` | boolean | No | `true` | Enable/disable the integration | | `gam_attribution_enabled` | boolean | No | `false` | Add fixed page-level `ts=true` targeting for GAM cohort reporting | | `script_url` | string | No | `https://securepubads.g.doubleclick.net/tag/js/gpt.js` | URL for the GPT bootstrap script | -| `cache_ttl_seconds` | integer | No | `3600` | Cache TTL for proxied scripts (60--86400s) | | `rewrite_script` | boolean | No | `true` | Whether to rewrite GPT script URLs in HTML | The environment override 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 96ae57867..8ac8d5492 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 @@ -10,7 +10,7 @@ **Goal:** Finish the APS render fix and resilient TSJS hard cutover on the exact `rc/202608` base, preserving all affected rc behavior while closing every remaining -revision-43 contract gap. +revision-44 contract gap. **Architecture:** Keep the already-landed single-runtime/first-display architecture and complete it rather than replaying its historical implementation. The browser @@ -32,24 +32,25 @@ checked-in shell/Node CI scripts. The sole design authority is `docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md` -revision 43. This is the sole implementation plan for that design. - -The implementation branch is `feature/aps-tsjs-resilience-rc202608`. Its first -parent is the fetched `origin/rc/202608` commit -`f0825604ec6740111e99dd8a178e3b880e7d772b`; the previously reviewed feature history -at `ecd78a9d11680deece4d4ec13f84be04fdae6b0d` is its second parent through merge -commit `95b562ea820268d6f16da08863dfa9f71076e4d2`. That integrated history already +revision 44. This is the sole implementation plan for that design. + +The implementation branch is `feature/aps-tsjs-resilience-rc202608`. It is created +directly from fetched `origin/rc/202608` commit +`985ff22987d80cc729f45f702e0c3548e429b2cf`. Previously reviewed feature and merge +SHAs are historical review references only; their approved changes were replayed +and reconciled onto this exact base and are not parent or implementation authority. +That replayed history already implements the descriptor/projection contract, proxy route, core runtime, first- display split, integration catalog, hard cutover, package upgrade, and most verification surfaces. Those commits are implementation history, not evidence that -revision 43 is complete. +revision 44 is complete. The final Task 17 refresh fetched and integrated `origin/rc/202608` at -`d4cd2cc823718d64ae73bcb068e5eab03ecd901a` on 2026-08-21. Its rc-owned package and +`985ff22987d80cc729f45f702e0c3548e429b2cf` on 2026-08-28. Its rc-owned package and operator-document changes are retained. Its raw-global GAM-attribution transport is superseded by the typed immutable GPT configuration carrier and sole parser-time GPT owner, and its mutable publisher-native APS experiment is superseded by the single -Trusted Server renderer owner required by revision 43. +Trusted Server renderer owner required by revision 44. `origin/rc/202608` is the behavior, API, dependency, CI, and performance baseline. Do not merge `main` separately. Before final verification, fetch the release branch; @@ -1510,6 +1511,87 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled lifecycle proof, complete JS/Rust quality scripts, exact performance sample, and clean-worktree checks before committing and pushing one replacement SHA. +### Task 15D: Seal runtime admission and preserve the publisher script-policy chain + +**Files:** + +- Modify: `crates/trusted-server-js/lib/build-all.mjs` +- Modify: `crates/trusted-server-js/lib/src/core/{bootstrap,index}.ts` +- Modify: `crates/trusted-server-js/lib/src/first_display/registration_client.ts` +- Modify: `crates/trusted-server-js/lib/src/kernel/phase_loader.ts` +- Modify: `crates/trusted-server-core/src/{html_processor,publisher,tsjs}.rs` +- Modify: `crates/trusted-server-core/src/integrations/{registry,datadome,prebid}.rs` +- Modify: `crates/trusted-server-core/benches/html_processor_bench.rs` +- Modify: `crates/trusted-server-js/lib/test/core/{index,request}.test.ts` +- Modify: `crates/trusted-server-js/lib/test/build/{generated-fallback,release-v1}.test.mjs` +- Modify: `crates/trusted-server-js/lib/test/kernel/phase_loader.test.ts` +- Create: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-policy.spec.ts` +- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts` +- Modify: `scripts/ci/aps-tsjs-cutover.sh` +- Modify: `.github/workflows/integration-tests.yml` + +- [ ] **Step 1: Add RED admission-boundary tests.** Require the generated persistent + artifact's first expression to consume a one-read, non-configurable + `window.tsjs` gate and the selected script node's own non-configurable + `_claimRuntimeV1` before any core or module initializer. The exact frozen claim + is `{boot,integrity,complete,currentScript,source,target,mode,bind}`. Cover + replay, reentrant DOM authentication, wrong/disconnected/replaced source, + mutable/wrong-shape candidates, wrong mode, corrupted public + `document.currentScript`, and publisher/upstream mutation of realm intrinsics. + Prove ordinary namespace reads always retain the original target and only the + exact selected runtime task receives the script node once. Replace every test + harness that still reconstructs `_claimBootSnapshot`/`_claimDirectRuntime`. + + For optional first-display slices, require one raw dense + `[1,id,releaseId,install]` carrier. Bootstrap must use its captured native + current-script/descriptor/array intrinsics, reserve registration before a + reentrant getter can run, authenticate the exact connected parser script, + freeze the carrier, retain only validated values, and reject wrong order, + duplicate, late, accessor, sparse, custom, or replayed registration. + +- [ ] **Step 2: Add RED server ordering and response-CSP tests.** Parse only + enforcing response headers using `script-src-elem` → `script-src` → + `default-src` precedence and select only one bounded nonce in the intersection + of nonce-bearing policies. Report-only, malformed, ambiguous, disjoint, and + meta-only values authorize no nonce. Thread the selected value through both + streaming and buffered HTML contexts. Prove exact output order + controller → live integration head inserts → selected TSJS artifact and the + same nonce on every Trusted Server executable tag, including DataDome's inline + plus external tags, Prebid's external tag, and request-scoped diagnostics. + Invalid input must emit no partial/escaped nonce attribute. + +- [ ] **Step 3: Add the RED-to-GREEN browser policy matrix.** In Chromium prove + direct self-source, matching response nonce, nonce-only, `strict-dynamic`, a + permitted named `trusted-server#tsjs-v1` policy, one exact-preserving default + policy fallback, mutation/throw/full block, missing deferred nonce, and + removed/replaced nodes. Prove the agent path creates the named policy only once + and leases it from persistent insertion to deferred loading. In all three + engines assert real first-display takeover, request/insertion outcomes, no + unauthorized diagnostics panel, and survival of the same committed kernel. + Keep exact `policy_blocked`/`load_error`/`registration_rejected` classification + in unit tests; do not add a production browser test seam for private enums. + +- [ ] **Step 4: Keep workflow behavior in checked-in scripts and run focused GREEN.** + Workflow YAML may select engines and invoke `scripts/ci/aps-tsjs-cutover.sh`; + policy setup, server lifecycle, and Playwright commands remain in repository + script files. + + ```bash + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" controller_precedes_live_head_inserts_which_precede_selected_runtime + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" response_csp_nonce + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" head_injector_emits_external_bundle_script_with_hash_and_integrity + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/^host: //p')" head_injector_emits_client_side_tag_when_key_configured + npm --prefix crates/trusted-server-js/lib test -- --run test/core/index.test.ts test/core/request.test.ts test/kernel/phase_loader.test.ts + 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 check:hard-cutover-absence + TS_BROWSER_PROJECTS=chromium,firefox,webkit npx --prefix crates/trusted-server-integration-tests/browser playwright test tests/shared/tsjs-policy.spec.ts --project=chromium --project=firefox --project=webkit + ``` + +- [ ] **Step 5: Commit the admission and policy chain.** Stage only the literal + files above after reviewing `git diff --check`; do not commit generated docs + temp/cache output. + ### Task 16: Enforce hard-cutover absence and supply-chain boundaries **Files:** @@ -1575,7 +1657,7 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled ``` If already an ancestor, abort the empty merge and record the no-op. If advanced, - resolve conflicts in favor of revision 43 where it explicitly supersedes rc and + resolve conflicts in favor of revision 44 where it explicitly supersedes rc and otherwise in favor of rc. Do not merge `main` separately. - [ ] **Step 2: Produce an overlap inventory before committing.** For every @@ -1583,7 +1665,7 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled integration config, build, or CI, name the final owner and focused test. Keep EdgeZero and other excluded features unchanged. - Final refresh inventory for `d4cd2cc823718d64ae73bcb068e5eab03ecd901a`: + Final refresh inventory for `985ff22987d80cc729f45f702e0c3548e429b2cf`: | Rc overlap | Final disposition and owner | Focused proof | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | @@ -1595,10 +1677,10 @@ prebid, sourcepoint, testlight`. Emit each enabled product once, omit disabled | Structural ad-template slot generation | Preserve rc's structural TOML update, contiguous provider tables, line-ending safeguards, and unmanaged-field check; add the required `enabled = true` only when the generator creates a hard-cutover section. | focused `slot_toml` and CLI configuration suites | | GAM cohort attribution | Preserve the configuration and reporting concept; supersede rc raw globals and the activation attribute with the frozen GPT config carrier and sole parser-time GPT owner. | GPT Rust config tests, `tsjs.rs` selection tests, first-display GPT adapter/slice tests, persistent GPT module tests | | GPT diagnostics correlation guide | Preserve the rc correlation evidence, rewritten to the immutable initial projection and navigation-internal page-bids projection. | GPT diagnostics store/API/data/overlay suites and docs format/build | - | Retired GPT bootstrap, request runtime, and legacy GPT tests | Keep the revision-43 deletions; no second runtime or compatibility file returns. | hard-cutover scan, architecture test, release graph test | + | Retired GPT bootstrap, request runtime, and legacy GPT tests | Keep the revision-44 deletions; no second runtime or compatibility file returns. | hard-cutover scan, architecture test, release graph test | | Docs TypeScript-ESLint update | Preserve rc package and lockfile ownership. | docs install, lint, format, and build | | GPT operator wording and example config | Preserve rc's provider-neutral environment-overlay wording; do not add EdgeZero feature work. | docs format/build and config tests | - | Rc GAM review plan/spec artifacts | Preserve as release-branch documentation; they do not override revision 43's TSJS architecture. | docs format/build and plan-integrity scan | + | Rc GAM review plan/spec artifacts | Preserve as release-branch documentation; they do not override revision 44's TSJS architecture. | docs format/build and plan-integrity scan | - [ ] **Step 3: Update every rc-baseline row and rerun its exact proof.** No stale SHA or prior pass satisfies the gate. @@ -1687,6 +1769,9 @@ and rerun this task from the start. bash scripts/ci/aps-tsjs-cutover.sh run-browser ``` + The checked-in browser command includes `tests/shared/tsjs-policy.spec.ts`; no + multiline policy/server implementation is embedded in workflow YAML. + - [ ] **Step 7: Push the exact locally verified candidate before remote gates.** Require a clean worktree, confirm `origin/rc/202608` is the audited base, push local HEAD to the named feature branch, and prove the remote ref equals local @@ -1748,11 +1833,18 @@ validate-inputs` and `run`; it tests PUC 1.17.2 in Chromium, Firefox, and WebKit **Files:** no new implementation files. - [ ] **Step 1: Use `superpowers:requesting-code-review`.** Review the complete diff - against the exact PR base, design revision 43, this plan, the rc adoption + against the exact PR base, design revision 44, this plan, the rc adoption ledger, security protocol, lifecycle ownership, no-vendoring boundary, and verification evidence. Resolve every blocking finding test-first and rerun the affected task plus Task 18. + Before changing branch/PR wiring, record that the focused APS proxy, projection, + bootstrap transport, runtime admission, first-display/takeover, GPT/Prebid, + creative, diagnostics, remaining-integration, static-route, policy-browser, + architecture, bundle, and no-vendoring suites are already green. The final + branch operation may integrate the audited rc tip but must not introduce new + behavior while switching refs. + - [ ] **Step 2: Use `superpowers:verification-before-completion`.** Confirm fresh command output rather than relying on earlier runs. @@ -1773,7 +1865,7 @@ validate-inputs` and `run`; it tests PUC 1.17.2 in Chromium, Firefox, and WebKit ## Completion checklist -- [ ] Design revision 43 and this one plan agree on `rc/202608` authority. +- [ ] Design revision 44 and this one plan agree on `rc/202608` authority. - [ ] All 23 concept rows have fresh final rc-baseline classifications. - [ ] No backward-compatibility path or second runtime remains. - [ ] No APS runner, GPT, or PUC bytes are vendored, pinned, cached, or stored. @@ -1783,6 +1875,12 @@ validate-inputs` and `run`; it tests PUC 1.17.2 in Chromium, Firefox, and WebKit modules receive attenuated values only. - [ ] Production boot uses one server-sealed canonical JSON lexical transport; bootstrap does not bundle the full object-form projection/config validators. +- [ ] Bootstrap seals one authenticated runtime admission through the selected + script's `_claimRuntimeV1`; no retired two-claim harness or mutable namespace + admission remains. +- [ ] One response-header CSP nonce, when uniquely admitted, covers every + Trusted Server executable tag and is copied only through the authenticated + runtime chain; the named Trusted Types policy is created at most once per path. - [ ] `ServerBootIntegrityV1` is always present; direct runtime and takeover both recompute projection/config digests before effects, and takeover requires exact integrity/outline/handoff equality. 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 77e1527c6..28fd93f95 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 @@ -1,23 +1,22 @@ # APS Render Fix and TSJS Resilience Architecture — Design -- **Status:** revision 43 — hard-cutover contract with a lean first-display owner, +- **Status:** revision 44 — hard-cutover contract with a lean first-display owner, atomic persistent-runtime takeover, an `rc/202608` implementation base, retired-branch concept-gap coverage, rc behavior reconciliation, and merge-blocking pre-action transfer remediation - **Date:** 2026-08-04 - **Implementation baseline:** fetched `origin/rc/202608` at - `f0825604ec6740111e99dd8a178e3b880e7d772b` on 2026-08-18. The implementation - branch is created directly from that commit. Existing feature history at - `ecd78a9d11680deece4d4ec13f84be04fdae6b0d` is integrated as the second parent - of merge commit `95b562ea820268d6f16da08863dfa9f71076e4d2`. + `985ff22987d80cc729f45f702e0c3548e429b2cf` on 2026-08-28. The implementation + branch is created directly from that commit. Earlier feature and merge SHAs are + historical review references only; their approved changes were replayed and + reconciled onto this base, and they are not parents or implementation authority. `origin/main` at the same fetch was `2e85a1cdcfe3d23814bab5b2215dd6b096f871eb` and is already an ancestor of the rc baseline; it is not a competing implementation authority. - **Final release-branch refresh:** fetched `origin/rc/202608` at - `d4cd2cc823718d64ae73bcb068e5eab03ecd901a` on 2026-08-21. The final overlap + `985ff22987d80cc729f45f702e0c3548e429b2cf` on 2026-08-28. The final overlap audit and performance comparison use that exact tip. It already contains the - `main` ancestry selected by the release branch, so `main` is not merged - separately. + `main` ancestry selected by the release branch, so `main` is not merged separately. - **Retired-branch evidence:** the immutable historical snapshot `905984e62a0858c53d9f0ff6dd3a1bf190cf311d` from retired `rc/july` is only a finite TSJS concept-gap checklist. It is not a baseline, merge source, API @@ -122,9 +121,10 @@ Any new analytics contract requires a separate design. The exact `origin/rc/202608` commit recorded above is the normative starting point. Its source, behavior, tests, APIs, CI, dependency state, and performance shape are -the baseline. The implementation branch has that rc commit as its first parent and -the previously reviewed APS/TSJS feature history as its second parent. A conflict -resolution is not itself behavioral evidence: every overlapping rc behavior must be +the baseline. The implementation branch was created directly from that exact rc +commit; approved feature changes were replayed and reconciled rather than made a +second-parent authority. A conflict resolution is not itself behavioral evidence: +every overlapping rc behavior must be identified, assigned an owner, and proved after reconciliation. Unless this design explicitly supersedes an rc behavior, the rc behavior wins. @@ -2577,11 +2577,17 @@ without enabled APS configuration is invalid rather than a reason to select the render owner, and `render_owner_initial` has no independently routable production asset; its bytes exist only inside the selected first-display composition. -Every slice registers into the bootstrap's release-private first-display sink from -the expected parser-inserted artifact and `document.currentScript`. The build fixes -their order, interfaces, and allowed imports; there is no public service locator or +Every optional slice submits one raw dense four-item carrier +`[1,id,releaseId,install]` to the bootstrap's release-private first-display sink +during the expected parser-inserted artifact task. The slice does not supply or +retain script identity. Bootstrap authenticates the exact connected script with the +closure-captured native `Document.prototype.currentScript` getter, reserves a +private `registrationClaiming` state before any mutable realm access, exact-data +validates the carrier with captured intrinsics, freezes it, and copies only the +validated id/install values into its closure-private registry. The build fixes their +order, interfaces, and allowed imports; there is no public service locator or third-party extension surface. The agent rejects an unknown, duplicate, omitted, -misordered, wrong-release, accessor-backed, or late slice before effects. Agent +misordered, wrong-release, accessor-backed, reentrant, or late slice before effects. Agent activation is one synchronous transaction with reverse-order rollback, the same inert-prepare/effectful-activate discipline used by the persistent catalog, and the same ten-second bootstrap deadline. All agent-owned timers, listeners, wrappers, @@ -3053,6 +3059,18 @@ any mutable DOM or realm authentication surface and rolls back only after a fail authentication. Its completion capability admits outcomes through primitive string comparisons, consumes its one-use guard before invoking the selected handler, and therefore remains one-shot under reentrant claim and completion attempts. +The generated persistent artifact starts with a build-owned banner whose first +expression directly reads the sealed `window.tsjs` admission gate, directly reads +the returned script node's own non-configurable `_claimRuntimeV1`, and calls it +before core or any co-bundled module initializer runs. For exactly that authenticated +runtime task, the non-configurable namespace getter returns the selected script node +once; every ordinary or later read returns the retained bootstrap target. The claim +returns one frozen exact record containing +`{boot,integrity,complete,currentScript,source,target,mode,bind}`. Core rejects any +other shape, source/current-script mismatch, mode mismatch, mutable record, or +replayed claim before effects. The controller captured the native current-script +getter and required object/array/descriptor intrinsics before any live upstream +script could run; later mutation of public realm properties cannot forge admission. The compact fallback does not import either public request validator. Both public operations refuse as unavailable without reading publisher input; full validation belongs only to a successfully committed kernel. @@ -3175,16 +3193,20 @@ defined here and must reload; the retained prior _binary_ in §8 is solely the w deployment rollback artifact. After rollback, that binary again serves its own release. This accepted stale-page break is not a cache or compatibility project. -Upstream-library loading is orthogonal to TSJS bundling. After the bootstrap -controller, the server may emit the existing fixed/configured live GPT tag as a non- -parser-blocking fetch early enough to overlap the first-display TSJS request when GPT is -required for the first projected display. When Prebid integration is enabled, its -external artifact tag is always emitted through that early overlap path because the -rc-baseline client readiness, bidder/user-ID/EID configuration, publisher queue, and -initial auction contract are required before the first action. It remains -an external script, never a TSJS source input or TSJS generated artifact. Its adapter installs -and owns all request-capable actions only after the agent transaction commits, so -an early library load cannot race a TS-owned display before correctness listeners. +Upstream-library loading is orthogonal to TSJS bundling. The exact server emission +order is the inline bootstrap controller, any enabled live integration head inserts, +and then the one selected parser-blocking first-display or persistent TSJS artifact. +The controller therefore captures its native intrinsics and installs all admission +sinks before publisher/upstream code can run, while the upstream fetch can still +overlap the selected TSJS request. The server may emit the existing fixed/configured +live GPT tag through this interstitial path when GPT is required for the first +projected display. When Prebid integration is enabled, its external artifact tag is +always emitted there because the rc-baseline client readiness, bidder/user-ID/EID +configuration, publisher queue, and initial auction contract are required before +the first action. It remains an external script, never a TSJS source input or TSJS +generated artifact. Its adapter installs and owns all request-capable actions only +after the agent transaction commits, so an early library load cannot race a TS-owned +display before correctness listeners. An optional/later upstream script follows its owning deferred module's trigger and cannot be prefetched by this path. The APS runner is never boot-preloaded: only a winning APS renderer document loads it through the live fixed-target proxy specified @@ -3384,21 +3406,37 @@ consume another module's deadline or delay its fetch, preparation, or activation Deferred insertion preserves the publisher's script policy; it never rewrites a Content-Security-Policy header or meta element and never adds `unsafe-inline`, -`unsafe-eval`, a source host, or a default Trusted Types policy. The parser-inserted -bootstrap and parser-inserted first-display/runtime tags remain subject to the publisher's existing CSP and are -a deployment precondition just as TSJS injection is on the rc baseline. When those -tags carry a CSP nonce, they must carry the same response-local value, and core -copies the authenticated parser-inserted element's `nonce` IDL value to the runtime -and every deferred script before -insertion. This supports nonce-only policies and preserves the trusted-root chain -under `strict-dynamic`; an absent nonce is never synthesized or copied from an -unrelated publisher element. +`unsafe-eval`, a source host, or a default Trusted Types policy. Before HTML +transformation, the server examines only enforcing response +`Content-Security-Policy` headers, applies `script-src-elem` → `script-src` → +`default-src` precedence within each policy, validates bounded base64/base64url nonce +tokens, and selects a nonce only when the intersection of nonce-bearing policies is +one exact value. Report-only headers, malformed values, multiple candidates, and +disjoint policies authorize no nonce. A nonce introduced only by publisher markup or +a meta-delivered policy is unavailable before injection and is not inferred. + +When one response-header nonce is admitted, the server places that same value on +every Trusted Server-generated executable tag in order: inline bootstrap controller, +request-scoped inline diagnostics cleanup, live integration head inserts (including +their inline and external script elements), and the selected parser-inserted +first-display/runtime tag. Invalid or absent authority produces no `nonce` +attribute. The parser-inserted tags remain subject to the publisher's effective CSP +and are a deployment precondition just as TSJS injection is on the rc baseline. The +controller/core copies only the authenticated parser-inserted element's `nonce` IDL +value to the post-paint runtime and every deferred script before insertion. This +supports nonce-only policies and preserves the trusted-root chain under +`strict-dynamic`; an absent nonce is never synthesized or copied from an unrelated +publisher element. `HTMLScriptElement.src` is a Trusted Types script-URL sink. When the browser exposes -`trustedTypes`, core attempts once per document runtime to create the closure-private -policy `trusted-server#tsjs-v1`. Its `createScriptURL` callback returns its canonical +`trustedTypes`, the direct core path attempts once per document runtime to create the +closure-private policy `trusted-server#tsjs-v1`. On an agent path, bootstrap creates +that policy at most once for the post-paint persistent insertion and leases the same +closure-private URL constructor to core for all deferred insertions; core never +creates a competing policy. Its `createScriptURL` callback returns its canonical absolute argument only when that value is exact membership in the frozen absolute -deferred URL set; it rejects every other string and is never exposed. If publisher CSP does +runtime/deferred URL set for the owning path; it rejects every other string and is +never exposed. If publisher CSP does not permit that policy name or the name is unavailable, core may assign the raw manifest string so a publisher's existing default policy or a non-enforcing browser can process it, but it immediately verifies that the element's resolved `src` is @@ -3478,9 +3516,14 @@ unclaimed -> installing -> agent -> transferring -> kernel \-> failed --------------------> fallback ``` -Initial namespace capture is field-wise and does not replace a publisher-created -`window.tsjs` object: `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}`. The -kernel remains externally inert and commits ownership only after all takeover +Bootstrap retains an existing non-null object/function `window.tsjs` target or +creates and assigns one only when the existing value is falsy; a conflicting truthy +primitive fails closed. It normalizes only that target's queue/boot fields and does +not replace a publisher-created object. Before the direct persistent task, or at +protected paint before an agent creates the persistent task, bootstrap permanently +seals `window.tsjs` as the one-read admission getter described above. Normal reads +continue to return the exact retained target, so the public namespace identity does +not change. The kernel remains externally inert and commits ownership only after all takeover integration modules prepare and synchronously activate in order. The agent also leaves the public runtime surface uncommitted. Deferred modules are not part of the bootstrap/takeover transaction. Before first-display or takeover module work, bootstrap @@ -5011,7 +5054,7 @@ After that upgrade, the lockfile compiler is the authority. CI runs a checked-in the deferred loader uses only authenticated classic same-origin script elements as specified in §5.2. -The first complete paired run on candidate +The historical first complete paired run on candidate `a0d8c0631a9774b5c8da8b25794a8836aa2e62f5` against exact rc base `d4cd2cc823718d64ae73bcb068e5eab03ecd901a` proved that correctness, load ordering, heap, GPT first-action timing, and every APS absolute action/completion/paint deadline @@ -5020,8 +5063,9 @@ was 92,931 raw / 28,802 gzip / 25,779 Brotli bytes against 70,943 / 21,571 / 18, the APS interval was 128,769 / 39,645 / 34,758 against the allowed rc × 1.10 values 78,659.9 / 24,098.8 / 21,006.7. APS first-action p90 was 616.3 ms against an allowed 562.0 ms, while its 900 ms absolute action ceiling and every downstream deadline -passed. This is immutable diagnostic evidence, not authority to change a comparator, -membership rule, fixture, threshold, or baseline. +passed. This predecessor-run record is immutable diagnostic evidence, not the current +baseline and not authority to change a comparator, membership rule, fixture, or +threshold. Final release evidence uses the 2026-08-28 base recorded above. Revision 43 closes that demonstrated source-graph gap without changing network semantics: @@ -5692,11 +5736,13 @@ Tests must cover at least: - CSP/Trusted Types browser fixtures for same-origin allowlisting, matching nonce, nonce-only policy, `strict-dynamic`, allowed `trusted-server#tsjs-v1`, rejected named policy with an exact-preserving publisher default, and full policy block; - mutation by a default policy, a disallowed URL, or a synchronous policy throw - produces no insertion/request and settles only the affected deferred module as + the unit phase-loader layer asserts the exact internal reasons: mutation by a + default policy, a disallowed URL, or a synchronous policy throw is `policy_blocked`; missing/invalid nonce or CSP source rejection after insertion is - `load_error`, while node removal/replacement may have initiated a request but - cannot register and becomes `registration_rejected` unless load failure wins; + `load_error`; and node removal/replacement is `registration_rejected` unless load + failure wins. Browser fixtures do not expose those private enums: they prove the + corresponding distinct environmental cause, insertion/request outcome, absence of + an unauthorized diagnostics panel, and survival of the same committed kernel; - exact kernel/fallback `TsjsApi` own surfaces; semantic version and release-id equality; boot deep-copy/freeze and malformed-field safe fallback; exact ordered integration-config id inclusion, manifest/product matching, attenuated per-module @@ -5908,10 +5954,11 @@ boolean and exposes no legacy alias or omitted-field compatibility path. Binary rollback restores Trusted Server code but cannot restore older live APS runner bytes. If the proxied runner becomes unavailable, incompatible, or produces suspect -completion results, the emergency containment action is to disable -`[integrations.aps]`; this stops new APS admission and makes both reserved APS routes -return their local `404 no-store` response. APS remains disabled until controlled -real-browser conformance passes again. +completion results, the emergency containment action is to remove or disable every +`[auction.providers.]` instance whose `profile = "aps"` (or disable `[auction]` +when auction-wide containment is intended). This stops new APS admission and makes +both reserved APS routes return their local `404 no-store` response. APS remains +disabled until controlled real-browser conformance passes again. This document does not prescribe new rollout telemetry. If existing operational signals are insufficient for a deployment decision, that blocks rollout and is diff --git a/scripts/ci/aps-tsjs-cutover.sh b/scripts/ci/aps-tsjs-cutover.sh index 5a1577a8f..08d78ace7 100755 --- a/scripts/ci/aps-tsjs-cutover.sh +++ b/scripts/ci/aps-tsjs-cutover.sh @@ -45,6 +45,7 @@ run_browser_matrix() { tests/shared/aps-renderer.spec.ts \ tests/shared/aps-puc-lifecycle.spec.ts \ tests/shared/tsjs-runtime.spec.ts \ + tests/shared/tsjs-policy.spec.ts \ tests/shared/creative-sandbox.spec.ts \ tests/nextjs/gpt-diagnostics.spec.ts \ tests/nextjs/navigation.spec.ts \ diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 8bb9a9d00..5ed765e0b 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -309,8 +309,8 @@ provider = "pbs-main" # inventory_domain = "publisher.example" # inventory_page_origin = "https://www.publisher.example" # -# Browser renderer ownership is configured separately via [integrations.aps] -# (see the Integrations section below). +# The compiled APS provider plan also owns browser renderer and live-runner +# route registration; there is no separate browser integration table. # Server-side ad slot templates + creative-opportunity auction. Kept active. [creative_opportunities] @@ -563,7 +563,6 @@ enabled = false # this is set true. gam_attribution_enabled = false # script_url = "https://ads.example.com/gpt.js" -# cache_ttl_seconds = 3600 # rewrite_script = true # GPT runtime diagnostics browser overlay. Optional and enabled manually (not @@ -572,16 +571,6 @@ gam_attribution_enabled = false # [integrations.gpt_diagnostics] # enabled = true -# Amazon Publisher Services (APS/TAM) browser renderer ownership. APS server -# behavior (endpoint, account id, timeouts, script creatives) is declared on an -# `aps`-profile [auction.providers.] table in the auction section above. -# Winning-bid renderer. Default `trusted_server` uses TS's opaque static renderer -# route; set `publisher_native` only for the controlled publisher-origin -# friendly-frame experiment. -# [integrations.aps] -# enabled = true -# rendering_mode = "trusted_server" - # Google Tag Manager first-party proxy. Kept active but disabled so `ts audit` # can fill container_id and flip `enabled` when GTM is detected. `container_id` # is required when this integration is actually enabled. From 67c3ff5eb696fbcb2dab0a7d256397fe613a22ba Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:46:31 -0700 Subject: [PATCH 844/844] fix(ci): align APS TSJS hard-cutover checks --- .github/workflows/integration-tests.yml | 6 + .../tests/shared/tsjs-performance.spec.ts | 66 +++++--- .../src/bin/generate-tsjs-fixture.rs | 20 +-- ...8-04-aps-tsjs-resilience-implementation.md | 34 +++-- ...s-render-fix-and-tsjs-resilience-design.md | 70 ++++++--- scripts/template-cache-local-test.sh | 144 ++---------------- .../template-cache-verify-hard-cutover.mjs | 100 ++++++++++++ .../validate-tsjs-performance-evidence.mjs | 109 +++++++------ 8 files changed, 307 insertions(+), 242 deletions(-) create mode 100644 scripts/template-cache-verify-hard-cutover.mjs diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 7eb3cd870..4761fd311 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -270,6 +270,12 @@ jobs: name: integration-test-artifacts path: ${{ env.ARTIFACTS_DIR }} + - name: Generate browser Viceroy configs with APS enabled + run: ./scripts/generate-integration-viceroy-configs.sh + env: + INTEGRATION_ENABLE_AUCTION: "true" + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + - name: Load integration test Docker images run: docker load --input "$DOCKER_ARTIFACT_PATH" 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 e3f9befcf..edd2be7df 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 @@ -131,6 +131,18 @@ interface ReferenceTransfer { brotliBytes: number; } +function externalAgentTransfer( + transfer: ReferenceTransfer, +): ReferenceTransferSource { + const external = transfer.sources.filter( + ({ delivery }) => delivery === "external", + ); + if (external.length !== 1) { + throw new Error("reference transfer must contain one external agent"); + } + return external[0]!; +} + interface FixtureRun { context: BrowserContext; page: Page; @@ -1898,7 +1910,7 @@ test("measures each distinct semantic transfer source exactly once", () => { ).toThrow("semantic transfer source is empty or duplicated"); }); -test("gates semantic reference transfer against the exact rc base build", () => { +test("gates selected agent transfer against the exact rc base build", () => { const mode = process.env.TSJS_PERF_MODE; const baselineRoot = process.env.TSJS_PERF_BASE_ROOT; const baseSha = process.env.TSJS_PERF_BASE_SHA; @@ -1909,12 +1921,12 @@ test("gates semantic reference transfer against the exact rc base build", () => mode === "pull-request" ) { throw new Error( - "exact-rc semantic transfer comparison is required in CI and release modes", + "exact-rc selected-agent transfer comparison is required in CI and release modes", ); } test.skip( true, - "exact-rc semantic transfer comparison unavailable; check:bundle still enforces candidate ceilings", + "exact-rc selected-agent transfer comparison unavailable; check:bundle still enforces controller and agent ceilings", ); return; } @@ -1926,19 +1938,21 @@ test("gates semantic reference transfer against the exact rc base build", () => ).toBe(baseSha); const baselineResources = loadBaselineFixtureResources(baselineRoot); const candidateResources = loadReleaseFixtureResources(REPO_ROOT); + const baselineAgent = externalAgentTransfer( + baselineResources.referenceTransfer, + ); + const candidateAgent = externalAgentTransfer( + candidateResources.referenceTransfer, + ); const regressions = ( ["rawBytes", "gzipBytes", "brotliBytes"] as const - ).filter( - (metric) => - candidateResources.referenceTransfer[metric] > - baselineResources.referenceTransfer[metric], - ); + ).filter((metric) => candidateAgent[metric] > baselineAgent[metric]); expect( regressions, JSON.stringify({ - baseline: baselineResources.referenceTransfer, - candidate: candidateResources.referenceTransfer, + baselineAgent, + candidateAgent, }), ).toEqual([]); }); @@ -2186,13 +2200,19 @@ test.describe("TSJS first-display performance gate", () => { expect .soft(candidateP90, "candidate p90") .toBeLessThanOrEqual(baselineP90 * MAXIMUM_P90_RATIO); + const baselineAgent = externalAgentTransfer( + baselineResources.referenceTransfer, + ); + const candidateAgent = externalAgentTransfer( + candidateResources.referenceTransfer, + ); for (const metric of ["rawBytes", "gzipBytes", "brotliBytes"] as const) { expect .soft( - candidateResources.referenceTransfer[metric], - `candidate ${metric} semantic transfer`, + candidateAgent[metric], + `candidate ${metric} selected-agent transfer`, ) - .toBeLessThanOrEqual(baselineResources.referenceTransfer[metric]); + .toBeLessThanOrEqual(baselineAgent[metric]); } for (let index = 0; index < WARMUPS; index += 1) { @@ -2268,15 +2288,19 @@ test.describe("TSJS first-display performance gate", () => { expect .soft(apsTotalToPaintP90, "APS total-to-paint p90") .toBeLessThanOrEqual(APS_TOTAL_P90_CEILING_MS); + const apsBaselineAgent = externalAgentTransfer( + apsBaselineResources.referenceTransfer, + ); + const apsCandidateAgent = externalAgentTransfer( + apsCandidateResources.referenceTransfer, + ); for (const metric of ["rawBytes", "gzipBytes", "brotliBytes"] as const) { expect .soft( - apsCandidateResources.referenceTransfer[metric], - `APS candidate ${metric} semantic transfer`, + apsCandidateAgent[metric], + `APS candidate ${metric} selected-agent transfer`, ) - .toBeLessThanOrEqual( - apsBaselineResources.referenceTransfer[metric] * MAXIMUM_P90_RATIO, - ); + .toBeLessThanOrEqual(apsBaselineAgent[metric] * MAXIMUM_P90_RATIO); } const representativeRun = await openFixture( @@ -2374,7 +2398,7 @@ test.describe("TSJS first-display performance gate", () => { encoding: "utf8", }).trim(); const evidence = { - schemaVersion: 6, + schemaVersion: 7, evidenceId, mode, headSha, @@ -2436,7 +2460,7 @@ test.describe("TSJS first-display performance gate", () => { }, }, transfer: { - algorithm: "semantic-tsjs-transfer-v1", + algorithm: "split-preaction-transfer-v2", baselineReferenceTransfer: { sha: baseSha, artifactModel: baselineResources.artifactModel, @@ -2519,7 +2543,7 @@ test.describe("TSJS first-display performance gate", () => { }, }, transfer: { - algorithm: "semantic-tsjs-transfer-v1", + algorithm: "split-preaction-transfer-v2", maximumRatio: MAXIMUM_P90_RATIO, baselineReferenceTransfer: { sha: baseSha, diff --git a/crates/trusted-server-integration-tests/src/bin/generate-tsjs-fixture.rs b/crates/trusted-server-integration-tests/src/bin/generate-tsjs-fixture.rs index 509f0cfb7..8b218a4f4 100644 --- a/crates/trusted-server-integration-tests/src/bin/generate-tsjs-fixture.rs +++ b/crates/trusted-server-integration-tests/src/bin/generate-tsjs-fixture.rs @@ -170,21 +170,23 @@ mod tests { assert!(html.contains(r#"id="perf-slot""#)); assert!(html.contains( - r#""creative":{"version":1,"enabled":true,"clickGuard":true,"renderGuard":false}"# + r#"\"creative\":{\"version\":1,\"enabled\":true,\"clickGuard\":true,\"renderGuard\":false}"# )); - assert!(html.contains(r#""renderTraceOverlay":true"#)); + assert!(html.contains(r#"\"renderTraceOverlay\":true"#)); assert!(html.contains( - r#"{"id":"gpt","config":{"gamAttributionEnabled":false,"pageBidsEnabled":true}}"# + r#"{\"id\":\"gpt\",\"config\":{\"gamAttributionEnabled\":false,\"pageBidsEnabled\":true}}"# )); - assert!(html.contains(r#""id":"diagnostics_presentation","phase":"deferred""#)); - assert!(html.contains(r#""id":"gpt_later","phase":"deferred""#)); + assert!(html.contains(r#"\"id\":\"diagnostics_presentation\",\"phase\":\"deferred\""#)); + assert!(html.contains(r#"\"id\":\"gpt_later\",\"phase\":\"deferred\""#)); assert!(html.contains( - r#""firstDisplay":{"src":"/static/tsjs=tsjs-first-display.min.js?m=0045\u0026v="# + r#"\"firstDisplay\":{\"src\":\"/static/tsjs=tsjs-first-display.min.js?m=008b\u0026v="# )); - assert!(html.contains(r#""slices":["first_display","creative_initial","gpt_initial"]"#)); - assert!(html.contains(r#""runtimeSrc":"/static/tsjs=tsjs-unified.min.js?v="#)); + assert!(html.contains( + r#"\"slices\":[\"first_display\",\"render_owner_initial\",\"creative_initial\",\"gpt_initial\"]"# + )); + assert!(html.contains(r#"\"runtimeSrc\":\"/static/tsjs=tsjs-unified.min.js?v="#)); assert_eq!(html.matches(" "$WORK/verify-seam.mjs" <<'NODEEOF' -import fs from "node:fs"; -import vm from "node:vm"; - -const [documentPath, gptPath] = process.argv.slice(2); -const html = fs.readFileSync(documentPath, "utf8"); -const gpt = fs.readFileSync(gptPath, "utf8"); -const scripts = [...html.matchAll(/