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..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 @@ -183,7 +183,16 @@ jobs: with: node-version: ${{ steps.shared-setup.outputs.node-version }} cache: npm - cache-dependency-path: crates/trusted-server-integration-tests/browser/package-lock.json + cache-dependency-path: | + crates/trusted-server-integration-tests/browser/package-lock.json + crates/trusted-server-js/lib/package-lock.json + + - name: Build TSJS browser fixtures + working-directory: crates/trusted-server-js/lib + run: | + npm ci + npm run build + npm run build:prebid-external - name: Install Playwright working-directory: crates/trusted-server-integration-tests/browser @@ -228,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/CHANGELOG.md b/CHANGELOG.md index 5bc49c80b..6ac59b0d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration now uses canonical `account_id` (`pub_id` remains a compatibility alias), no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. - **Breaking** — `bid_param_zone_overrides` inner values must now be JSON objects; previously non-object or empty values (`"header" = "x"`, `"header" = {}`) were accepted and silently produced a dead rule at runtime. They now fail at startup with a configuration error. Operators upgrading should audit their `bid_param_zone_overrides` config for non-object zone entries. - **Breaking** — Sourcepoint browser module inclusion now requires explicit `[integrations.sourcepoint].enabled = true`; operators relying on the previous unconditional Sourcepoint module should enable the integration before upgrading. +- Added optional APS `inventory_domain` and `inventory_page_origin` overrides for deployments whose edge hostname differs from the APS-authorized inventory identity. +- Preserved APS renderer capabilities through the client-side `trustedServer` Prebid adapter, allowing its generated `hb_adid` to render through GAM and Prebid Universal Creative instead of producing an empty creative. ### Security @@ -18,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added opt-in APS HTTP debug metadata for controlled test sites, exposing the direct request and response under `/auction` provider metadata using the Prebid Server `debug.httpcalls` shape. +- 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 typed APS renderer transport for direct auctions and GAM/Prebid Universal Creative, using a minimized one-bid envelope, a fragment-bound nonce, and an opaque sandboxed renderer endpoint. - 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/Cargo.lock b/Cargo.lock index 9c89eb87c..cb8f40c68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5352,6 +5352,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 695099d49..7ca87e687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ version = "0.1.0" [workspace.dependencies] anyhow = "1" +async-stream = "0.3" async-trait = "0.1" axum = "0.8" base64 = "0.22" diff --git a/TESTING.md b/TESTING.md index e1ac1c3a2..c4a8c43ea 100644 --- a/TESTING.md +++ b/TESTING.md @@ -131,8 +131,9 @@ INFO: Running 2 bidders in parallel INFO: Requesting bids from: prebid INFO: Prebid returned 2 bids (time: 120ms) INFO: Requesting bids from: aps -INFO: APS (MOCK): returning 2 bids in 80ms -INFO: GAM mediation: slot 'header-banner' won by 'amazon-aps' at $2.50 CPM +INFO: APS requests bids for 2 impressions +INFO: APS returns 2 accepted bids in 80ms +INFO: GAM mediation: slot 'header-banner' won by 'aps' at $2.50 CPM ``` ### Verify Provider Registration 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..1700c3969 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, 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, 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, @@ -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, @@ -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. @@ -798,13 +796,13 @@ async fn dispatch_fallback( .await { Ok(pub_response) => { - buffer_publisher_response_async( + publisher_response_into_streaming_response( pub_response, &method, - &state.settings, - &state.registry, - &state.orchestrator, - &publisher_services, + Arc::clone(&state.settings), + state.registry.as_ref(), + Arc::clone(&state.orchestrator), + publisher_services.clone(), ) .await } @@ -1161,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)); @@ -2158,6 +2157,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, @@ -2268,6 +2271,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-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs index f2ff5d9e5..db55aa07b 100644 --- a/crates/trusted-server-adapter-fastly/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -328,10 +328,11 @@ impl<'a> BackendConfig<'a> { /// Ensure a dynamic backend exists for this configuration and return its name. /// - /// The name is a collision-resistant function of the complete backend spec - /// (see `Self::compute_name`), so different specs — for example, different - /// timeout values — always produce different backend registrations and a - /// tight deadline cannot be silently widened by an earlier registration. + /// The backend name is derived from the scheme, host, port, certificate + /// setting, `first_byte_timeout`, and `between_bytes_timeout` to avoid + /// collisions. Different timeout values produce different backend + /// registrations so that a tight deadline cannot be silently widened by an + /// earlier registration. /// /// # Errors /// diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index ab3236c0f..e6cda086f 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>, @@ -337,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(); @@ -350,11 +351,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/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/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index ffa58122f..36f277070 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -458,11 +458,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()) @@ -480,7 +483,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(); @@ -506,6 +511,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 @@ -522,6 +533,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, @@ -529,14 +544,22 @@ 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 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)?; - 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( @@ -552,7 +575,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)?; @@ -599,7 +624,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, ) } @@ -873,6 +898,75 @@ 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 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 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 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; 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/Cargo.toml b/crates/trusted-server-core/Cargo.toml index a8ca8e0c9..c035799e9 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -12,6 +12,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/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/README.md b/crates/trusted-server-core/src/auction/README.md index dcc9e1506..fad3cb4d5 100644 --- a/crates/trusted-server-core/src/auction/README.md +++ b/crates/trusted-server-core/src/auction/README.md @@ -117,7 +117,7 @@ When a request arrives at the `/auction` endpoint, it goes through the following ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ 9. Each Provider Processes Request │ -│ - Transform AuctionRequest → Provider format (e.g., APS TAM) │ +│ - Transform AuctionRequest → Provider OpenRTB request │ │ - Send HTTP request to provider endpoint │ │ - Parse provider response │ │ - Transform → AuctionResponse with Bid[] │ @@ -188,30 +188,21 @@ AdSlot { #### 3. Provider Execution Each registered provider (APS, Prebid, etc.) receives the `AuctionRequest` and: -- Transforms it to their specific format (e.g., APS TAM, OpenRTB) +- Transforms it to the provider's OpenRTB request format - Makes HTTP request to their endpoint - Parses the response - Returns `AuctionResponse` with `Bid[]` For example, APS provider: ```rust -// Transform AuctionRequest → ApsBidRequest -let aps_request = ApsBidRequest { - pub_id: "5128", - slots: vec![ - ApsSlot { - slot_id: "header-banner", - sizes: vec![[728, 90], [970, 250]], - slot_name: Some("header-banner"), - } - ], - page_url: Some("https://example.com"), - ua: Some("Mozilla/5.0..."), - timeout: Some(800), -}; - -// HTTP POST to http://localhost:6767/e/dtb/bid -// Parse response → AuctionResponse +// Transform AuctionRequest → APS OpenRTB request +// - ext.account = configured account_id +// - ext.sdk = { source: "prebid", version: "2.2.0" } +// - banner slots become secure impressions with matching formats/floors +// - existing consent, identity, device, and geo privacy gates apply + +// HTTP POST to https://web.ads.aps.amazon-adsystem.com/e/pb/bid +// Parse decoded-price response → AuctionResponse with a typed renderer ``` #### 4. Response Assembly @@ -222,17 +213,29 @@ The orchestrator collects all bids and creates an OpenRTB response: "id": "auction-response", "seatbid": [ { - "seat": "amazon-aps", + "seat": "aps", "bid": [ { - "id": "amazon-aps-header-banner", + "id": "fictional-selected-bid-id", "impid": "header-banner", "price": 2.5, - "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(), + }, + }, + // 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(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' }, + }, + // 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(setDisplayBidsSpy).not.toHaveBeenCalled(); + + delete (window as TestWindow).apstag; + }); + + 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(), + 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).not.toHaveBeenCalled(); + }); + + 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).not.toHaveBeenCalled(); + }); +}); + +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(); + 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!; + } + + 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; + // 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(); + 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(); + expect(portMessages).toHaveLength(1); + 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('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); + bridgeListener(event); + + 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(); + }); + + 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).not.toHaveBeenCalled(); + 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('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; + // 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(); // 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', + expect.objectContaining({ mode: 'cors', signal: expect.any(AbortSignal) }) + ); + 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('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(fetchStub).toHaveBeenCalledTimes(1); + expect(portMessages).toHaveLength(1); + 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + 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 () => { + // 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 = () => {}; + 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('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: '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', + hb_adid: 'debug-adid', + hb_bidder: 'mocktioneer', + hb_pb: '0.20', + nurl: 'https://debug.example/win', + burl: 'https://debug.example/bill', + adm: debugAdm, }, }, adSlots: [ @@ -878,24 +2794,7 @@ describe('installTsRenderBridge', () => { }, ], }; - }); - - afterEach(() => { - vi.unstubAllGlobals(); - document.getElementById('div-header')?.remove(); - delete (window as TestWindow).tsjs; - }); - - function createTrustedSlotIframe(): Window { - const slot = document.createElement('div'); - slot.id = 'div-header'; - 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 @@ -910,33 +2809,6 @@ describe('installTsRenderBridge', () => { await import('../../../src/integrations/gpt/index'); addSpy.mockRestore(); - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); - return bridgeListener!; - } - - it('calls stopImmediatePropagation and fetches PBS Cache for a TS bid', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const mockAd = '
Test Creative
'; - fetchStub.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(mockAd), - } 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; - // 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(); // Restore only addEventListener — fetchStub must stay stubbed - expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); const stopSpy = vi.fn(); @@ -944,113 +2816,127 @@ describe('installTsRenderBridge', () => { 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' }), + data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-adid' }), 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(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('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(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('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. + 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); - const mockAd = '
Test Creative
'; - let resolveFetch: (value: Response) => void = () => {}; - fetchStub.mockReturnValue( - new Promise((resolve) => { - resolveFetch = resolve; - }) - ); + // 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 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); + 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'); + capturePrivateOwner('slot_a', 'div-a'); + capturePrivateOwner('slot_b', 'div-b'); - // 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)); + 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 + ); + } - 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(); + // 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('responds with adm without fetching PBS Cache when debug adm is available', async () => { + it('serves inline adm without fetching PBS Cache even when cache coords are present', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - const debugAdm = '
Debug Creative
'; + 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: debugAdm, + adm: inlineAdm, }, }, adSlots: [ @@ -1103,7 +2989,7 @@ describe('installTsRenderBridge', () => { 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.ad).toBe(inlineAdm); expect(parsed.width).toBe(728); expect(parsed.height).toBe(90); expect(beaconSpy).toHaveBeenCalledWith('https://debug.example/win'); @@ -1112,6 +2998,142 @@ describe('installTsRenderBridge', () => { 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + 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!; + capturePrivateOwner('homepage_in_content', 'div-in-content'); + + 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + // 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', { @@ -1235,7 +3257,7 @@ describe('installTsRenderBridge', () => { window.dispatchEvent( new MessageEvent('message', { data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), - ports: [fakePort as MessagePort], + ports: [fakePort as unknown as MessagePort], source: foreignIframe.contentWindow, }) ); @@ -1280,7 +3302,7 @@ describe('installTsRenderBridge', () => { 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 MessagePort], + ports: [fakePort as unknown as MessagePort], source, }) ); 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..d773fa08f --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_trace.test.ts @@ -0,0 +1,405 @@ +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 and bills once, then supersedes acknowledgement ownership on refresh', () => { + const source = trustedSource(); + const foreignSource = window; + const port = { postMessage: vi.fn() }; + const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const targeting: Record = { + hb_adid: 'old-ad-id', + hb_bidder: 'example-bidder', + ts_trace: OLD_TOKEN, + }; + const slot = slotWithTargeting(targeting); + 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 + ); + 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); + + 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' }) + ); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'creative_ack_source_mismatched', + generation: 1, + bidTraceId: OLD_TOKEN, + }) + ); + + // GPT acknowledgement messages carry no generation, so a normal refresh + // proactively terminates the old pending acknowledgement. + Object.assign(targeting, { hb_adid: 'client-next', hb_bidder: 'client-bidder' }); + delete targeting.ts_trace; + module.captureAdTraceRequest(slot as any, 'prebid_refresh'); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'creative_ack_superseded', + generation: 1, + bidTraceId: OLD_TOKEN, + }) + ); + record.mockClear(); + for (const traceToken of [OLD_TOKEN, NEW_TOKEN]) { + bridge( + Object.assign(new Event('message'), { + data: { type: 'ts-creative-load', version: 1, traceToken }, + source, + }) as unknown as MessageEvent + ); + } + expect(record).not.toHaveBeenCalledWith( + expect.objectContaining({ kind: 'creative_load_acknowledged' }) + ); + expect(beacon).toHaveBeenCalledTimes(2); + + module.supersedeAdTraceSlot(slot 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'); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'creative_ack_superseded', + generation: 1, + bidTraceId: OLD_TOKEN, + }) + ); + 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('records when an exact renderer response cannot arm a trace token', () => { + const source = trustedSource(); + const slot = slotWithTargeting({ + hb_adid: 'old-ad-id', + hb_bidder: 'example-bidder', + }); + 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 + ); + + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'creative_ack_missing_token', + generation: 1, + }) + ); + expect(JSON.parse(port.postMessage.mock.calls[0][0])).not.toHaveProperty('traceToken'); + }); + + 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: 'creative_ack_timed_out', + generation: 1, + bidTraceId: OLD_TOKEN, + }) + ); + }); + + it('records an acknowledgement timeout without waiting for another bridge message', () => { + vi.useFakeTimers(); + try { + 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 + ); + record.mockClear(); + + vi.advanceTimersByTime(30_000); + + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'creative_ack_timed_out', + generation: 1, + bidTraceId: OLD_TOKEN, + }) + ); + } finally { + vi.useRealTimers(); + } + }); +}); 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/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 9a08defcb..761fa8b3b 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 @@ -74,11 +74,11 @@ describe('installSpaAuctionHook', () => { const adInit = vi.fn(); ts.adInit = adInit; - history.pushState({}, '', '/next-page'); + history.pushState({}, '', '/next-page?edition=fictional#section'); await flushAsync(); expect(fetchStub).toHaveBeenCalledWith( - '/__ts/page-bids?path=%2Fnext-page', + '/__ts/page-bids?path=%2Fnext-page%3Fedition%3Dfictional', expect.objectContaining({ credentials: 'include', headers: { 'X-TSJS-Page-Bids': '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 726f40b49..017073678 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,4 +1,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import envelope from '../../fixtures/aps-renderer-v1.json'; + +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, + }; +} // Define mocks using vi.hoisted so they're available inside vi.mock factories const { @@ -8,6 +25,9 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockMarkBidAsRendered, + mockMarkWinner, + mockOnEvent, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -17,6 +37,9 @@ const { const mockRequestBids = vi.fn(); const mockRegisterBidAdapter = vi.fn(); const mockGetBidAdapter = vi.fn(); + const mockMarkBidAsRendered = vi.fn(); + const mockMarkWinner = vi.fn(); + const mockOnEvent = vi.fn(); const mockGetUserIdsAsEids = vi.fn( () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); @@ -28,6 +51,7 @@ const { registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, getConfig: mockGetConfig, + onEvent: mockOnEvent, adUnits: [] as any[], }; const mockAdapterManager = { @@ -40,6 +64,9 @@ const { mockRegisterBidAdapter, mockGetUserIdsAsEids, mockGetConfig, + mockMarkBidAsRendered, + mockMarkWinner, + mockOnEvent, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -50,6 +77,10 @@ const { // 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 })); +vi.mock('prebid.js/src/adRendering.js', () => ({ + markBidAsRendered: mockMarkBidAsRendered, + markWinner: mockMarkWinner, +})); // Side-effect imports are no-ops in tests vi.mock('prebid.js/modules/consentManagementTcf.js', () => ({})); @@ -149,6 +180,70 @@ describe('prebid/auctionBidsToPrebidBids', () => { }); }); + it('preserves an APS renderer without converting it to executable markup', () => { + const renderer = apsRenderer(); + const auctionBids: AuctionBid[] = [ + { + impid: 'div-aps', + adm: '', + 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, + }) + ); + }); + + 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 +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 (mockPbjs as any).__tsApsBidResponseListenerInstalled; + delete (mockPbjs as any).__tsAdTraceObserved; + delete window.tsjs; }); afterEach(() => { @@ -241,6 +339,116 @@ describe('prebid/installPrebidNpm', () => { ); }); + 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 = (window as any).tsjs.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(); + expect(mockMarkWinner).toHaveBeenCalledWith( + expect.objectContaining({ adId: 'prebid-generated-ad-id' }) + ); + expect(mockMarkBidAsRendered).toHaveBeenCalledWith( + expect.objectContaining({ adId: 'prebid-generated-ad-id' }) + ); + }); + + 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(); + // interpretResponse output: stashes the descriptor keyed by requestId (survives + // Prebid's bid normalization, which strips the custom trustedServerRenderer field). + 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 field REMOVED — only requestId survives. + bidResponseListener!({ + adapterCode: 'trustedServer', + bidderCode: 'aps', + adId: 'stripped-field-ad-id', + adUnitCode: 'div-aps', + ttl: 300, + requestId: 'req-strip', + }); + + const entry = (window as any).tsjs.apsPrebidRenderers['stripped-field-ad-id']; + expect(entry).toEqual( + expect.objectContaining({ adUnitCode: 'div-aps', renderer, markWinner: expect.any(Function) }) + ); + }); + + it('does not register malformed or non-trusted APS renderer capabilities', () => { + 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((window as any).tsjs?.apsPrebidRenderers?.['malformed-ad-id']).toBeUndefined(); + expect((window as any).tsjs?.apsPrebidRenderers?.['foreign-ad-id']).toBeUndefined(); + expect(malformedBid).not.toHaveProperty('trustedServerRenderer'); + }); + it('calls setConfig with debug=false by default', () => { installPrebidNpm(); @@ -668,6 +876,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(); @@ -800,6 +1017,89 @@ describe('prebid/installPrebidNpm', () => { expect(document.cookie).toBe(''); }); + + it('retains only the bounded Prebid auction duration for later generation attachment', () => { + let now = 100; + vi.spyOn(performance, 'now').mockImplementation(() => now); + const recordAdTrace = vi.fn(); + window.tsjs = { recordAdTrace } as any; + installPrebidNpm(); + const handlers = new Map) => void>( + mockOnEvent.mock.calls.map(([event, handler]) => [event, handler]) + ); + handlers.get('auctionInit')?.({ auctionId: 'auction-1' }); + handlers.get('bidResponse')?.({ + auctionId: 'auction-1', + adUnitCode: 'slot-a', + requestId: 'request-1', + adId: 'ad-1', + }); + now = 1_600; + handlers.get('auctionEnd')?.({ auctionId: 'auction-1', adUnits: [{ code: 'slot-a' }] }); + + expect(window.tsjs!.prebidCorrelation?.[0]).toMatchObject({ + slotId: 'slot-a', + prebidAuctionDurationMs: 1_500, + }); + expect(window.tsjs!.prebidCompletedAuctions?.[0]).toMatchObject({ + slotIds: ['slot-a'], + prebidAuctionDurationMs: 1_500, + }); + expect(JSON.stringify(window.tsjs!.prebidCorrelation)).not.toContain('performance'); + expect(recordAdTrace).toHaveBeenCalledWith({ kind: 'prebid_bid_response' }); + expect(JSON.stringify(recordAdTrace.mock.calls)).not.toContain('slot-a'); + }); + + it('joins late winner and render events to the retained selected generation', () => { + const recordAdTrace = vi.fn(); + const recordAdTraceCoverage = vi.fn(); + window.tsjs = { + recordAdTrace, + recordAdTraceCoverage, + 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 = { + // Some documented terminal payloads omit auctionId. Exact slot plus + // immutable request/ad identity must still resolve uniquely. + 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(recordAdTraceCoverage).toHaveBeenCalledWith({ + category: 'prebid_render_succeeded', + resolution: 'correlated', + }); + expect(window.tsjs!.prebidSelectedParticipants).toEqual([]); + }); }); }); @@ -854,11 +1154,13 @@ describe('prebid/installRefreshHandler', () => { mockPbjs.adUnits = []; (window as any).tsjs = undefined; delete (window as any).googletag; + delete (window as any).__tsjs_prebid; }); afterEach(() => { (window as any).tsjs = undefined; delete (window as any).googletag; + delete (window as any).__tsjs_prebid; }); it('builds refresh ad units from injected slot metadata', () => { @@ -1352,6 +1654,192 @@ describe('prebid/installRefreshHandler', () => { 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]), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).__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), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).__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(targetSlots, options); + }); + + it('auctions eligible slots and refreshes every slot in a mixed global refresh', () => { + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).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), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).__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); + + delete (mockPbjs as any).setTargetingForGPTAsync; + }); + + 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]), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).__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]), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + (window as any).__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(); @@ -1403,6 +1891,678 @@ 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 lists synthetic and bypasses 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?: { 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(2); + expect(mockRequestBids.mock.calls[1][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).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); + }); + + 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: () => [], + 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(); diff --git a/crates/trusted-server-js/lib/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index 97d9d84c8..ad9452899 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -11,6 +11,10 @@ export default defineConfig({ __dirname, 'node_modules/prebid.js/dist/src/src/adapterManager.js' ), + 'prebid.js/src/adRendering.js': path.resolve( + __dirname, + 'node_modules/prebid.js/dist/src/src/adRendering.js' + ), }, }, test: { diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index d75958812..64a46610f 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -10,9 +10,9 @@ Key capabilities: - **Parallel execution** — Bid requests to all providers launch concurrently using Fastly's `select()` API - **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 +- **Mediator support** — Optional external mediator for final winner selection and 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) @@ -92,26 +92,26 @@ sequenceDiagram activate Mock par Parallel Provider Calls - Orch->>APS: POST /e/dtb/bid
APS TAM format - Note right of Orch: { "pubId": "5128",
"slots": [{ "slotID": "header-banner",
"sizes": [[728,90]] }] } + Orch->>APS: POST /e/pb/bid
APS OpenRTB + Note right of Orch: { "id": "request",
"imp": [{ "id": "header-banner",
"banner": { "w": 728, "h": 90 } }],
"ext": { "account": "example-account" } } - APS->>Mock: APS TAM request - Mock-->>APS: APS bid response
(encoded prices, no creative) - Note right of Mock: { "contextual": { "slots": [{
"slotID": "header-banner",
"amznbid": "Mi41MA==", // "2.50"
"fif": "1" }] } } + APS->>Mock: APS OpenRTB request + Mock-->>APS: OpenRTB bid response
(decoded price and renderer URL) + Note right of Mock: { "seatbid": [{ "bid": [{
"impid": "header-banner", "price": 2.50,
"ext": { "creativeurl": "https://creative.example/render",
"tagtype": "iframe" } }] }] } - APS-->>Orch: AuctionResponse
(APS bids) + APS-->>Orch: AuctionResponse
(decoded price and typed renderer) and Orch->>Prebid: POST /openrtb2/auction
OpenRTB 2.x format Note right of Orch: { "id": "request",
"imp": [{ "id": "header-banner",
"banner": { "w": 728, "h": 90 } }] } Prebid->>Mock: OpenRTB request - Mock-->>Prebid: OpenRTB response
(clear prices, with creative) + Mock-->>Prebid: OpenRTB response
(decoded price with creative) Note right of Mock: { "seatbid": [{ "seat": "prebid",
"bid": [{ "price": 2.00, "adm": "..." }] }] } Prebid-->>Orch: AuctionResponse
(Prebid bids) end - Note over Orch: Collected bids from all providers
APS: encoded prices, no creative
Prebid: clear prices, with creative + Note over Orch: Collected decoded-price bids
APS: typed renderer, no adm
Prebid: sanitized creative or cache source deactivate Mock deactivate APS deactivate Prebid @@ -122,23 +122,19 @@ sequenceDiagram rect rgb(236,253,245) Note over Client,Mock: Mediation Flow activate Med - Orch->>Med: POST /adserver/mediate
All bids for final selection - Note right of Orch: { "id": "auction-123",
"imp": [...],
"ext": { "bidder_responses": [
{ "bidder": "amazon-aps",
"bids": [{ "encoded_price": "Mi41MA==" }] },
{ "bidder": "prebid",
"bids": [{ "price": 2.00 }] }] } } - - Med->>Med: Decode APS encoded prices
Apply floor prices
Select highest CPM per slot - Note right of Med: Base64 decode: "Mi41MA==" → "2.50"
Winner: APS at $2.50 vs Prebid at $2.00 + Orch->>Med: POST /adserver/mediate
Decoded-price bids for final selection + Note right of Orch: APS price: 2.50
Prebid price: 2.00 + Med->>Med: Apply mediation policy and floors
Select highest CPM per slot Med-->>Orch: OpenRTB response with winners - Note right of Med: { "seatbid": [{ "seat": "amazon-aps",
"bid": [{ "price": 2.50, "impid": "header-banner" }] }] } + Note right of Med: APS renderer state is restored from
the reduced source bid after mediation deactivate Med end else No Mediator (parallel_only) rect rgb(253,243,235) Note over Client,Mock: Direct Winner Selection - Orch->>Orch: Compare clear prices only
Skip APS (encoded prices)
Select highest CPM - Note right of Orch: APS bids skipped (encoded prices)
Winner: Prebid at $2.00 (only clear price) - - Note over Orch: Results: Limited winner selection
Cannot compare encoded APS prices
Prebid wins by default + Orch->>Orch: Compare decoded prices
Apply slot floor
Select highest CPM + Note right of Orch: Winner: APS at $2.50 vs Prebid at $2.00 end end @@ -147,12 +143,12 @@ 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
Preserve typed render source
Sanitize ordinary 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": "' } // 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` + +- [ ] **Step 3: Implement** — change the guard (`ts` is the local `window.tsjs`): + +```ts +// Direct GAM replacement is a testing-only bypass. `debug_bid` is present only +// 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) +} +``` + +- [ ] **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.** + +- [ ] **Step 6: Commit** — `git commit -m "Gate GAM-bypass adm injection on per-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` +response without a cache fetch; `nurl`/`burl` on the local path; cache-fetch +concurrency + beacon dedup. Do **not** duplicate them. + +- [ ] **Step 1:** Rename "debug adm" terminology → "inline/local adm" in the existing bridge tests. +- [ ] **Step 2:** Confirm the local-`adm` test fixtures carry **both** `hb_cache_*` coordinates **and** inline `adm`, proving the bridge prefers local `adm` even when cache coords are present (the production shape). +- [ ] **Step 3: Run — expect PASS.** `cd crates/trusted-server-js/lib && npx vitest run ad_init` +- [ ] **Step 4: Commit** — `git commit -m "Rename debug-adm test terminology to inline/local adm"` + +--- + +## Task 5: Full verification + +- [ ] **Step 1:** `cargo fmt --all -- --check` +- [ ] **Step 2:** `cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin` +- [ ] **Step 3:** Clippy — exact CI-gate commands: + ``` + cargo clippy-fastly + cargo clippy-axum + cargo clippy-cloudflare + cargo clippy-cloudflare-wasm + cargo clippy-spin-native + cargo clippy-spin-wasm + ``` +- [ ] **Step 4:** `cd crates/trusted-server-js/lib && npx vitest run && npm run format && node build-all.mjs` +- [ ] **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. + +--- + +## 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 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. +- **Precondition:** only changes the bridge's data source when GAM's Prebid line item already serves the PUC — no change to GAM competition or whether the PUC fires. diff --git a/docs/superpowers/plans/2026-07-15-aps-openrtb-first-class-integration.md b/docs/superpowers/plans/2026-07-15-aps-openrtb-first-class-integration.md new file mode 100644 index 000000000..0f89e6f9f --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-aps-openrtb-first-class-integration.md @@ -0,0 +1,897 @@ +# Amazon APS OpenRTB First-Class Integration Implementation Plan + +> **For agentic workers:** Implement task-by-task with tests first. Do not use bare +> `cargo test --workspace`; use the adapter-aware aliases from `.cargo/config.toml`. +> Commit or push only when the supervising user explicitly requests it. + +**Goal:** Replace the legacy APS `/e/dtb/bid` integration with APS OpenRTB, +participate with decoded prices without mediation, and render APS banner creatives in +the direct-auction and GPT/page-bids browser paths. + +**Architecture:** Keep APS request/response policy in an APS-specific OpenRTB adapter. +Map the response into the shared `Bid` plus a typed, versioned APS renderer descriptor. +Carry that descriptor through `/auction` and the publisher bid map. TSJS builds a +Trusted Server-owned bootstrap for the fixed APS `prebid-creative.js` runner and +executes it in the approved sandbox. Ordinary `adm` remains on the existing mandatory +sanitize/rewrite path. + +**Tech stack:** Rust 2024, generated OpenRTB types, serde/serde_json, base64 0.22, +error-stack, URL validation, TypeScript, Vitest, Prebid Universal Creative messaging. + +**Spec:** +`docs/superpowers/specs/2026-07-15-aps-openrtb-first-class-integration-design.md` + +**Branch:** `issue-764-aps-openrtb` + +--- + +## Context and guardrails for every task + +- APS may be implemented and tested before production edge support is confirmed. + Broad production rollout remains blocked until the APS account team confirms the + Fastly/edge contract. +- Send APS SDK identity `{ "source": "prebid", "version": "2.2.0" }`. +- Build the exact renderer allowlist: one bid with only `id`, `price`, `w`, `h`, + `ext.creativeurl`, and `ext.tagtype`. Do not preserve unknown fields or silently + expose the complete APS response. +- The outer renderer sandbox must omit `allow-same-origin`. + `allow_script_creatives` is a server-side capability gate that defaults false; + disabled script bids must be dropped before candidate reduction/winner selection. + Enable it only under the staged opaque-origin and controlled-account test policy. +- APS user sync and APS `nurl`/`burl` delivery are out of scope. Do not add either + browser path. +- Cut over directly. Keep `pub_id` as an alias only; do not add a legacy protocol + switch. +- When a Trusted Server APS renderer is present, do not call + `apstag.setDisplayBids()`. +- Banner only. Do not advertise APS video support in this issue. +- Do not weaken `sanitize_creative_html` or its tests. +- Raw APS requests/responses remain TRACE-only. Never log real account IDs, EIDs, + consent strings, creative URLs, bid tokens, or response bodies at normal levels. +- All committed fixtures/docs use fictional `example` values. Controlled test-account + values stay out of the repository and terminal transcripts intended for review. +- Use `error-stack`; no anyhow/eyre/thiserror in core code. +- Use `log::` macros, not `println!`; use descriptive `expect("should ...")`, not + `unwrap()`. +- Run the focused test immediately after each production-code change. + +--- + +## File map + +### Rust core + +- `crates/trusted-server-core/src/auction/types.rs`: bid identifiers and typed + renderer contract. +- `crates/trusted-server-core/src/openrtb.rs`: narrowly scoped request/bid extension + serialization types if they are shared outside APS. +- `crates/trusted-server-core/src/integrations/aps.rs`: configuration, OpenRTB request, + response parser, exact renderer envelope, candidate reduction, static renderer route, + diagnostics and provider dispatch. +- `crates/trusted-server-core/src/integrations/mod.rs`: register the APS proxy route as + well as the separate auction provider. +- `crates/trusted-server-core/src/auction/formats.rs`: page derivation and `/auction` + renderer extension. +- `crates/trusted-server-core/src/auction/orchestrator.rs`: decoded-price direct-winner + coverage and stale-comment removal. +- `crates/trusted-server-core/src/integrations/adserver_mock.rs`: preserve renderer and + IDs through mediation. +- `crates/trusted-server-core/src/publisher.rs`: normal bid-map renderer transport and + APS `hb_adid`. + +### Browser + +- `crates/trusted-server-js/lib/src/core/types.ts`: APS renderer wire type. +- `crates/trusted-server-js/lib/src/core/auction.ts`: parse `/auction` renderer ext. +- `crates/trusted-server-js/lib/src/core/request.ts`: direct APS render dispatch. +- New `crates/trusted-server-js/lib/src/integrations/aps/render.ts`: exact envelope + decoding/cross-checking, opaque renderer frame creation and postMessage dispatch. +- `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`: exact APS Universal + Creative bridge, APS beacon suppression and native-APS hook removal. +- New `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json`: shared Rust/TS + golden wire fixture. +- New `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` plus existing + core/GPT tests. +- `crates/trusted-server-integration-tests/browser/`: restrictive-CSP and opaque-origin + Playwright coverage. + +### Configuration and docs + +- `trusted-server.example.toml` +- `docs/guide/integrations/aps.md` +- `CHANGELOG.md` + +--- + +## Task 1: Add typed bid identifiers and renderer state + +**What:** Give the shared auction model enough typed state to distinguish OpenRTB bid, +ad, and creative IDs and carry a validated APS renderer without using arbitrary +metadata. + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/types.rs` +- Modify: every production/test `Bid { ... }` construction found by + `rg -n 'Bid \{' crates/trusted-server-core` +- Test: `crates/trusted-server-core/src/auction/types.rs` + +- [ ] Add failing serde tests for a bid with: + - separate `bid_id`, `ad_id`, and `creative_id`; + - an APS renderer version, account ID, selected bid ID, tag type, creative URL, + encoded response, and dimensions; and + - `renderer = None` omission for ordinary bids. +- [ ] Define a strict `ApsTagType` enum with only `Iframe` and `Script`. +- [ ] Define a versioned, typed renderer enum/descriptor. The serialized renderer must + use camelCase and a discriminator equivalent to: + + ```json + { + "type": "aps", + "version": 1, + "accountId": "example-account-id", + "bidId": "fictional-bid-id", + "creativeId": "fictional-creative-id", + "tagType": "iframe", + "creativeUrl": "https://creative.example/render", + "aaxResponse": "base64-data", + "width": 300, + "height": 250 + } + ``` + +- [ ] Add to `Bid`: + - `bid_id: Option`; + - `creative_id: Option`; and + - `renderer: Option<...>`. +- [ ] Make descriptor `creativeId` optional and omit it when APS does not return + `crid`; add a serde test for the absent-`crid` case. +- [ ] Correct stale comments saying APS prices are encoded or that APS has no creative + delivery mechanism. +- [ ] Update every `Bid` literal. Use `None` outside production parser paths; do not use + serde defaults to hide missed production mappings. +- [ ] Update Prebid's `parse_bid` to preserve OpenRTB `id` as `bid_id` and `crid` as + `creative_id`, while retaining the existing strict `adid -> ad_id` semantics. +- [ ] Run the focused tests: + + ```bash + cargo test-fastly auction::types + cargo test-fastly integrations::prebid::tests::parse_bid + ``` + +- [ ] Run `cargo fmt --all` and `cargo check-fastly` before proceeding. + +**Expected result:** Shared bids can represent APS renderer state without overloading +`ad_id` or leaking renderer data through general metadata. + +--- + +## Task 2: Cut APS configuration over to the OpenRTB contract + +**What:** Replace the public APS configuration terminology and default endpoint before +replacing request/response behavior. + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Test: existing APS test module in the same file + +- [ ] Add failing configuration tests proving: + - `account_id = "example-account-id"` deserializes; + - legacy `pub_id` deserializes to the same field; + - string and integer compatibility is retained; + - empty and whitespace-only strings fail validation; + - surrounding whitespace is trimmed deterministically; + - supplying both names fails as a duplicate field; + - the default endpoint is `/e/pb/bid`; + - HTTP, missing-host, and credential-bearing endpoint overrides fail validation; and + - APS remains disabled by default; and + - `allow_script_creatives` defaults false. +- [ ] Rename `ApsConfig.pub_id` to `account_id`, using `pub_id` only as a serde alias. + Trim string values, reject empty-after-trim, normalize integers to strings, and + rename the custom deserializer/error text accordingly. +- [ ] Add `allow_script_creatives: bool` with a serde default of `false`. Treat it as a + server-side bid-eligibility capability, not a browser-only rendering preference. +- [ ] Change `default_endpoint()` to: + + ```text + https://web.ads.aps.amazon-adsystem.com/e/pb/bid + ``` + +- [ ] Replace generic URL-only endpoint validation with a custom check requiring HTTPS, + a non-empty host, and no username/password. Do not add a plaintext test exception: + the endpoint response is trusted to select executable renderer data. +- [ ] Remove account IDs from registration and request INFO logs. Log only that APS was + registered and the endpoint/provider-safe state needed operationally. +- [ ] Change `supports_media_type` to banner only. +- [ ] Run: + + ```bash + cargo test-fastly integrations::aps::tests + ``` + +- [ ] Run `cargo fmt --all -- --check`. + +**Expected result:** Existing configs continue through `pub_id`, new configs use +`account_id`, and no runtime protocol switch is introduced. + +--- + +## Task 3: Replace the legacy request builder with APS OpenRTB + +**What:** Delete the private `/e/dtb/bid` request types and build the request described +by the design spec from trusted auction context. + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify only if useful: `crates/trusted-server-core/src/openrtb.rs` +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Test: APS and auction-format test modules + +### 3.1 Request fixture tests + +- [ ] Add a failing complete-request test with fictional values. Assert exact JSON for: + - request `id`, `tmax`, and `cur = ["USD"]`; + - `ext.account`; + - `ext.sdk.source = "prebid"` and `version = "2.2.0"`; + - one impression per eligible slot with direct `imp.id` mapping; + - banner `format`, first `w`/`h`, `topframe = 0`, floor, floor currency, and + unconditional `secure = 1`; + - site page/domain/publisher fields; + - consent-allowed user ID and EIDs; + - UA/IP/language/DNT and coarse geo; + - TCF/USP/GPP registrations with COPPA omitted because no trusted signal exists; and + - absence of all PBS-only request and impression extensions. +- [ ] Add focused privacy/validation tests: + - latitude/longitude are omitted while permitted coarse geo remains; + - gated-out user/EID values stay absent; + - no user data, keywords, gender, YOB, customdata, cookies, or arbitrary headers; + - non-banner slots are omitted; + - dimensions above `i32::MAX` are omitted safely; + - empty/invalid formats cannot create a malformed impression; + - configured and effective timeout use the orchestrator-granted budget. + +### 3.2 `/auction` page derivation + +- [ ] Add failing tests to `auction/formats.rs` proving: + - a valid same-publisher HTTP(S) `Referer` becomes the `/auction` current page; + - userinfo, wrong-host, non-HTTP(S), malformed, and oversized URLs are rejected; + - rejection falls back to the configured publisher origin; and + - no unrestricted browser body field is accepted as page context. +- [ ] Implement a small private URL-validation helper. Reuse it in APS referrer + handling if practical, but do not make it a broad URL-policy refactor. + +### 3.3 Implementation + +- [ ] Remove `ApsBidRequest`, `ApsSlot`, legacy consent wrappers, slot-ID fallback, and + `to_aps_request`. +- [ ] Add APS request extension types locally in `aps.rs` unless another module must + serialize them. Implement `ToExt` consistently with existing OpenRTB extension + types. +- [ ] Implement `build_openrtb_request(&AuctionRequest, &AuctionContext)` using generated + OpenRTB types and `to_openrtb_i32`. +- [ ] Build registrations from the existing `ConsentContext` placements. Copy only the + small field mapping needed by APS; do not expose/refactor the PBS builder in this + task. +- [ ] Derive `site.ref` only from a valid HTTP(S) downstream `Referer` that differs from + the normalized current page. +- [ ] Change `request_bids` to serialize/send the OpenRTB body with + `Content-Type: application/json`, the existing bounded auction timeout, and no + forwarded cookies. +- [ ] Keep serialized request logging at TRACE. +- [ ] Run: + + ```bash + cargo test-fastly integrations::aps::tests + cargo test-fastly auction::formats + cargo check-fastly + ``` + +- [ ] Review serialized request fixtures against the immutable upstream request code; + explicitly confirm no `ext.prebid` or Trusted Server signing extension appears. + +**Expected result:** Every eligible APS banner slot produces a standards-based OpenRTB +impression with explicit APS and privacy policy. + +--- + +## Task 4: Parse APS OpenRTB and build the minimized renderer envelope + +**What:** Replace `contextual.slots` parsing with strict `seatbid` parsing, decoded CPM, +one APS candidate per impression, and the exact renderer envelope. + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Test: APS test module + +### 4.1 Add failing response tests + +- [ ] A runner-compatible banner bid maps: + - `id -> bid_id`; + - optional `adid -> ad_id` and `crid -> creative_id`; + - `impid -> slot_id`; + - decoded price/USD, dimensions and domains; + - `Bid.bidder = "aps"` regardless of upstream seat; and + - `ext.creativeurl`/`ext.tagtype` into the typed renderer. +- [ ] Missing, string, numeric, and unexpected seat values never become `Bid.bidder` or + `hb_bidder` and do not invalidate an otherwise valid bid. +- [ ] A valid runner-compatible bid without `crid` remains renderable with no + `creativeId` in the descriptor. +- [ ] The immutable official adapter fixture (`ext.bidder` only) is an expected safe + drop because the live runner requires `creativeurl`/`tagtype`. +- [ ] `iframe` and `script` parse into strict enum variants. With + `allow_script_creatives = false`, every script bid is safe-dropped before + per-impression reduction and winner selection; iframe bids remain eligible. +- [ ] With one higher-priced script bid and one lower-priced iframe bid for the same + impression, the disabled script cannot win and the iframe candidate survives. + With only disabled script bids, APS returns no bid. +- [ ] An APS bid with `adm` but no valid URL/tag type is dropped. When valid renderer + metadata coexists with `adm`, all markup is excluded from the browser envelope + and `Bid.creative`. +- [ ] HTTP 204, empty/missing `seatbid`, and seat bids with no bids are normal no-bids. +- [ ] Legacy `{ "contextual": ... }` is `Error`/`unexpected_response_shape`. +- [ ] Drop individual bids with unknown/missing `impid`, invalid price/currency/media, + missing/zero/out-of-range/incompatible `w` or `h`, missing bid ID, invalid tag + type, disabled script capability, or invalid creative URL. +- [ ] Creative URLs using HTTP, credentials, excessive length, malformed syntax, or the + configured publisher origin are rejected. +- [ ] One structurally malformed bid does not discard another valid bid. Invalid JSON or + non-representable numeric literals fail the complete response. +- [ ] APS `nurl`/`burl` are intentionally discarded, and top-level `ext.userSyncs` + reaches neither `Bid`, diagnostics, nor renderer data. +- [ ] Diagnostics contain only fixed reason/count keys and never IDs, URLs, payloads, + account values, seats, or raw extension data. + +### 4.2 Exact envelope and collision tests + +- [ ] Add `test/fixtures/aps-renderer-v1.json` containing exactly: + - root `seatbid`; + - one seat object containing only `bid`; + - one bid containing only `id`, `price`, `w`, `h`, and `ext`; and + - `ext` containing only `creativeurl` and `tagtype`. +- [ ] Assert Rust serialization equals that golden JSON semantically and that decoded + `aaxResponse` has no top-level ID/currency, seat, impid, ad/crid/domain, + notifications, markup, syncs, sibling bids, or unknown extensions. +- [ ] Remove unknown fields after transient parsing; do not copy an upstream extension + map wholesale into the renderer envelope. +- [ ] Add a response over the 256 KiB pre-base64 cap and assert + `render_payload_too_large`. +- [ ] Add two valid APS bids with the same impression/seat. Assert the parser returns + only the highest price; for a tie, lexicographically lowest bid ID wins with its + own matching renderer payload. + +### 4.3 Implementation + +- [ ] Remove all legacy request/response types, slot maps and tests. +- [ ] Special-case HTTP 204 before body collection/JSON parsing. +- [ ] Parse the response as `serde_json::Value`, validate response-level fields, then + validate each bid independently so a wrong-typed bid cannot discard valid + siblings. +- [ ] Set `Bid.bidder = "aps"`; discard upstream seat/network ID unless a bounded typed + internal consumer is introduced. Set APS `nurl = None` and `burl = None`. +- [ ] Require present, positive, compatible `w` and `h`. Apply + `allow_script_creatives` before adding a candidate to the per-impression group so + a disabled script cannot reach floors, mediation, or winner selection. +- [ ] Validate creative URL against the configured publisher origin and build the exact + allowlisted envelope from new values rather than cloning upstream objects. +- [ ] Group accepted candidates by `impid`, reduce deterministically to one, then build + the final `AuctionResponse` and aggregate diagnostics. +- [ ] Preserve `collect_response_bounded(..., UPSTREAM_RTB_MAX_RESPONSE_BYTES, "aps")`, + TRACE-only raw body logging, and safe aggregate normal logs. +- [ ] Run: + + ```bash + cargo test-fastly integrations::aps::tests + cargo fmt --all -- --check + cargo clippy-fastly + ``` + +**Expected result:** APS returns at most one priced, renderer-compatible candidate per +impression, with an exact browser envelope and stable `bidder = "aps"`. + +--- + +## Task 5: Emit APS winners through `/auction` and prove direct selection + +**What:** Use the new identifiers and renderer in the OpenRTB response without +manufacturing an empty creative, then replace the old mediation assumptions. + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/formats.rs` +- Modify: `crates/trusted-server-core/src/openrtb.rs` if a typed bid extension helper is + useful +- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs` +- Test: format and orchestrator test modules + +### 5.1 Failing tests + +- [ ] `/auction` maps `Bid.bid_id`, `ad_id`, and optional `creative_id` to OpenRTB + `id`, `adid`, and optional `crid` instead of fabricating APS identifiers. +- [ ] An APS renderer winner has no `adm` key and has + `ext.trusted_server.renderer` with the complete versioned descriptor—not a + `{type,version}` abbreviation. +- [ ] A normal `Bid.creative` is still sanitized and rewritten before becoming `adm`. +- [ ] A winning bid with neither creative nor renderer fails explicitly; it does not + serialize `adm: ""`. +- [ ] A renderer descriptor does not enter the HTML sanitizer. +- [ ] APS decoded price wins with no mediator when it is highest. +- [ ] APS loses to a higher clear-price bid. +- [ ] APS is removed below a slot floor. +- [ ] Optional mediator presence does not make decoded APS pricing special. + +### 5.2 Implementation + +- [ ] Add a typed bid-extension serializer for `trusted_server.renderer`. +- [ ] In `convert_to_openrtb_response`: + - preserve real bid/ad/creative IDs; + - process `creative` only through the existing sanitize/rewrite branch; + - emit renderer ext only for a typed renderer; and + - return an auction error for a winner with no render source. +- [ ] Remove stale “mediation should have provided creative” APS assumptions and encoded + price comments. +- [ ] Replace old orchestrator APS tests with decoded-price/floor tests. Do not change the + winner algorithm itself. +- [ ] Run: + + ```bash + cargo test-fastly auction::formats + cargo test-fastly auction::orchestrator + cargo fmt --all -- --check + ``` + +**Expected result:** Direct clients receive a priced APS winner with an explicit render +capability and never mistake an empty `adm` for a creative. + +--- + +## Task 6: Preserve APS renderer state through mediation and publisher bid maps + +**What:** Ensure both publisher paths expose the same APS descriptor and a mediator +cannot accidentally strip it. + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/adserver_mock.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Test: tests in both files + +### 6.1 Mediation tests + +- [ ] Add a regression test documenting the current collision: two unreduced APS bids + with the same provider/slot/bidder overwrite the `(provider, slot, bidder)` index + and can restore the wrong renderer. +- [ ] Feed the parser's reduced APS response into mediation and prove only the selected + source bid reaches the index/request and its bid ID/renderer survive reconstruction. +- [ ] Keep the broader mediator index unchanged in this issue; APS avoids its existing + ambiguity by returning one candidate per impression. Extend restoration only for + `bid_id`, optional `creative_id`, and renderer while preserving existing non-APS + notification/cache behavior. +- [ ] Run: + + ```bash + cargo test-fastly integrations::adserver_mock + ``` + +### 6.2 Bid-map tests + +- [ ] Add failing `build_bid_map` tests proving: + - APS renderer is present when `include_adm = false`; + - renderer data is identical for initial-page and page-bids serialization; + - `hb_bidder` is always `aps`, never upstream seat/network ID; + - `hb_adid` uses APS selected `bid_id` ahead of OpenRTB `adid`; + - PBS Cache UUID remains highest priority for PBS bids; + - APS `nurl`/`burl` are absent while existing non-APS notifications are unchanged; + - no general metadata is exposed in normal mode; + - `aaxResponse` and account data survive `build_bids_script` escaping without + breaking out of the script; and + - a non-APS bid has no renderer property. +- [ ] Update `build_bid_map` to serialize typed renderer data normally while retaining + `adm`/`debug_bid` only under the existing debug flag. +- [ ] Set APS `hb_adid` from the descriptor's selected bid ID. Preserve current cache and + ad-ID fallbacks. +- [ ] Update `debug_bid` deliberately: IDs may be shown under existing debug behavior, + but do not duplicate the potentially large encoded renderer envelope there. +- [ ] Run: + + ```bash + cargo test-fastly publisher + cargo fmt --all -- --check + ``` + +**Expected result:** Direct, mediated, initial-navigation, and SPA auction paths retain +one typed APS render contract without exposing losing bids or arbitrary metadata. + +--- + +## Task 7: Add the opaque APS renderer endpoint and direct-auction integration + +**What:** Serve a static renderer document with its own CSP, validate the exact data the +vendor consumes, and keep all APS/bidder execution below an outer opaque-origin +sandbox. + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/aps.rs` +- Modify: `crates/trusted-server-core/src/integrations/mod.rs` +- New: `crates/trusted-server-js/lib/src/integrations/aps/render.ts` +- New: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts` +- New: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json` +- 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/src/core/request.ts` +- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts` +- Modify/add: `crates/trusted-server-integration-tests/browser/` + +### 7.1 Shared wire contract + +- [ ] Add TypeScript discriminated types matching the Rust descriptor, with optional + `creativeId`. +- [ ] Make the shared fictional JSON fixture the source of truth: Rust serialization + equals it and TS tests import it. +- [ ] Add `/auction` parser tests for valid renderer ext, absent APS `adm`, optional + creative ID, ordinary non-APS `adm`, unrelated ext, and malformed descriptors. +- [ ] Keep parsing structural; complete trust validation happens before DOM/message + side effects. + +### 7.2 Static renderer endpoint + +- [ ] Add APS to `integrations::builders()` as a proxy registration using the same + validated `ApsConfig`, `.with_proxy(...)`, and `.without_js()`. +- [ ] Register only `GET /integrations/aps/renderer` and return a static document—no + account, bid, URL, or response data in HTML. +- [ ] Add Rust tests for exact route registration, method rejection, content type, + `nosniff`, `Referrer-Policy: no-referrer`, and the explicit renderer CSP. The CSP + must repeat the approved sandbox tokens without `allow-same-origin` so direct + embedding cannot bypass the opaque boundary. +- [ ] Before loading the vendor runner, the static initializer reads and strictly + validates the expected nonce from the iframe URL fragment, stores it, removes the + fragment from visible history, and installs its one-message listener. +- [ ] The static script accepts one parent message, verifies `event.source`, version, + exact structure, and equality with the independently fragment-bound nonce, then + removes its listener, initializes the official account queue, dispatches + `prebid/creative/render`, and dynamically loads the fixed runner. Reply with the + accepted nonce only after runner load (or report runner failure). Because the + sandbox origin is opaque, use `"*"` as the target origin in both directions but + require the exact child/source window and one-time nonce. +- [ ] Load only the fixed Amazon runner URL; do not interpolate renderer data into + script text, HTML, `srcdoc`, `document.write`, or another executable sink. +- [ ] Run: + + ```bash + cargo test-fastly integrations::aps + cargo test-fastly integrations::tests + ``` + +### 7.3 Decode and cross-check consumed data + +- [ ] `validateApsRenderer` must base64-decode UTF-8 JSON and require the exact allowlist: + one seat, one bid, exact bid/ext keys, finite price, dimensions, URL, and tag type. +- [ ] Cross-check decoded ID/dimensions/URL/tag type against descriptor fields. +- [ ] Reject unknown keys, markup, notifications, syncs, invalid base64/UTF-8/JSON, + non-HTTPS or credential-bearing URLs, and URLs matching `location.origin`. +- [ ] Validate before slot clearing, `stopImmediatePropagation`, iframe creation, or + `postMessage`. +- [ ] Repeat message/envelope validation inside the static renderer document. + +### 7.4 Opaque direct renderer + +- [ ] `renderApsCreative` creates a sized iframe with `src` set to + `/integrations/aps/renderer`; it never uses outer `srcdoc`. +- [ ] Apply exactly these tokens and assert `allow-same-origin` is absent: + + ```text + allow-forms + allow-pointer-lock + allow-popups + allow-popups-to-escape-sandbox + allow-scripts + allow-top-navigation-by-user-activation + ``` + +- [ ] Generate at least 128 random bits per frame with `crypto.getRandomValues`, encode + them as strict base64url, and set `iframe.src` to the trusted renderer URL with + that nonce in the fragment before insertion/navigation. After load, post the + validated descriptor with the same nonce. Clear existing content and reveal the + frame only after an exact source- and nonce-bound runner-ready acknowledgement; + remove an unacknowledged or failed hidden frame after a bounded timeout. +- [ ] Reject a missing/malformed fragment, nonce mismatch, repeat, or stale message; a + nonce carried only in the posted message is not sufficient. +- [ ] Leave existing slot content intact on validation/load failure and log no payload, + account, URL or bid ID. +- [ ] Dispatch valid APS renderer bids before ordinary non-APS `adm`; do not change the + generic sanitizer/renderer. + +### 7.5 Restrictive-CSP and origin proof + +- [ ] Add Playwright coverage using a restrictive publisher CSP that permits only + `frame-src 'self'` for the renderer route. +- [ ] Use a local fictional runner/creative implementation matching observed APS + iframe/script behavior; CI must not depend on the live Amazon script. +- [ ] Prove iframe and fetched-HTML script creatives cannot read or modify + `top.document`, even though the nested vendor frame requests + `allow-same-origin`. Also embed the renderer without an iframe sandbox attribute + and prove the response-level CSP sandbox still keeps it opaque. +- [ ] Prove the renderer route's CSP permits required HTTPS ad resources, the outer + frame stays opaque, malformed/mismatched envelopes or fragment/message nonce + mismatches do not clear slots, replay is rejected, and the runner can size/render + without parent-origin `frameElement` access. +- [ ] Keep `allow_script_creatives = false` for normal traffic during local browser + proof. If script rendering/resizing fails, keep the server gate false and stop for + an APS-supported isolated renderer contract. Never add `allow-same-origin` to the + outer frame. +- [ ] Run: + + ```bash + cd crates/trusted-server-js/lib + npx vitest run test/integrations/aps/render.test.ts test/core/auction.test.ts + node build-all.mjs + npm run format + + cd ../../../crates/trusted-server-integration-tests/browser + npx playwright test + ``` + +**Expected result:** Direct APS rendering uses a separately served document below an +opaque outer sandbox with a pre-bound, one-time nonce; script bids cannot win while the +default-off server capability is disabled. + +--- + +## Task 8: Integrate APS with the GPT/Prebid Universal Creative bridge + +**What:** Serve APS descriptors through the existing source-checked `Prebid Request` +message path and stop signaling the native APS SDK for TS winners. + +**Files:** + +- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` +- Modify if needed: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts` +- Reuse: `src/integrations/aps/render.ts` + +### 8.1 Add failing bridge tests + +- [ ] A matching APS `hb_adid` and renderer: + - is accepted only from the owning slot's message source; + - is fully decoded/cross-checked before `stopImmediatePropagation()`; + - does not fetch PBS Cache or fire generic nurl/burl beacons; + - posts one exact serializable `Prebid Response` using the deployed Universal + Creative renderer version; and + - creates only the opaque renderer-route iframe before posting the descriptor. +- [ ] Two concurrent requests for the same APS ad ID do not double-render. +- [ ] An APS descriptor requested from another slot is ignored. +- [ ] Invalid renderer data does not stop another legitimate handler and does not clear + a slot. +- [ ] Existing debug-ADM and PBS Cache tests remain green. +- [ ] `adInit()` does not call `apstag.setDisplayBids()` for a bid with a Trusted Server + APS renderer, even if `window.apstag` exists. +- [ ] A publisher-owned native APS SDK remains otherwise untouched; TS does not delete, + reinitialize, or monkey-patch it. + +### 8.2 Implement bridge support + +- [ ] Import the shared envelope validator/opaque-frame helper without importing the + full Prebid bundle. +- [ ] Add the APS branch after source-slot ownership and full renderer validation, before + debug ADM/PBS Cache branches. +- [ ] Define and fixture-test the exact Universal Creative message object, including + `rendererVersion`; do not describe or send a non-cloneable function. +- [ ] Compute an absolute `/integrations/aps/renderer` URL from the trusted publisher + page origin before crossing into GAM; a relative URL in the creative frame would + resolve against GAM. Validate that URL separately from APS data. +- [ ] Keep renderer source static and limited to creating the sandboxed iframe with that + absolute renderer URL plus a fresh nonce fragment, then post validated data and + the matching nonce after load and resolve only after the source- and nonce-bound + renderer-ready acknowledgement. +- [ ] Preserve `renderingAdIds` and live `window.tsjs.bids`. Do not call + `fireWinBillingBeacons` for APS; existing non-APS beacon deduplication is unchanged. +- [ ] Remove the `apstag?.setDisplayBids?.()` block that treats every APS targeting bid + as a native SDK bid. Update comments accordingly. +- [ ] Run: + + ```bash + cd crates/trusted-server-js/lib + npx vitest run \ + test/integrations/aps/render.test.ts \ + test/integrations/gpt/ad_init.test.ts \ + test/integrations/gpt/index.test.ts \ + test/integrations/gpt/spa_hook.test.ts + npm run format + ``` + +**Expected result:** Initial navigation and SPA page-bids render the selected APS bid +through the same typed bootstrap, without relying on native `apstag` state. + +--- + +## Task 9: Update operator configuration, migration docs, and changelog + +**What:** Replace all legacy public guidance and document test/rollout dependencies. + +**Files:** + +- Modify: `trusted-server.example.toml` +- Rewrite: `docs/guide/integrations/aps.md` +- Modify: `CHANGELOG.md` +- Review references found by: + + ```bash + rg -n 'e/dtb/bid|pub_id|amznbid|amznp|setDisplayBids|APS.*mediat' \ + --glob '!target/**' . + ``` + +- [ ] Change the example to `account_id` and `/e/pb/bid`, with fictional values. +- [ ] Document `pub_id` as a compatibility alias and duplicate-name failure. +- [ ] Remove any claim that `slot_id`/`bidders.aps.slotID` is required for the OpenRTB + provider. +- [ ] Document banner-only scope, USD comparison, decoded-price direct winners, and + aggregate diagnostics. +- [ ] Document both rendering paths, static renderer endpoint, fixed APS runner URL, + exact envelope allowlist, fragment-bound nonce, outer sandbox without + `allow-same-origin`, renderer endpoint/publisher CSP requirements, and the + default-off `allow_script_creatives` server gate. +- [ ] State that user sync and Trusted Server firing of APS `nurl`/`burl` are not + implemented. +- [ ] State that public APS metadata says PBS unsupported and production edge traffic + still requires account-team confirmation. +- [ ] Add a cohort rollout checklist: + - TS APS enabled; + - native APS demand disabled for that cohort; + - GAM line item/Universal Creative prepared for `hb_bidder=aps` and selected + `hb_adid`; + - iframe rendering observed first while `allow_script_creatives = false`; + - script enabled only for the isolated cohort after local browser proof, then observed + in a real browser; and + - no real IDs/tokens captured in docs or fixtures. +- [ ] Document rollback as disabling APS, restoring native APS for the cohort, or binary + rollback—not a legacy config switch. +- [ ] Add a breaking/migration changelog entry for endpoint and canonical field changes. +- [ ] Run: + + ```bash + cd docs + npx prettier --write \ + superpowers/specs/2026-07-15-aps-openrtb-first-class-integration-design.md \ + superpowers/plans/2026-07-15-aps-openrtb-first-class-integration.md \ + guide/integrations/aps.md + npm run format + ``` + +**Expected result:** Operators cannot accidentally configure the old protocol or assume +user sync/video/native SDK behavior that no longer exists. + +--- + +## Task 10: Controlled APS and browser verification + +**What:** Validate the uncertain external contract without putting account-specific +material into source control. + +**Prerequisites:** Controlled APS account/config supplied out of band; native APS demand +disabled for the test cohort; representative GAM line items and publisher CSP; local +restrictive-CSP/opaque-origin script proof already passing. + +- [ ] Deploy with APS enabled only in the controlled cohort and + `allow_script_creatives = false` initially. +- [ ] Prove with parser/orchestrator telemetry and tests that disabled script bids are + removed before candidate reduction/winner selection and cannot leave a winning + slot without a renderer. +- [ ] Confirm the outbound body has: + - `/e/pb/bid` endpoint; + - `ext.account` from operator config; + - `ext.sdk = { source: "prebid", version: "2.2.0" }`; + - expected impressions, floors, page/device/privacy fields; and + - no latitude/longitude or disallowed identity fields. +- [ ] Confirm a bid returns a decoded price and can beat another provider with no + mediator. +- [ ] Confirm the browser receives the exact one-bid allowlist in `aaxResponse`, with no + seat, impid, IDs beyond selected bid ID, domains, notifications, markup, syncs, + unknown extensions, sibling bids, or losing seats. +- [ ] Confirm the immutable official no-renderer fixture safe-drops and separately + observe real `creativeurl`/`tagtype` responses. +- [ ] Exercise a real `tagtype=iframe` response through direct `/auction` and + navigation/page-bids/GAM while the script gate remains false. +- [ ] Only after the local browser proof, set `allow_script_creatives = true` for this + isolated cohort and exercise a real `tagtype=script` response through both paths. + Do not enable it for other traffic yet. +- [ ] Verify the exact envelope works with the current APS runner. If it does not, record + only missing field names/shape, stop rollout, consult APS, and update spec/tests; + never preserve unknown fields or fall back to the full response. +- [ ] Verify the outer frame has an opaque origin, restrictive publisher CSP succeeds, + click-through/dimensions work, and bidder content cannot access parent DOM. +- [ ] If the fixed runner cannot render/resize script creatives without outer + `allow-same-origin`, restore `allow_script_creatives = false` and stop; do not + weaken the boundary. +- [ ] Verify `apstag.setDisplayBids()` is not called for the TS winner and APS is not + participating twice. +- [ ] Verify INFO/WARN logs and auction summaries contain no raw response or sensitive + values. +- [ ] Obtain APS account-team confirmation before enabling broad production traffic. + +**Expected result:** Iframe creatives render under the default policy; script creatives +become winner-eligible only for the staged cohort after both browser proof phases pass, +or the server gate returns to false and rollout stops with a bounded contract gap. + +--- + +## Task 11: Final regression and repository verification + +Run the complete required matrix from the repository root. + +### Rust formatting, tests, and lint + +- [ ] Run: + + ```bash + cargo fmt --all -- --check + 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 + ``` + +### Cross-adapter parity + +- [ ] Run: + + ```bash + cargo test \ + --manifest-path crates/trusted-server-integration-tests/Cargo.toml \ + --test parity + ``` + +### JS and docs + +- [ ] Run: + + ```bash + (cd crates/trusted-server-js/lib && node build-all.mjs) + (cd crates/trusted-server-js/lib && npx vitest run) + (cd crates/trusted-server-js/lib && npm run format) + (cd crates/trusted-server-integration-tests/browser && npx playwright test) + (cd docs && npm run format) + ``` + +### Final review + +- [ ] Run: + + ```bash + git diff --check + git status --short + ``` + +- [ ] Review every `Bid` construction and mediator conversion for the new identifiers + and renderer field. +- [ ] Review every `adm` assignment and prove it still passes through server + sanitization. +- [ ] Review every renderer serialization boundary and prove only winning APS data is + exposed. +- [ ] Review logs for account IDs, URLs, bid IDs, payloads, and consent/EID leakage. +- [ ] Verify no real controlled-account data entered source, tests, docs, comments, or + snapshots. +- [ ] Compare the final behavior against all acceptance criteria in the design spec. + +--- + +## Suggested implementation checkpoints + +If commits are requested, keep reviewable checkpoints aligned with the tasks: + +1. `Add typed APS renderer state to auction bids` +2. `Build APS OpenRTB requests` +3. `Parse APS OpenRTB responses` +4. `Carry APS renderer metadata to clients` +5. `Render APS creatives in TSJS and GPT` +6. `Document APS OpenRTB migration` + +Do not combine unrelated refactors with these checkpoints. 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..cd89dea13 --- /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. 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..cbd4a2ed1 --- /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 example 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}/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("/99999/example/homepage") + .expect("should parse a static path as a single literal"); + assert!( + matches!(parts.as_slice(), [UnitTemplatePart::Literal(s)] if s == "/99999/example/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/article-123", "home"), "news"); + assert_eq!(derive_section("/my-section/x", "home"), "my-section"); +} + +#[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}/example/{section}".to_string()); + slot.compile_unit_template().expect("should compile template"); + assert_eq!( + slot.render_gam_unit_path("99999", "news"), + "/99999/example/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}/example/{section}".to_string()); + CreativeOpportunitiesConfig { + gam_network_id: "99999".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}/example/{section}", section_root = "homepage" + let slot = &config.slot[0]; + + 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"], "/99999/example/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 example `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/plans/2026-07-24-prebid-refresh-gam-path-opt-out.md b/docs/superpowers/plans/2026-07-24-prebid-refresh-gam-path-opt-out.md new file mode 100644 index 000000000..a35cab40c --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-prebid-refresh-gam-path-opt-out.md @@ -0,0 +1,195 @@ +# Prebid Refresh GAM-Path Opt-Out Implementation Plan + +**Design:** `docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md` + +**Goal:** Let operators exclude selected GAM ad-unit-path suffixes from Trusted +Server's Prebid refresh auctions without suppressing the corresponding GAM refresh. + +## Scope and constraints + +- Do not change publisher source, slot div IDs, or GAM configuration. +- Do not edit `dist` output, minified assets, or an externally hosted Prebid bundle + by hand. `build-prebid-external.mjs`/`ts prebid bundle` are the supported build + path. +- The mechanism is literal, case-sensitive GAM-path suffix matching; it is not a + size-based rule and does not add a div-ID fallback. +- Preserve the current `adInitRefreshInProgress` direct pass-through before all new + work, including targeting cleanup. +- Use only fictional paths and hostnames in checked-in tests and documentation. + +## Files + +**Modify:** + +- `crates/trusted-server-core/src/integrations/prebid.rs` +- `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` +- `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` +- `trusted-server.example.toml` +- `docs/guide/integrations/prebid.md` + +**Update as needed for this work record only:** + +- `docs/superpowers/specs/2026-07-24-prebid-refresh-gam-path-opt-out-design.md` +- this plan + +## Task 1 — Add and inject canonical Prebid configuration + +**Files:** `crates/trusted-server-core/src/integrations/prebid.rs` + +1. Add `excluded_gam_ad_unit_path_suffixes: Vec` to + `PrebidIntegrationConfig`, defaulting to an empty vector. Initialize it in + `base_config()` and every other explicit `PrebidIntegrationConfig` literal. +2. Implement one shared validation/canonicalization path used by both `build()` and + `validate_config_for_startup()`: + - reject leading/trailing whitespace; + - reject empty/whitespace-only values; + - require a leading `/`; + - reject `/` itself; + - retain every other value literally; + - deduplicate exact valid entries, preserving first declaration order. + Put schema-level validation on the field so typed settings parsing produces a + useful configuration error, and retain the normalizer so the runtime payload is + canonical rather than merely valid. +3. Extend the local `InjectedPrebidClientConfig` with a borrowed + `excluded_gam_ad_unit_path_suffixes` field. Use the existing camel-case serde + convention and omit it when empty, producing + `excludedGamAdUnitPathSuffixes` only for a non-empty list. +4. Add Rust tests next to the existing Prebid tests: + - omitted field defaults to empty; + - valid values parse and duplicate values inject once in first-occurrence order; + - empty, whitespace-padded, missing-leading-slash, and `/` inputs are rejected; + - the head injector includes the expected camel-case JSON when configured and + omits it by default. +5. Run focused Prebid tests while iterating, then run the target-matched Rust test + commands required for the changed core configuration: + + ```bash + cargo test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + ``` + +## Task 2 — Filter only refresh-auction slots in the browser + +**Files:** `crates/trusted-server-js/lib/src/integrations/prebid/index.ts` + +1. Add `excludedGamAdUnitPathSuffixes?: string[]` to `InjectedPrebidConfig` and + `getAdUnitPath?: () => string` to `RefreshGptSlot`. +2. Add a small private predicate that receives a slot and configured suffix set. It + must call `getAdUnitPath()` only when it exists, catch a getter exception, require + a string result, and use `path.endsWith(suffix)` for any configured suffix. A + missing, non-string, empty, or throwing getter returns `false` (fail open). +3. In `installRefreshHandler()`, leave the + `window.tsjs?.adInitRefreshInProgress` branch byte-for-byte behaviorally first. + Keep target-slot resolution unchanged. +4. Keep `targetSlots.forEach(clearRefreshTargeting)` before filtering. Build + `auctionSlots` from `targetSlots` by applying the new predicate. +5. When `auctionSlots` is empty, call `originalRefresh(targetSlots, opts)` + immediately. Do not call `pbjs.requestBids()` or + `pbjs.setTargetingForGPTAsync()`. +6. Otherwise, build synthetic ad units and `refreshAdUnitCodes` from + `auctionSlots` only. In the bids-back handler, target only those eligible codes, + then call `originalRefresh(targetSlots, opts)` so mixed refreshes retain excluded + slots in the GAM list. +7. Do not alter `TS_REFRESH_TARGETING_KEYS`, size fallback logic, injected-slot + lookup, bidder-parameter recovery, or client-side-bid recovery except to ensure + they run only for eligible auction slots. + +## Task 3 — Cover refresh behavior with Vitest + +**Files:** `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts` + +Add tests in the existing `prebid/installRefreshHandler` suite. Make the mocked +`requestBids` invoke its callback synchronously only where post-auction behavior is +being asserted. + +| Case | Assertions | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Normal explicit slot | Nonmatching path still creates an ad unit, requests bids, scopes targeting to its code, and refreshes after callback. | +| Explicit excluded slot | Each TS/Prebid key is cleared; no Prebid request/targeting call; original GPT refresh receives the same slot list and options immediately. | +| All-excluded global refresh | `getSlots()` supplies the concrete list; all targets are cleaned; no Prebid call; GPT receives the complete concrete list and options. | +| Mixed global refresh | Both slots are cleaned; only the normal slot occurs in ad units and scoped targeting; GPT receives both slots after callback. | +| Missing getter | Slot is auctioned and wrapper does not throw. | +| Throwing or non-string getter | Slot is auctioned and wrapper does not throw. | +| Literal semantics | Case mismatch and trailing-slash mismatch do not exclude. | +| Regression | Existing `adInitRefreshInProgress` direct-pass-through test and normal client-side bidder/param-recovery tests remain unchanged and green. | + +Run the JS suite after each change and format it: + +```bash +cd crates/trusted-server-js/lib +npx vitest run +npm run format +``` + +## Task 4 — Document the operator contract + +**Files:** `trusted-server.example.toml`, `docs/guide/integrations/prebid.md` + +1. Add a commented example of + `excluded_gam_ad_unit_path_suffixes = ["/trackingonly"]` to the example TOML. +2. Add the field to the Prebid guide's configuration table and explain: + - it excludes only Trusted Server's GPT-refresh Prebid auction; + - GAM still refreshes the matching slot; + - matching is case-sensitive literal suffix matching via `getAdUnitPath()`; + - invalid/empty/root suffixes are rejected and a missing getter fails open; + - use a specific terminal path rather than a size or div-ID rule; + - mixed global refreshes still auction normal slots. +3. Include the external-bundle/config rollout dependency and direct-Prebid/APS + non-goals. Do not add real inventory names, production domains, or tracking IDs. +4. Format docs: + + ```bash + cd docs && npm run format + ``` + +## Task 5 — Final validation and rollout verification + +1. Inspect the complete diff; verify it contains no generated bundle edits and no + publisher-source changes. +2. Run project gates relevant to the changed Rust, TypeScript, and docs surfaces: + + ```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 test-fastly + cargo test-axum + cargo test-cloudflare + cargo test-spin + cd crates/trusted-server-js/lib && npx vitest run && npm run format + cd docs && npm run format + ``` + +3. Regenerate the external browser bundle through the supported `ts prebid bundle` + workflow, upload the generated artifact, and update the operator-managed + URL/hash/SRI configuration. Deploy that bundle together with the Trusted Server + application/config carrying the suffix list. +4. On a controlled staging page, use browser request instrumentation to verify: + - the injected browser config contains the expected suffixes; + - a matching path still produces a GAM request/impression but no refresh + `/auction` request; + - a normal display slot in a mixed global refresh does produce `/auction`, gets + scoped refreshed targeting, and GAM refreshes both slots; + - `disableInitialLoad()`/initial Trusted Server loading still uses the existing + `adInitRefreshInProgress` direct GPT path. +5. Record deployed bundle hash, configuration version, test page, and observations + in the implementation handoff. Do not treat an unstable production page as the + sole verification environment. + +## Acceptance criteria + +- With no new configuration, refresh behavior is unchanged. +- A configured matching GAM path is never included in a synthetic refresh auction, + but remains in the GPT refresh call. +- An all-excluded refresh produces no `requestBids()` or targeting call. +- A mixed refresh auctions and targets only eligible slots while refreshing every + requested GPT slot. +- Stale Trusted Server/Prebid targeting is cleared from every target slot, and the + initial-load bypass remains untouched. +- The deployed external bundle and injected configuration are version-compatible. 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..24879c8e4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-prevent-duplicate-gpt-slot-requests.md @@ -0,0 +1,223 @@ +# 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()`. +> +> **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 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:** + +- `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 `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`. +- [ ] 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 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:** + +```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 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 + 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 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 + 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 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. + +## 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-03-19-auction-orchestration-flow-design.md b/docs/superpowers/specs/2026-03-19-auction-orchestration-flow-design.md index 02a86d629..4ecfa9e75 100644 --- a/docs/superpowers/specs/2026-03-19-auction-orchestration-flow-design.md +++ b/docs/superpowers/specs/2026-03-19-auction-orchestration-flow-design.md @@ -1,5 +1,11 @@ # 🎯 Auction Orchestration Flow +> [!WARNING] +> The APS portions of this historical design are superseded by +> [APS OpenRTB First-Class Integration](./2026-07-15-aps-openrtb-first-class-integration-design.md). +> The legacy `/e/dtb/bid` contextual flow and encoded-price mediation described below +> are not current runtime behavior. + ## 🔄 System Flow Diagram ```mermaid 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 new file mode 100644 index 000000000..a9ba9a652 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md @@ -0,0 +1,233 @@ +# SSAT: render the winning creative inline (no PBS Cache round trip) + +**Date:** 2026-07-13 +**Status:** Implemented — see "Implementation reconciliation" below for how the +shipped code diverged from the original design. +**Branch:** `feat/ssat-render-inline-creative` + +## Implementation reconciliation (final) + +The core design below is accurate; the shipped implementation refined it in four +ways. Where the sections that follow still say `rewrite_creative_html`, the +foreign-origin render context (see below) means the code uses +`rewrite_inline_creative_html`. + +1. **Dedicated inline rewriter — `rewrite_inline_creative_html`.** The inline + `adm` is rendered by the Prebid Universal Creative inside GAM's iframe + (`f.srcdoc = d.ad`), a **foreign origin**. Root-relative `/first-party/…` + URLs would resolve against GAM and 404, so the inline rewriter emits + **absolute** first-party URLs and does **not** inject the `tsjs` bundle (its + only job — safeguarding click URLs — is moot once URLs are absolute, and the + bundle is pure weight in a creative iframe). `rewrite_creative_html` (root- + relative, tsjs-injecting) remains the `/auction` same-origin path. +2. **Absolute URLs use the request origin, not `publisher.domain`.** + `rewrite_inline_creative_html` takes a `base_origin` derived from the trusted + request (`scheme://host`, host including any port). `publisher.domain` cannot + carry a port and may differ from the subdomain serving the request; it is only + the fallback when the request origin is unknown. Threaded through + `write_bids_to_state`/`build_bid_map`; SPA page-bids derives it from + `RequestInfo`. +3. **Render metadata in the bid map.** `build_bid_map` emits the winning + creative's `w`/`h`; the bridge sizes the inline (and cache-fallback) render + from those, falling back to the first configured slot format only when absent. + `${AUCTION_PRICE}` is expanded from the exact winning CPM **before** + sanitize/rewrite/sign, so the clearing price — not an encoded macro — is what + gets signed into proxy/click URLs. +4. **Cache fallback decodes a structured bid.** `parseCachedBid` (replacing the + adm-only `extractCachedAdm`) retains the cached creative's dimensions and + price, so the fallback sizes correctly and expands `${AUCTION_PRICE}` from the + cached price. Firing a cached win-notification URL is deferred pending a real + PBS Cache payload to verify the field and dedup contract. + +## Problem + +On the server-side auction (SSAT / streaming path), when GAM picks the +trusted-server header-bid line item, the winning creative is fetched at render +time from PBS Cache: + +``` +https://?uuid= +``` + +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. + +## Goal + +Make SSAT render the winning creative **from the copy it already holds**, the +same way the client does — eliminating the render-time PBS Cache round trip — +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. + +## Current flow (verified in code) + +1. `build_bid_map` ([publisher.rs:1933]) writes `window.tsjs.bids[slot]` with + `hb_pb`, `hb_bidder`, `hb_adid`, `hb_cache_host`, `hb_cache_path`. The raw + `adm` (creative) and a verbose `debug_bid` blob are included **only** when the + current `include_adm` param is set — today wired to + `settings.debug.inject_adm_for_testing`. +2. `build_bids_script` ([publisher.rs:2018]) serializes the bid map and runs it + through `html_escape_for_script` (escapes `<`/`>`/`&` and `U+2028/2029`), + embedding it as `JSON.parse("…")`. +3. GAM's Prebid line item (matched by `hb_pb`) serves the Prebid Universal + Creative (PUC), which `postMessage`s `"Prebid Request"`. +4. `installTsRenderBridge` ([gpt/index.ts:839]) intercepts and either: + - serves `matchedBid.adm` **directly** when present (no round trip), then + fires win/billing beacons, or + - **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_. + Its "testing only" status is a comment, not an actual gate. + +## Design + +### 1. Always include the render `adm`; keep `debug_bid` gated + +`build_bid_map`: + +- **Always** insert `adm` for a winner when present — there is no runtime reason + to withhold it, so it is not parameterized. The creative is first run through + the **same sanitize/rewrite boundary as the `/auction` path** (`auction::formats`): + `creative::sanitize_creative_html` then `creative::rewrite_creative_html`. + `sanitize_creative_html` also enforces the 1 MiB `MAX_CREATIVE_SIZE` cap, + returning empty for oversized or unparseable markup — in which case `adm` is + omitted and the bridge falls back to the PBS Cache coordinates. `build_bid_map` + therefore takes `&Settings` (needed by `rewrite_creative_html`). +- Insert the verbose `debug_bid` blob **only** when the testing flag is set. The + single `include_adm` param becomes `include_debug_bid`. (The `debug_bid` blob + still carries the raw, un-sanitized creative for diagnostics; only the + client-facing `adm` is sanitized.) + +`hb_cache_host`/`hb_cache_path` remain inserted unconditionally. + +### 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 +and the round trip disappears. + +**Cache payload decode:** PBS Cache (`returnCreative=false`) returns the cached +bid as a **JSON object**, and the Prebid Universal Creative's own cache path +`JSON.parse`s it and renders `bidObject.adm` — not the raw response body. The +fallback therefore parses the cache response and extracts the string `adm` +(`extractCachedAdm`), with raw-markup compatibility for caches that return the +creative directly, and declines to render (no `"Prebid Response"`, no beacons) +when the payload has no usable `adm`. It does **not** forward a serialized bid +document to the PUC. + +**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 the inline `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. + +### 3. Gate the GAM-bypass on `bid.debug_bid` (no new global flag) + +`injectAdmIntoSlot` must not fire in production merely because `adm` is now +present. Rather than introduce a global `window.tsjs` flag (which goes stale +across SPA navigations — an empty initial response would pin it `false`), gate +the bypass on the per-bid `debug_bid` field, which is already present **iff** +`inject_adm_for_testing` is on: + +```ts +if (bid.adm && bid.debug_bid) { + injectAdmIntoSlot(divId, bid.adm) +} +``` + +This works for both initial and SPA auction responses, needs no `TsjsApi` +change, and keeps production `adm` on the bridge-only (keep-GAM) path. + +### 4. Security + +Two layers, both provided directly by trusted-server: + +1. **Creative-processing boundary (server-side).** The inline `adm` runs through + the same `sanitize_creative_html` → `rewrite_creative_html` pass as the + `/auction` path before it enters the bid map. Sanitization strips executable + markup (`` breakout and `U+2028/2029`. Pinned by a + line-separator escaping 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) | Expand `${AUCTION_PRICE}`, then sanitize + `rewrite_inline_creative_html` `bid.creative` (1 MiB cap) before inserting `adm`; omit when rejected. Emit winning `w`/`h`. Takes `&Settings` + `request_origin`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | +| `creative.rs` | `rewrite_inline_creative_html(&Settings, base_origin, markup)` (absolute URLs, no tsjs); `expand_auction_price_macro`. | +| `build_bid_map` / `write_bids_to_state` callers | Thread `&Settings` + the request origin (`scheme://host`); pass `include_debug_bid = inject_adm_for_testing`. | +| `gpt/index.ts` `installTsRenderBridge` | Resolve the bid by requesting slot; size from `matchedBid.w`/`h`; decode PBS Cache JSON into a structured bid (`parseCachedBid`) preserving dims + price, expanding `${AUCTION_PRICE}`; decline when absent. | +| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | +| `core/types.ts` `AuctionBidData` | Add `w`/`h` render dimensions. | +| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; realistic `returnCreative=false` cache payload + malformed/raw-markup coverage; slot/dimension/origin/macro cases. | + +No `build_bids_script` change, no `window.tsjs` flag, no `TsjsApi` change. + +## Data flow (after) + +``` +SSAT auction → winner (bid.creative held) → build_bid_map sanitizes+rewrites → 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 inline adm → RENDER (no round trip) + beacons + │ → (adm absent) → PBS Cache fetch → decode JSON → extract adm → RENDER (fallback) + └ GAM has higher demand → GAM serves its own creative +``` + +## Precondition + +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. + +## Testing + +- **Rust**: `build_bid_map` includes a sanitized `adm` for winners on every path; + a hostile `adm` has its ` +``` + +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/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..c94e25b2c --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-prevent-duplicate-gpt-slot-requests-design.md @@ -0,0 +1,185 @@ +# 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. + +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 + 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: 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 +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. **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. +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. +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. +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 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 + +### 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 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 +`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 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 + 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()` 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. | +| 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. +- 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. +- 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 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. 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..08dc5b5bc 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -18,6 +18,7 @@ cd "$REPO_ROOT" 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}')" if [ -z "$NODE_VERSION" ]; then @@ -37,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..." @@ -49,12 +63,26 @@ 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" npm ci npx 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" + # --- Export env vars for global-setup.ts --- export WASM_BINARY_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm" export INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" @@ -71,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 26d95d681..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 = "" @@ -59,6 +62,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" @@ -112,15 +120,38 @@ rewrite_script = true [auction] enabled = false +# 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 = [] [integrations.aps] enabled = false -pub_id = "your-aps-publisher-id" -endpoint = "https://aps.example.com/e/dtb/bid" +account_id = "example-aps-account-id" +endpoint = "https://web.ads.aps.amazon-adsystem.com/e/pb/bid" timeout_ms = 1000 +# Include raw APS request/response data in /auction metadata on test sites only. +debug = false +# Set both when the deployment hostname differs from APS-authorized inventory. +# inventory_domain = "publisher.example" +# inventory_page_origin = "https://www.publisher.example" +# Script creatives require separate security validation before opt-in. +allow_script_creatives = false [integrations.google_tag_manager] enabled = false @@ -157,7 +188,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