diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ec85b96ac..4973afe44 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -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 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 68f14e753..cb8f40c68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5283,6 +5283,7 @@ dependencies = [ "log-fastly", "serde", "serde_json", + "sha2 0.10.9", "trusted-server-core", "url", "urlencoding", @@ -5351,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/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 461b567d1..a511daab2 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -158,11 +158,19 @@ impl PlatformBackend for AxumPlatformBackend { let port = spec .port .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); + // Keep two providers that share an origin on distinct names so auction + // response correlation cannot cross providers. + let discriminator = spec + .discriminator + .as_deref() + .map(|d| format!("_p_{}", normalize_env_segment(d))) + .unwrap_or_default(); Ok(format!( - "{}_{}_{}", + "{}_{}_{}{}", normalize_env_segment(&spec.scheme), normalize_env_segment(&spec.host), port, + discriminator, )) } @@ -644,6 +652,7 @@ mod tests { first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), host_header_override: None, + discriminator: None, }; let name1 = backend.predict_name(&spec).expect("should return a name"); let name2 = backend @@ -664,6 +673,7 @@ mod tests { first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), host_header_override: None, + discriminator: None, }; assert_eq!( backend.predict_name(&spec).expect("should return name"), diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index a01c4d979..9467abb71 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -71,8 +71,15 @@ impl PlatformBackend for NoopBackend { } else { "_nocert" }; + // Keep two providers that share an origin on distinct names so auction + // response correlation cannot cross providers. + let discriminator = spec + .discriminator + .as_deref() + .map(|d| format!("_p_{d}")) + .unwrap_or_default(); Ok(format!( - "{}_{}_{}_{timeout_ms}ms{cert_suffix}", + "{}_{}_{}_{timeout_ms}ms{cert_suffix}{discriminator}", spec.scheme, spec.host, port )) } diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 2ad65462a..b6bc0f1a1 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -25,6 +25,7 @@ log = { workspace = true } log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } trusted-server-core = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 955ff235b..ae4ca421f 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, @@ -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 } @@ -2158,6 +2156,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 +2270,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 9ef09cf4f..e812b95bd 100644 --- a/crates/trusted-server-adapter-fastly/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -2,6 +2,7 @@ use std::time::Duration; use error_stack::{Report, ResultExt as _}; use fastly::backend::Backend; +use sha2::{Digest as _, Sha256}; use url::Url; use trusted_server_core::error::TrustedServerError; @@ -47,6 +48,34 @@ fn sanitize_backend_name_component(value: &str) -> String { .collect() } +/// Fastly's documented maximum length for a dynamic backend name. +const MAX_BACKEND_NAME_LEN: usize = 255; +/// Maximum length of the human-readable prefix folded into a backend name. +/// +/// Bounds the name so that `backend__` can never exceed +/// [`MAX_BACKEND_NAME_LEN`]: 8 (`backend_`) + 200 + 1 (`_`) + +/// [`SPEC_DIGEST_HEX_LEN`] = 241 ≤ 255. +const MAX_READABLE_PREFIX_LEN: usize = 200; +/// Width of the hex digest suffix — the first 128 bits of a SHA-256 over the +/// full backend spec, which is collision-resistant at the handful-of-hundreds +/// scale of a service's dynamic backends. +const SPEC_DIGEST_HEX_LEN: usize = 32; + +/// Hex-encode the first 128 bits of a SHA-256 digest of `canonical`. +/// +/// Used to make a backend name a collision-resistant function of the complete +/// backend spec (see [`BackendConfig::canonical_spec_string`]). +fn spec_digest_hex(canonical: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let digest = hasher.finalize(); + digest + .iter() + .take(SPEC_DIGEST_HEX_LEN / 2) + .map(|byte| format!("{byte:02x}")) + .collect() +} + /// Default first-byte timeout for backends (15 seconds). pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); /// Default timeout between response body bytes for backends (10 seconds). @@ -64,6 +93,7 @@ pub struct BackendConfig<'a> { first_byte_timeout: Duration, between_bytes_timeout: Duration, host_header_override: Option<&'a str>, + discriminator: Option<&'a str>, } impl<'a> BackendConfig<'a> { @@ -81,6 +111,7 @@ impl<'a> BackendConfig<'a> { first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_BETWEEN_BYTES_TIMEOUT, host_header_override: None, + discriminator: None, } } @@ -128,14 +159,77 @@ impl<'a> BackendConfig<'a> { self } + /// Set an optional stable discriminator folded into the backend name. + /// + /// Two callers targeting the same origin with the same transport timeout + /// otherwise share a backend name. Auction response correlation keys on the + /// backend name, so a shared name would let one provider's response be + /// parsed as another's. A per-provider discriminator keeps the names + /// distinct while staying stable across requests. + #[must_use] + pub fn discriminator(mut self, discriminator: Option<&'a str>) -> Self { + self.discriminator = discriminator; + self + } + + /// Build an unambiguous, length-prefixed encoding of the complete backend + /// spec for digesting. + /// + /// Every field is prefixed with its byte length so that no two distinct + /// specs can encode to the same string (a lossy substitution like + /// `sanitize_backend_name_component` cannot guarantee this). `Option` fields + /// are presence-tagged so a `None` never aliases a `Some("")`. The result is + /// fed to [`spec_digest_hex`]; it is never parsed, only hashed. + fn canonical_spec_string(&self, target_port: u16) -> String { + fn push_field(buf: &mut String, field: &str) { + buf.push_str(&field.len().to_string()); + buf.push(':'); + buf.push_str(field); + } + + let mut buf = String::new(); + push_field(&mut buf, self.scheme); + push_field(&mut buf, self.host); + push_field(&mut buf, &target_port.to_string()); + push_field(&mut buf, if self.certificate_check { "1" } else { "0" }); + match self.host_header_override { + Some(value) => { + buf.push('s'); + push_field(&mut buf, value); + } + None => buf.push('n'), + } + match self.discriminator { + Some(value) => { + buf.push('s'); + push_field(&mut buf, value); + } + None => buf.push('n'), + } + push_field(&mut buf, &self.first_byte_timeout.as_millis().to_string()); + push_field( + &mut buf, + &self.between_bytes_timeout.as_millis().to_string(), + ); + buf + } + /// Compute the deterministic backend name and resolved port without /// registering anything. /// - /// The name encodes scheme, host, port, certificate setting, and - /// first-byte timeout so that backends with different configurations - /// never collide. Including the timeout prevents "first-registration-wins" - /// poisoning where a later request for the same origin with a tighter - /// timeout would silently inherit the original registration's value. + /// The name is `backend__`, where `` is a + /// collision-resistant SHA-256 over an unambiguous encoding of the + /// *complete* backend spec — scheme, host, port, certificate setting, Host + /// override, provider discriminator, and the first-byte/between-bytes + /// timeouts (see [`canonical_spec_string`](Self::canonical_spec_string)). + /// Because distinct specs yield distinct digests, name equality implies spec + /// equality: that is what makes reusing a `NameInUse` backend provably safe, + /// and it prevents "first-registration-wins" poisoning where a later request + /// with a tighter timeout would inherit an earlier registration's value. The + /// `` half is a lossy, bounded slug carried only for logs — any + /// collision there is harmless because uniqueness comes from the digest. The + /// whole name is bounded to [`MAX_BACKEND_NAME_LEN`] so a long host or + /// discriminator can never produce a name Fastly rejects at registration. fn compute_name(&self) -> Result<(String, u16), Report> { if self.host.is_empty() { return Err(Report::new(TrustedServerError::Proxy { @@ -174,16 +268,46 @@ impl<'a> BackendConfig<'a> { } else { "_nocert" }; + let discriminator_suffix = self + .discriminator + .map(|d| format!("_p_{}", sanitize_backend_name_component(d))) + .unwrap_or_default(); let first_byte_timeout_ms = self.first_byte_timeout.as_millis(); let between_bytes_timeout_ms = self.between_bytes_timeout.as_millis(); - let backend_name = format!( - "backend_{}{}{}_fb{}_bb{}", + + // Lossy, human-readable slug for logs. Correctness does not depend on + // it — uniqueness comes from the digest below — so it is bounded to a + // fixed length. Sanitization only emits ASCII, so a char-boundary take + // is byte-exact. + let readable_full = format!( + "{}{}{}{}_fb{}_bb{}", sanitize_backend_name_component(&name_base), host_override_suffix, cert_suffix, + discriminator_suffix, first_byte_timeout_ms, between_bytes_timeout_ms ); + let readable: String = readable_full + .chars() + .take(MAX_READABLE_PREFIX_LEN) + .collect(); + + // Collision-resistant over the *complete* spec, so name equality implies + // spec equality and `NameInUse` reuse is safe. + let digest = spec_digest_hex(&self.canonical_spec_string(target_port)); + let backend_name = format!("backend_{readable}_{digest}"); + + // Bounded by construction; assert it so any future format change fails + // attributably during prediction rather than at Fastly registration. + if backend_name.len() > MAX_BACKEND_NAME_LEN { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "backend name exceeds {MAX_BACKEND_NAME_LEN}-char limit ({} chars)", + backend_name.len() + ), + })); + } Ok((backend_name, target_port)) } @@ -350,7 +474,33 @@ impl<'a> BackendConfig<'a> { #[cfg(test)] mod tests { - use super::{BackendConfig, compute_host_header}; + use super::{BackendConfig, MAX_BACKEND_NAME_LEN, SPEC_DIGEST_HEX_LEN, compute_host_header}; + + /// Assert a computed name is `backend__` and stays within + /// Fastly's length limit. The digest is what makes the name injective, so + /// checking its presence and width guards the collision-safety property. + fn assert_backend_name_shape(name: &str, expected_body: &str) { + let prefix = format!("backend_{expected_body}_"); + assert!( + name.starts_with(&prefix), + "name should start with the readable body `{prefix}`, got {name}" + ); + let digest = &name[prefix.len()..]; + assert_eq!( + digest.len(), + SPEC_DIGEST_HEX_LEN, + "digest suffix should be {SPEC_DIGEST_HEX_LEN} hex chars, got {digest}" + ); + assert!( + digest.bytes().all(|byte| byte.is_ascii_hexdigit()), + "digest suffix should be hex, got {digest}" + ); + assert!( + name.len() <= MAX_BACKEND_NAME_LEN, + "name should stay within the {MAX_BACKEND_NAME_LEN}-char limit, got {}", + name.len() + ); + } // Tests for compute_host_header - the fix for port preservation in Host header #[test] @@ -399,7 +549,7 @@ mod tests { let name = BackendConfig::new("https", "origin.example.com") .ensure() .expect("should create backend for valid HTTPS origin"); - assert_eq!(name, "backend_https_origin_example_com_443_fb15000_bb10000"); + assert_backend_name_shape(&name, "https_origin_example_com_443_fb15000_bb10000"); } #[test] @@ -408,10 +558,7 @@ mod tests { .certificate_check(false) .ensure() .expect("should create backend with cert check disabled"); - assert_eq!( - name, - "backend_https_origin_example_com_443_nocert_fb15000_bb10000" - ); + assert_backend_name_shape(&name, "https_origin_example_com_443_nocert_fb15000_bb10000"); } #[test] @@ -420,7 +567,7 @@ mod tests { .port(Some(8080)) .ensure() .expect("should create backend for HTTP origin with explicit port"); - assert_eq!(name, "backend_http_api_test-site_org_8080_fb15000_bb10000"); + assert_backend_name_shape(&name, "http_api_test-site_org_8080_fb15000_bb10000"); } #[test] @@ -428,7 +575,7 @@ mod tests { let name = BackendConfig::new("http", "example.org") .ensure() .expect("should create backend defaulting to port 80 for HTTP"); - assert_eq!(name, "backend_http_example_org_80_fb15000_bb10000"); + assert_backend_name_shape(&name, "http_example_org_80_fb15000_bb10000"); } #[test] @@ -483,13 +630,13 @@ mod tests { name_a, name_b, "backends with different host header overrides should have different names" ); - assert_eq!( - name_a, - "backend_https_origin_example_com_443_oh_www_example_com_fb15000_bb10000" + assert_backend_name_shape( + &name_a, + "https_origin_example_com_443_oh_www_example_com_fb15000_bb10000", ); - assert_eq!( - name_b, - "backend_https_origin_example_com_443_oh_m_example_com_fb15000_bb10000" + assert_backend_name_shape( + &name_b, + "https_origin_example_com_443_oh_m_example_com_fb15000_bb10000", ); } @@ -544,12 +691,12 @@ mod tests { "backends with different timeouts should have different names" ); assert!( - name_a.ends_with("_fb2000_bb10000"), - "name should include first-byte and between-bytes timeout suffix" + name_a.contains("_fb2000_bb10000_"), + "name should include first-byte and between-bytes timeout in the readable body" ); assert!( - name_b.ends_with("_fb500_bb10000"), - "name should include first-byte and between-bytes timeout suffix" + name_b.contains("_fb500_bb10000_"), + "name should include first-byte and between-bytes timeout in the readable body" ); } @@ -571,12 +718,89 @@ mod tests { "backends with different between-bytes timeouts should have different names" ); assert!( - name_a.ends_with("_fb15000_bb2000"), - "name should include first-byte and between-bytes timeout suffix" + name_a.contains("_fb15000_bb2000_"), + "name should include first-byte and between-bytes timeout in the readable body" + ); + assert!( + name_b.contains("_fb15000_bb500_"), + "name should include first-byte and between-bytes timeout in the readable body" + ); + } + + #[test] + fn discriminators_that_sanitize_alike_produce_distinct_names() { + // `provider.a` and `provider_a` both sanitize to the same readable slug + // (`.` maps to `_`). Before the spec digest they collided to one backend + // name, so the second registration silently reused the first — routing + // one provider's auction traffic through another's backend. The digest + // over the raw spec must keep them distinct. + let dotted = BackendConfig::new("https", "gateway.example.com") + .discriminator(Some("provider.a")) + .predict_name() + .expect("should predict name for dotted discriminator"); + let underscored = BackendConfig::new("https", "gateway.example.com") + .discriminator(Some("provider_a")) + .predict_name() + .expect("should predict name for underscored discriminator"); + assert_ne!( + dotted, underscored, + "discriminators differing only by a sanitized character must not collide" + ); + } + + #[test] + fn host_overrides_that_sanitize_alike_produce_distinct_names() { + // `host.example.com:8443` (host+port) and `host.example.com.8443` (DNS + // label) are both valid overrides that sanitize to the same readable + // slug (`:` and `.` both map to `_`). The digest over the raw value must + // keep the two backends — with different Host routing — distinct. + let with_port = BackendConfig::new("https", "origin.example.com") + .host_header_override(Some("host.example.com:8443")) + .predict_name() + .expect("should predict name for host:port override"); + let with_label = BackendConfig::new("https", "origin.example.com") + .host_header_override(Some("host.example.com.8443")) + .predict_name() + .expect("should predict name for dotted-label override"); + assert_ne!( + with_port, with_label, + "host overrides differing only by a sanitized character must not collide" + ); + } + + #[test] + fn long_host_and_discriminator_stay_within_the_length_limit() { + // A syntactically valid maximum-length DNS host plus a discriminator + // previously pushed the name past Fastly's 255-char limit, so + // `predict_name` succeeded while `ensure` failed at registration. The + // bounded prefix + fixed-width digest must keep prediction, and the name + // it predicts, within the limit. + let label = "a".repeat(63); + let long_host = format!("{label}.{label}.{label}.{label}.example.com"); + assert!( + long_host.len() > 200, + "should exercise a host longer than the readable-prefix bound" ); + let name = BackendConfig::new("https", &long_host) + .discriminator(Some("prebid")) + .predict_name() + .expect("should predict a bounded name for a long host and discriminator"); assert!( - name_b.ends_with("_fb15000_bb500"), - "name should include first-byte and between-bytes timeout suffix" + name.len() <= MAX_BACKEND_NAME_LEN, + "name should stay within the {MAX_BACKEND_NAME_LEN}-char limit, got {}", + name.len() + ); + + // Two long hosts sharing the truncated prefix must still resolve to + // different backends via the digest. + let other_host = format!("{label}.{label}.{label}.{label}.example.net"); + let other = BackendConfig::new("https", &other_host) + .discriminator(Some("prebid")) + .predict_name() + .expect("should predict a bounded name for the sibling host"); + assert_ne!( + name, other, + "hosts sharing a truncated prefix must stay distinct via the digest" ); } } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index ab3236c0f..8fec21435 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -321,10 +321,9 @@ fn run_edgezero_pull_sync_after_send( /// Sends a finalized `EdgeZero` response to the client. /// -/// Asset streams commit headers first, then pipe the origin body chunk by chunk -/// so large responses do not materialize in the Wasm heap. Publisher responses -/// are buffered by the server-side auction path so bids can be injected into the -/// document, and are sent in one shot along with all other responses. +/// Streaming `EdgeZero` bodies commit headers first, then pipe chunks to Fastly's +/// client stream so large asset and publisher-origin responses do not +/// materialize in the Wasm heap. fn send_edgezero_response( mut response: HttpResponse, request_filter_effects: Option<&RequestFilterEffects>, @@ -350,11 +349,11 @@ fn send_edgezero_response( match futures::executor::block_on(stream_asset_body(body, &mut streaming_body)) { Ok(()) => { if let Err(e) = streaming_body.finish() { - log::error!("failed to finish EdgeZero asset streaming body: {e}"); + log::error!("failed to finish EdgeZero streaming body: {e}"); } } Err(e) => { - log::error!("EdgeZero asset streaming failed: {e:?}"); + log::error!("EdgeZero streaming failed: {e:?}"); drop(streaming_body); } } diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index b1bab232b..80f807ab2 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -156,6 +156,75 @@ fn backend_config_from_spec(spec: &PlatformBackendSpec) -> BackendConfig<'_> { .certificate_check(spec.certificate_check) .first_byte_timeout(spec.first_byte_timeout) .between_bytes_timeout(spec.between_bytes_timeout) + .discriminator(spec.discriminator.as_deref()) +} + +/// Transport-timeout quantum for auction backends (see +/// [`FastlyPlatformBackend::canonicalize_transport_timeout_ms`]). +const TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; + +/// Upper bound of the fine-grained quantum range. +/// +/// Budget-bound values below this ceiling are floored to a +/// [`TRANSPORT_TIMEOUT_QUANTUM_MS`] multiple (the issue #847 behavior for the +/// default 2000 ms auction). At or above it, values snap to the coarse +/// [`TRANSPORT_TIMEOUT_COARSE_LADDER_MS`] instead so the total number of +/// distinct budget-derived buckets stays globally bounded regardless of how +/// large the configured ceiling is. +const TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS: u32 = 2000; + +/// Coarse rungs for budget-bound transport timeouts below one quantum, +/// ordered high to low. +/// +/// Below one quantum, passing the exact wall-clock remainder through would mint +/// a distinct backend name for every millisecond in `1..250`, so the +/// near-exhausted tail alone could exceed Fastly's per-service dynamic backend +/// limit. Snapping to this finite ladder instead bounds the number of +/// budget-derived names an origin can produce. Budgets below the smallest rung +/// round to zero, which callers treat as "budget exhausted — skip the launch". +const SUB_QUANTUM_LADDER_MS: [u32; 4] = [200, 150, 100, 50]; + +/// Coarse rungs for budget-bound transport timeouts at or above the quantum +/// ceiling, ascending. Every rung is a [`TRANSPORT_TIMEOUT_QUANTUM_MS`] +/// multiple. +/// +/// Above [`TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS`], flooring to a 250 ms multiple +/// would let a large configured ceiling (e.g. 60,000 ms) mint hundreds of +/// distinct backend names — recreating the per-service dynamic backend +/// exhaustion this quantization exists to prevent. This fixed, globally finite +/// ladder caps the number of high-budget buckets instead: values are floored to +/// the greatest rung no larger than the remaining budget, and anything above +/// the top rung clamps to it. Rounding down never extends a transport cap past +/// the remaining budget. +const TRANSPORT_TIMEOUT_COARSE_LADDER_MS: [u32; 8] = + [2000, 3000, 5000, 10000, 20000, 30000, 45000, 60000]; + +/// Round a budget-bound transport timeout down to a stable, globally bounded +/// bucket. +/// +/// - At or above [`TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS`], floors to the +/// greatest [`TRANSPORT_TIMEOUT_COARSE_LADDER_MS`] rung no larger than +/// `remaining_ms` (clamping to the top rung above it). +/// - Within the quantum range, floors to a [`TRANSPORT_TIMEOUT_QUANTUM_MS`] +/// multiple. +/// - Below one quantum, snaps down to the greatest [`SUB_QUANTUM_LADDER_MS`] +/// rung no larger than `remaining_ms` (or zero). +fn quantize_transport_timeout_ms(remaining_ms: u32) -> u32 { + if remaining_ms >= TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS { + return TRANSPORT_TIMEOUT_COARSE_LADDER_MS + .into_iter() + .rev() + .find(|&rung| rung <= remaining_ms) + .unwrap_or(TRANSPORT_TIMEOUT_QUANTUM_CEILING_MS); + } + let floored = (remaining_ms / TRANSPORT_TIMEOUT_QUANTUM_MS) * TRANSPORT_TIMEOUT_QUANTUM_MS; + if floored > 0 { + return floored; + } + SUB_QUANTUM_LADDER_MS + .into_iter() + .find(|&rung| rung <= remaining_ms) + .unwrap_or(0) } impl PlatformBackend for FastlyPlatformBackend { @@ -170,6 +239,28 @@ impl PlatformBackend for FastlyPlatformBackend { .ensure() .change_context(PlatformError::Backend) } + + /// Quantize the transport timeout so budget-derived values do not mint a + /// new dynamic backend name on every request. + /// + /// Fastly embeds the first-byte and between-bytes timeouts in the dynamic + /// backend name (see [`BackendConfig`]) and pools connections per backend + /// name. A per-request wall-clock budget would otherwise defeat that + /// pooling and accumulate registrations toward the per-service dynamic + /// backend limit. + /// + /// A provider's own configured timeout is a constant, so when it is the + /// binding constraint it is returned verbatim — including sub-quantum + /// configured values, which must not be rounded away or the provider could + /// never launch. Only the budget-bound value is snapped to a stable bucket + /// via [`quantize_transport_timeout_ms`]. Rounding down never extends a + /// transport cap past the remaining budget. + fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { + if remaining_ms >= configured_ms { + return configured_ms; + } + quantize_transport_timeout_ms(remaining_ms) + } } // --------------------------------------------------------------------------- @@ -424,6 +515,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, @@ -635,15 +730,16 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let name = backend .predict_name(&spec) .expect("should compute backend name for valid spec"); - assert_eq!( - name, "backend_https_origin_example_com_443_fb15000_bb15000", - "should match BackendConfig naming convention" + assert!( + name.starts_with("backend_https_origin_example_com_443_fb15000_bb15000_"), + "should match BackendConfig naming convention, got {name}" ); } @@ -658,15 +754,18 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let name = backend .predict_name(&spec) .expect("should compute backend name for host header override"); - assert_eq!( - name, "backend_https_origin_example_com_443_oh_www_example_com_fb15000_bb15000", - "should match BackendConfig naming convention with host header override" + assert!( + name.starts_with( + "backend_https_origin_example_com_443_oh_www_example_com_fb15000_bb15000_" + ), + "should match BackendConfig naming convention with host header override, got {name}" ); } @@ -681,6 +780,7 @@ mod tests { certificate_check: false, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let name = backend @@ -704,6 +804,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let result = backend.predict_name(&spec); @@ -722,6 +823,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_millis(2000), between_bytes_timeout: Duration::from_millis(2000), + discriminator: None, }; let name = backend @@ -729,8 +831,40 @@ mod tests { .expect("should compute name with custom timeout"); assert!( - name.ends_with("_fb2000_bb2000"), - "should encode 2000ms first-byte and between-bytes timeouts in name" + name.contains("_fb2000_bb2000_"), + "should encode 2000ms first-byte and between-bytes timeouts in name, got {name}" + ); + } + + #[test] + fn predict_name_matches_ensured_backend_name() { + // The auction orchestrator maps responses back to providers by the + // predicted backend name, so predict_name and ensure must return the + // identical string for the same spec — a divergence would make + // responses land in the "unknown backend" branch and drop bids + // silently. + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "consistency.example.com".to_string(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(750), + between_bytes_timeout: Duration::from_millis(750), + discriminator: None, + }; + + let predicted = backend + .predict_name(&spec) + .expect("should predict backend name"); + let ensured = backend + .ensure(&spec) + .expect("should register backend for valid spec"); + + assert_eq!( + predicted, ensured, + "predicted backend name should match the registered backend name" ); } @@ -904,4 +1038,178 @@ mod tests { "should describe the unsupported streaming body: {err:?}" ); } + + // --- FastlyPlatformBackend::canonicalize_transport_timeout_ms ----------- + + #[test] + fn canonicalize_prefers_configured_timeout_when_budget_allows() { + let backend = FastlyPlatformBackend; + assert_eq!( + backend.canonicalize_transport_timeout_ms(2000, 1000), + 1000, + "should use the configured timeout verbatim when the budget allows" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(2000, 100), + 100, + "should preserve a sub-quantum configured constant — it is name-stable on its own" + ); + } + + #[test] + fn canonicalize_floors_budget_bound_value_to_quantum() { + let backend = FastlyPlatformBackend; + assert_eq!( + backend.canonicalize_transport_timeout_ms(999, 2000), + 750, + "should floor a 999ms budget to the 750ms quantum bucket" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(300, 2000), + 250, + "should floor a tight budget down to one quantum" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(250, 2000), + 250, + "should keep an exact quantum multiple" + ); + } + + #[test] + fn canonicalize_snaps_sub_quantum_budget_to_bounded_ladder() { + let backend = FastlyPlatformBackend; + // Exact wall-clock values in 1..250 must NOT pass through — that is the + // unbounded-cardinality regression this ladder closes. + assert_eq!( + backend.canonicalize_transport_timeout_ms(249, 2000), + 200, + "should snap a sub-quantum budget down to the greatest ladder rung, not pass 249 through" + ); + assert_eq!(backend.canonicalize_transport_timeout_ms(200, 2000), 200); + assert_eq!(backend.canonicalize_transport_timeout_ms(150, 2000), 150); + assert_eq!(backend.canonicalize_transport_timeout_ms(100, 2000), 100); + assert_eq!(backend.canonicalize_transport_timeout_ms(50, 2000), 50); + assert_eq!( + backend.canonicalize_transport_timeout_ms(49, 2000), + 0, + "a budget below the smallest rung rounds to zero (launch skipped)" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(0, 1000), + 0, + "an exhausted budget canonicalizes to zero" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(100, 0), + 0, + "a zero configured timeout canonicalizes to zero" + ); + } + + #[test] + fn canonicalize_budget_derived_names_stay_within_a_safe_cardinality() { + // Enumerate every reachable remaining budget for a normal 2000ms + // ceiling and confirm the number of distinct backend-name-bearing + // transport values an origin can mint stays far below Fastly's + // per-service dynamic backend limit (documented default 200). + let backend = FastlyPlatformBackend; + let configured = 2000; + let mut distinct = std::collections::BTreeSet::new(); + for remaining in 0..=configured { + let value = backend.canonicalize_transport_timeout_ms(remaining, configured); + if value > 0 { + distinct.insert(value); + } + // No arbitrary clock-derived value may leak: every canonical value + // is either a quantum multiple or one of the bounded ladder rungs. + assert!( + value == 0 + || value % TRANSPORT_TIMEOUT_QUANTUM_MS == 0 + || SUB_QUANTUM_LADDER_MS.contains(&value), + "canonical value {value}ms (from remaining {remaining}ms) is neither a quantum \ + multiple nor a ladder rung" + ); + } + assert!( + distinct.len() <= 16, + "budget-derived transport values should stay well under the dynamic backend limit, \ + got {} distinct values: {distinct:?}", + distinct.len() + ); + } + + #[test] + fn canonicalize_budget_derived_names_stay_bounded_for_large_ceiling() { + // A large configured ceiling (e.g. a 60s mediator budget) must not let + // the budget-derived buckets grow with the ceiling. Without the coarse + // ladder a 60,000ms ceiling would mint ~240 distinct 250ms buckets and + // blow past Fastly's documented per-service dynamic backend limit (200). + let backend = FastlyPlatformBackend; + let configured = 60_000; + let mut distinct = std::collections::BTreeSet::new(); + for remaining in 0..=configured { + let value = backend.canonicalize_transport_timeout_ms(remaining, configured); + if value > 0 { + distinct.insert(value); + } + assert!( + value == 0 + || value % TRANSPORT_TIMEOUT_QUANTUM_MS == 0 + || SUB_QUANTUM_LADDER_MS.contains(&value), + "canonical value {value}ms (from remaining {remaining}ms) is neither a quantum \ + multiple nor a ladder rung" + ); + } + // A budget above the top coarse rung must clamp to it, not open a new + // bucket per 250ms step. + assert_eq!( + backend.canonicalize_transport_timeout_ms(120_000, 240_000), + 60_000, + "a budget above the top coarse rung clamps to it" + ); + assert!( + distinct.len() <= 20, + "large-ceiling budget-derived values must stay bounded, got {} distinct values: {distinct:?}", + distinct.len() + ); + } + + // --- FastlyPlatformBackend::predict_name discriminator ------------------ + + #[test] + fn predict_name_includes_provider_discriminator() { + let backend = FastlyPlatformBackend; + let base = PlatformBackendSpec { + scheme: "https".to_string(), + host: "gateway.example.com".to_string(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(750), + between_bytes_timeout: Duration::from_millis(750), + discriminator: Some("prebid".to_string()), + }; + let prebid_name = backend + .predict_name(&base) + .expect("should predict name with discriminator"); + assert!( + prebid_name.contains("_p_prebid"), + "should fold the provider discriminator into the name, got {prebid_name}" + ); + + // Same origin + same transport timeout, different provider → distinct + // backend names, so auction response correlation cannot cross them. + let aps = PlatformBackendSpec { + discriminator: Some("aps".to_string()), + ..base.clone() + }; + let aps_name = backend + .predict_name(&aps) + .expect("should predict name for the second provider"); + assert_ne!( + prebid_name, aps_name, + "two providers on one origin must not share a backend name" + ); + } } diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index 217c167b8..f2df61744 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -212,6 +212,7 @@ fn tinybird_backend_spec(api_host: &str) -> PlatformBackendSpec { certificate_check: true, first_byte_timeout: TINYBIRD_FIRST_BYTE_TIMEOUT, between_bytes_timeout: TINYBIRD_BETWEEN_BYTES_TIMEOUT, + discriminator: None, } } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 1e13ca300..492f1a518 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -92,8 +92,15 @@ impl PlatformBackend for NoopBackend { } else { "_nocert" }; + // Keep two providers that share an origin on distinct names so auction + // response correlation cannot cross providers. + let discriminator = spec + .discriminator + .as_deref() + .map(|d| format!("_p_{d}")) + .unwrap_or_default(); Ok(format!( - "{}_{}_{}_{timeout_ms}ms{cert_suffix}", + "{}_{}_{}_{timeout_ms}ms{cert_suffix}{discriminator}", spec.scheme, spec.host, port )) } diff --git a/crates/trusted-server-core/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/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; @@ -488,6 +579,92 @@ describe('installTsAdInit', () => { beaconSpy.mockRestore(); }); + it('stamps trace markers and records the render on slotRenderEnded', async () => { + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue([]), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn; + }), + }; + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'cache-uuid-9', + hb_auction_id: 'ts-req-trace9', + hb_crid: 'cr-98765', + hb_adm_hash: 'a1b2c3d4e5f60718', + }, + }, + }; + + const { installTsAdInit } = await import('../../../src/integrations/gpt/index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(capturedListener).toBeDefined(); + capturedListener!({ isEmpty: false, slot: mockSlot }); + + const el = document.getElementById('div-atf-sidebar')!; + expect(el.getAttribute('data-ts-slot-id')).toBe('atf_sidebar_ad'); + expect(el.getAttribute('data-ts-render-path')).toBe('ssat'); + expect(el.getAttribute('data-ts-rendered')).toBe('true'); + expect(el.getAttribute('data-ts-auction-id')).toBe('ts-req-trace9'); + expect(el.getAttribute('data-ts-bidder')).toBe('kargo'); + expect(el.getAttribute('data-ts-ad-id')).toBe('cache-uuid-9'); + expect(el.getAttribute('data-ts-creative-id')).toBe('cr-98765'); + expect(el.getAttribute('data-ts-adm-hash')).toBe('a1b2c3d4e5f60718'); + + const record = (window as TestWindow).tsjs!.renders?.['atf_sidebar_ad']; + expect(record).toEqual( + expect.objectContaining({ + slotId: 'atf_sidebar_ad', + path: 'ssat', + rendered: true, + elementId: 'div-atf-sidebar', + auctionId: 'ts-req-trace9', + bidder: 'kargo', + adId: 'cache-uuid-9', + creativeId: 'cr-98765', + admHash: 'a1b2c3d4e5f60718', + servedFrom: 'gam', + }) + ); + + // An empty render must record rendered:false and bump the count. + capturedListener!({ isEmpty: true, slot: mockSlot }); + const second = (window as TestWindow).tsjs!.renders?.['atf_sidebar_ad']; + expect(second?.rendered).toBe(false); + expect(second?.count).toBe(2); + expect(el.getAttribute('data-ts-rendered')).toBe('false'); + }); + it('does not fire beacons for an APS-style bid that carries no hb_adid', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); let capturedListener: ((e: SlotRenderEvent) => void) | undefined; @@ -654,7 +831,7 @@ describe('installTsAdInit', () => { beaconSpy.mockRestore(); }); - it('calls apstag.setDisplayBids when hb_bidder is aps', async () => { + it('does not call native apstag for a Trusted Server APS renderer winner', async () => { const setDisplayBidsSpy = vi.fn(); (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; @@ -687,7 +864,12 @@ describe('installTsAdInit', () => { }, ], bids: { - atf_sidebar_ad: { hb_pb: '1.50', hb_bidder: 'aps', nurl: '', burl: '' }, + 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; @@ -696,7 +878,8 @@ describe('installTsAdInit', () => { installTsAdInit(); (window as TestWindow).tsjs!.adInit!(); - expect(setDisplayBidsSpy).toHaveBeenCalled(); + expect(setDisplayBidsSpy).not.toHaveBeenCalled(); + expect((window as TestWindow).apstag).toEqual({ setDisplayBids: setDisplayBidsSpy }); delete (window as TestWindow).apstag; }); @@ -914,6 +1097,279 @@ describe('installTsRenderBridge', () => { 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
'; @@ -1039,18 +1495,22 @@ describe('installTsRenderBridge', () => { 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 +1563,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'); @@ -1235,7 +1695,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 +1740,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/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..00cf99dd0 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,37 @@ 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('falls back to impid when no matching bidRequest found', () => { const auctionBids: AuctionBid[] = [ { @@ -218,6 +280,8 @@ describe('prebid/installPrebidNpm', () => { document.cookie = 'ts-eids=; Path=/; Max-Age=0'; delete (window as any).__tsjs_prebid; delete (window as any).__tsjs_prebid_diagnostics; + delete (window as any).tsjs; + delete (mockPbjs as any).__tsApsBidResponseListenerInstalled; }); afterEach(() => { @@ -241,6 +305,73 @@ 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('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 +799,15 @@ describe('prebid/installPrebidNpm', () => { expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); }); + it('normalizes a truthy non-array bids value without throwing', () => { + const pbjs = installPrebidNpm(); + const adUnits = [{ code: 'example-malformed-slot', bids: { malformed: true } }] as any[]; + + expect(() => pbjs.requestBids({ adUnits } as any)).not.toThrow(); + + expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); + }); + it('includes zone from mediaTypes.banner.name in trustedServer params', () => { const pbjs = installPrebidNpm(); @@ -1403,6 +1543,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/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..8dee3f452 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md @@ -0,0 +1,164 @@ +# SSAT: render the winning creative inline (no PBS Cache round trip) + +**Date:** 2026-07-13 +**Status:** Design revised after review, pending final approval +**Branch:** `feat/ssat-render-inline-creative` + +## 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` (from `bid.creative`) for a winner when present — + there is no runtime reason to withhold it, so it is not parameterized. +- Insert the verbose `debug_bid` blob **only** when the testing flag is set. The + single `include_adm` param becomes `include_debug_bid`. + +`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. + +**Fallback scope (corrected):** the bridge posts the markup to the PUC and +returns; it receives **no render-success signal**. So the PBS Cache fallback +fires only when `adm` is **absent or empty** — _not_ when `adm` is present but +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 + +No new escaping code. The `adm` string is part of the bid-map JSON that already +passes through `html_escape_for_script` + `JSON.parse("…")`, which neutralizes +`` breakout and `U+2028/2029`. **This is the guarantee trusted-server +directly provides**, pinned by a hostile-`adm` regression test. + +Frame isolation of the rendered creative is **not** guaranteed by TS on the +bridge path: `injectAdmIntoSlot` sets `sandbox=ADM_IFRAME_SANDBOX`, but the +bridge renderer hands `adm` to the PUC-provided `mkFrame`, which TS neither sets +nor verifies a sandbox on. Bridge isolation therefore depends on the Prebid +Universal Creative implementation, not on TS. + +## Components changed + +| Unit | Change | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `build_bid_map` (Rust) | Always insert `adm` when `bid.creative` is `Some`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | +| `build_bid_map` callers | Pass `include_debug_bid = inject_adm_for_testing`. | +| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | +| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; confirm existing coverage. | + +No `build_bids_script` change, no `window.tsjs` flag, no `TsjsApi` change. + +## Data flow (after) + +``` +SSAT auction → winner (bid.creative held) → build_bid_map inserts adm + → build_bids_script (html_escape_for_script) → window.tsjs.bids + → hb_pb targeting → GAM competes + ├ GAM picks TS line item → PUC "Prebid Request" + │ → bridge replies with local adm → RENDER (no round trip) + beacons + │ → (adm absent) → PBS Cache fetch → 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 `adm` for winners on every path; `debug_bid` + present only under the testing flag; a hostile `` / `U+2028/2029` + `adm` is escaped so `build_bids_script` output stays inside the `