From 428d4ed1c2cfce668dc9741741756df78144135b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 29 May 2026 18:06:06 +0530 Subject: [PATCH 01/18] Move slot templates from creative-opportunities.toml into trusted-server.toml Add [[creative_opportunities.slot]] array to trusted-server.toml and remove the separate creative-opportunities.toml file. Slots now deserialize directly into CreativeOpportunitiesConfig.slot via the existing vec_from_seq_or_map deserializer, with compile_slots() called in Settings::prepare_runtime(). Update publisher.rs and main.rs function signatures from &CreativeOpportunitiesFile to &[CreativeOpportunitySlot]. Build.rs slot-ID validation now reads from the merged settings rather than a separate file. --- .../trusted-server-adapter-fastly/src/main.rs | 46 +- .../src/route_tests.rs | 11 +- crates/trusted-server-core/build.rs | 53 +- .../src/creative_opportunities.rs | 62 +- crates/trusted-server-core/src/publisher.rs | 71 +- crates/trusted-server-core/src/settings.rs | 17 +- creative-opportunities.toml | 47 -- .../2026-05-29-pr680-reviewer-findings.md | 613 ++++++++++++++++++ trusted-server.toml | 45 ++ 9 files changed, 775 insertions(+), 190 deletions(-) delete mode 100644 creative-opportunities.toml create mode 100644 docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 5be29494..2a73c8c0 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -40,41 +40,6 @@ use crate::error::to_error_response; use crate::logging::init_logger; use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; -const CREATIVE_OPPORTUNITIES_TOML: &str = include_str!("../../../creative-opportunities.toml"); - -/// Parses the embedded `creative-opportunities.toml` at most once per Wasm -/// instance. -/// -/// On parse failure, logs an error and falls back to an empty -/// [`CreativeOpportunitiesFile`] — i.e. the documented "feature disabled" -/// state — instead of panicking the request hot path. The build-time -/// validator in `crates/trusted-server-core/build.rs` catches every realistic -/// authoring mistake; this fallback exists so a CI-bypassed binary patch or a -/// future schema change can't take the entire fleet down with a per-request -/// panic. -static SLOTS_FILE: std::sync::LazyLock< - trusted_server_core::creative_opportunities::CreativeOpportunitiesFile, -> = std::sync::LazyLock::new(|| { - let mut file = match toml::from_str::< - trusted_server_core::creative_opportunities::CreativeOpportunitiesFile, - >(CREATIVE_OPPORTUNITIES_TOML) - { - Ok(file) => file, - Err(err) => { - log::error!( - "creative-opportunities.toml failed to parse at startup; \ - falling back to an empty slots file (server-side ad-slot \ - templates disabled): {err}" - ); - trusted_server_core::creative_opportunities::CreativeOpportunitiesFile::default() - } - }; - // Pre-compile glob patterns once so per-request `matches_path` doesn't - // re-invoke `Pattern::new` on every page hit. - file.compile(); - file -}); - /// Entry point for the Fastly Compute program. /// /// Uses an undecorated `main()` with `Request::from_client()` instead of @@ -127,8 +92,6 @@ fn main() { } }; - let slots_file = &*SLOTS_FILE; - let integration_registry = match IntegrationRegistry::new(&settings) { Ok(r) => r, Err(e) => { @@ -152,7 +115,7 @@ fn main() { &orchestrator, &integration_registry, &runtime_services, - slots_file, + settings.creative_opportunity_slots(), req, )) { response.send_to_client(); @@ -206,7 +169,7 @@ async fn route_request( orchestrator: &AuctionOrchestrator, integration_registry: &IntegrationRegistry, runtime_services: &RuntimeServices, - slots_file: &trusted_server_core::creative_opportunities::CreativeOpportunitiesFile, + slots: &[trusted_server_core::creative_opportunities::CreativeOpportunitySlot], mut req: Request, ) -> Option { // Strip client-spoofable forwarded headers at the edge. @@ -285,8 +248,7 @@ async fn route_request( (Method::GET, "/__ts/page-bids") => { match runtime_services_for_consent_route(settings, runtime_services) { Ok(publisher_services) => { - handle_page_bids(settings, orchestrator, &publisher_services, slots_file, req) - .await + handle_page_bids(settings, orchestrator, &publisher_services, slots, req).await } Err(e) => Err(e), } @@ -328,7 +290,7 @@ async fn route_request( integration_registry, &publisher_services, orchestrator, - slots_file, + slots, req, ) .await diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 06336a9b..fa496783 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -184,9 +184,6 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); - let slots_file = - trusted_server_core::creative_opportunities::CreativeOpportunitiesFile::default(); - let discovery_req = Request::get("https://test.com/.well-known/trusted-server.json"); let discovery_services = test_runtime_services(&discovery_req); let discovery_resp = futures::executor::block_on(route_request( @@ -194,7 +191,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &discovery_services, - &slots_file, + &[], discovery_req, )) .expect("should route discovery request"); @@ -211,7 +208,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &admin_services, - &slots_file, + &[], admin_req, )) .expect("should route admin request"); @@ -228,7 +225,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &auction_services, - &slots_file, + &[], auction_req, )) .expect("should return an error response for auction requests"); @@ -245,7 +242,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &publisher_services, - &slots_file, + &[], publisher_req, )) .expect("should return an error response for publisher fallback"); diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index b21cb684..8da48dae 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -35,6 +35,12 @@ mod price_bucket; mod creative_opportunities { use serde::{Deserialize, Serialize}; + /// Stub slot type — only used so settings.rs compiles in the build context. + #[derive(Debug, Clone, Deserialize, Serialize)] + pub struct CreativeOpportunitySlot { + pub id: String, + } + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CreativeOpportunitiesConfig { pub gam_network_id: String, @@ -42,6 +48,19 @@ mod creative_opportunities { pub auction_timeout_ms: Option, #[serde(default = "default_price_granularity")] pub price_granularity: String, + /// Deserialized as raw JSON values so build.rs can validate slot IDs + /// without pulling in the full runtime type. + #[serde(default, rename = "slot")] + pub slot_raw: Vec, + /// Typed slot vec — always empty in the build context; exists only so + /// settings.rs (included via #[path]) compiles against the stub. + #[serde(skip)] + pub slot: Vec, + } + + impl CreativeOpportunitiesConfig { + /// No-op stub — pattern compilation only runs at runtime. + pub fn compile_slots(&mut self) {} } fn default_price_granularity() -> String { @@ -57,7 +76,6 @@ use std::path::Path; const TRUSTED_SERVER_INIT_CONFIG_PATH: &str = "../../trusted-server.toml"; const TRUSTED_SERVER_OUTPUT_CONFIG_PATH: &str = "../../target/trusted-server-out.toml"; -const CREATIVE_OPPORTUNITIES_PATH: &str = "../../creative-opportunities.toml"; fn main() { // Always rerun build.rs: integration settings are stored in a flat @@ -87,33 +105,28 @@ fn main() { .unwrap_or_else(|_| panic!("Failed to write {dest_path:?}")); } - // Validate creative-opportunities.toml slot IDs at build time - println!("cargo:rerun-if-changed={}", CREATIVE_OPPORTUNITIES_PATH); - - let co_path = Path::new(CREATIVE_OPPORTUNITIES_PATH); - if co_path.exists() { - let co_content = - fs::read_to_string(co_path).expect("should read creative-opportunities.toml"); - let co_value: toml::Value = - toml::from_str(&co_content).expect("creative-opportunities.toml: invalid TOML"); - let slot_id_re = regex::Regex::new(r"^[A-Za-z0-9_\-]+$").expect("should compile regex"); - if let Some(slots) = co_value.get("slot").and_then(|v| v.as_array()) { - for slot in slots { - let id = slot - .get("id") - .and_then(|v| v.as_str()) - .expect("creative-opportunities.toml: slot missing 'id' field"); + // Validate slot IDs from [creative_opportunities.slot] in trusted-server.toml + let slot_id_re = regex::Regex::new(r"^[A-Za-z0-9_\-]+$").expect("should compile regex"); + if let Some(co) = &settings.creative_opportunities { + for slot in &co.slot_raw { + if let Some(id) = slot.get("id").and_then(|v| v.as_str()) { if !slot_id_re.is_match(id) { panic!( - "creative-opportunities.toml: slot id '{}' is invalid; \ + "trusted-server.toml [creative_opportunities.slot]: slot id '{}' is invalid; \ only [A-Za-z0-9_-] allowed", id ); } + } else { + panic!( + "trusted-server.toml [creative_opportunities.slot]: a slot entry is missing the required 'id' field" + ); } + } + if !co.slot_raw.is_empty() { println!( - "cargo:warning=creative-opportunities.toml: {} slot(s) validated", - slots.len() + "cargo:warning=creative_opportunities: {} slot(s) validated", + co.slot_raw.len() ); } } diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 95180041..7574a49f 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -12,6 +12,7 @@ use glob::Pattern; use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::price_bucket::PriceGranularity; +use crate::settings::vec_from_seq_or_map; /// Top-level configuration for the creative opportunities system. #[derive(Debug, Clone, Deserialize, Serialize)] @@ -38,10 +39,22 @@ pub struct CreativeOpportunitiesConfig { /// Price granularity for header-bidding price bucketing. #[serde(default = "PriceGranularity::dense")] pub price_granularity: PriceGranularity, + /// Slot templates. Empty vec = feature disabled (no auction fired, no globals injected). + #[serde(default, deserialize_with = "vec_from_seq_or_map")] + pub slot: Vec, +} + +impl CreativeOpportunitiesConfig { + /// Pre-compile glob patterns for all slots. Call once after deserialization. + pub fn compile_slots(&mut self) { + for slot in &mut self.slot { + slot.compile_patterns(); + } + } } /// A single ad placement opportunity on the publisher's site. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct CreativeOpportunitySlot { /// Unique identifier for the slot (e.g., `"atf"`, `"below-fold-sidebar"`). @@ -68,10 +81,10 @@ pub struct CreativeOpportunitySlot { pub providers: SlotProviders, /// Pre-compiled [`page_patterns`](Self::page_patterns) for hot-path matching. /// - /// Populated by [`compile_patterns`](Self::compile_patterns) once at file - /// load time (see [`CreativeOpportunitiesFile::compile`]). When this is + /// Populated by [`compile_patterns`](Self::compile_patterns) once at startup + /// via [`CreativeOpportunitiesConfig::compile_slots`]. When this is /// empty, [`matches_path`](Self::matches_path) falls back to compiling on - /// every call so callers that build slots by hand (tests, legacy code) + /// every call so callers that build slots by hand in tests /// still work. /// /// `pub(crate)` rather than private so cross-module test helpers in this @@ -188,7 +201,7 @@ impl CreativeOpportunitySlot { } /// An ad format combining a media type with pixel dimensions. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct CreativeOpportunityFormat { /// Creative width in pixels. pub width: u32, @@ -210,40 +223,19 @@ impl CreativeOpportunityFormat { } /// Provider-specific slot identifiers for a [`CreativeOpportunitySlot`]. -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct SlotProviders { /// Amazon Publisher Services (APS/TAM) slot parameters. pub aps: Option, } /// APS-specific parameters for a slot. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct ApsSlotParams { /// The APS slot ID string used when making TAM bid requests. pub slot_id: String, } -/// TOML file structure for creative opportunity slot definitions. -#[derive(Debug, Clone, Deserialize, Default)] -#[serde(deny_unknown_fields)] -pub struct CreativeOpportunitiesFile { - /// All slot definitions in the file (mapped from `[[slot]]` TOML arrays). - #[serde(rename = "slot", default)] - pub slots: Vec, -} - -impl CreativeOpportunitiesFile { - /// Pre-compile every slot's - /// [`page_patterns`](CreativeOpportunitySlot::page_patterns) so - /// [`matches_path`](CreativeOpportunitySlot::matches_path) runs without - /// re-invoking `Pattern::new` on every request. Call once after loading. - pub fn compile(&mut self) { - for slot in &mut self.slots { - slot.compile_patterns(); - } - } -} - /// Validates that a slot ID contains only safe characters. /// /// Allowed characters: ASCII alphanumerics, underscores (`_`), and hyphens (`-`). @@ -326,16 +318,16 @@ mod tests { } #[test] - fn file_compile_populates_every_slot() { - let mut file = CreativeOpportunitiesFile { - slots: vec![make_slot("a", vec!["/a/*"]), make_slot("b", vec!["/b/*"])], - }; - file.compile(); - for slot in &file.slots { + fn compile_slots_populates_every_slot() { + let mut slots = vec![make_slot("a", vec!["/a/*"]), make_slot("b", vec!["/b/*"])]; + for slot in &mut slots { + slot.compile_patterns(); + } + for slot in &slots { assert_eq!( slot.compiled_patterns.len(), 1, - "every slot's patterns should be pre-compiled after file.compile()" + "every slot's patterns should be pre-compiled after compile_patterns()" ); } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c6ffa776..4d955273 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -853,7 +853,7 @@ pub async fn handle_publisher_request( integration_registry: &IntegrationRegistry, services: &RuntimeServices, orchestrator: &AuctionOrchestrator, - slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + slots: &[crate::creative_opportunities::CreativeOpportunitySlot], mut req: Request, ) -> Result> { log::debug!("Proxying request to publisher_origin"); @@ -939,7 +939,7 @@ pub async fn handle_publisher_request( let is_bot = is_bot_user_agent(&req); let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() && is_get { - crate::creative_opportunities::match_slots(&slots_file.slots, &request_path) + crate::creative_opportunities::match_slots(slots, &request_path) .into_iter() .cloned() .collect() @@ -1479,7 +1479,7 @@ pub async fn handle_page_bids( settings: &Settings, orchestrator: &AuctionOrchestrator, services: &RuntimeServices, - slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + slots: &[crate::creative_opportunities::CreativeOpportunitySlot], req: Request, ) -> Result> { let Some(co_config) = &settings.creative_opportunities else { @@ -1494,11 +1494,10 @@ pub async fn handle_page_bids( .map(|(_, v)| v.into_owned()) .unwrap_or_else(|| "/".to_string()); - let matched_slots: Vec<_> = - crate::creative_opportunities::match_slots(&slots_file.slots, &path_param) - .into_iter() - .cloned() - .collect(); + let matched_slots: Vec<_> = crate::creative_opportunities::match_slots(slots, &path_param) + .into_iter() + .cloned() + .collect(); let http_req = compat::from_fastly_headers_ref(&req); let request_info = @@ -2582,6 +2581,7 @@ mod tests { gam_network_id: "21765378893".to_string(), auction_timeout_ms: Some(500), price_granularity: PriceGranularity::Dense, + slot: Vec::new(), } } @@ -2789,9 +2789,7 @@ mod tests { mod page_bids_no_match_tests { use super::super::*; use crate::auction::AuctionOrchestrator; - use crate::creative_opportunities::{ - CreativeOpportunitiesFile, CreativeOpportunityFormat, CreativeOpportunitySlot, - }; + use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; use crate::platform::test_support::noop_services; use crate::test_support::tests::crate_test_settings_str; use fastly::http::Method; @@ -2805,24 +2803,22 @@ mod tests { Settings::from_toml(&toml).expect("should parse settings with creative_opportunities") } - fn file_with_article_slot() -> CreativeOpportunitiesFile { - CreativeOpportunitiesFile { - slots: vec![CreativeOpportunitySlot { - id: "atf".to_string(), - gam_unit_path: None, - div_id: None, - page_patterns: vec!["/20**".to_string()], - formats: vec![CreativeOpportunityFormat { - width: 300, - height: 250, - media_type: crate::auction::types::MediaType::Banner, - }], - floor_price: Some(0.50), - targeting: Default::default(), - providers: Default::default(), - compiled_patterns: Vec::new(), + fn article_slot() -> Vec { + vec![CreativeOpportunitySlot { + id: "atf".to_string(), + gam_unit_path: None, + div_id: None, + page_patterns: vec!["/20**".to_string()], + formats: vec![CreativeOpportunityFormat { + width: 300, + height: 250, + media_type: crate::auction::types::MediaType::Banner, }], - } + floor_price: Some(0.50), + targeting: Default::default(), + providers: Default::default(), + compiled_patterns: Vec::new(), + }] } fn make_page_bids_request(path: &str) -> Request { @@ -2839,10 +2835,9 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = CreativeOpportunitiesFile { slots: vec![] }; let req = make_page_bids_request("/2024/01/my-article/"); - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &[], req) .await .expect("should return ok response"); @@ -2855,7 +2850,7 @@ mod tests { .expect("slots should be array") .len(), 0, - "empty slots file should produce zero injected slots" + "empty slots should produce zero injected slots" ); assert_eq!( body["bids"] @@ -2863,7 +2858,7 @@ mod tests { .expect("bids should be object") .len(), 0, - "empty slots file should produce zero bids" + "empty slots should produce zero bids" ); } @@ -2875,11 +2870,11 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = file_with_article_slot(); + let slots = article_slot(); let mut req = make_page_bids_request("/2024/01/my-article/"); req.set_header("user-agent", "Mozilla/5.0 (compatible; Googlebot/2.1)"); - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) .await .expect("should return ok response"); @@ -2911,11 +2906,11 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = file_with_article_slot(); + let slots = article_slot(); let mut req = make_page_bids_request("/2024/01/my-article/"); req.set_header("sec-purpose", "prefetch"); - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) .await .expect("should return ok response"); @@ -2946,10 +2941,10 @@ mod tests { let settings = settings_with_co(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); - let slots_file = file_with_article_slot(); // slot matches /20** only + let slots = article_slot(); // slot matches /20** only let req = make_page_bids_request("/about"); // does not match - let response = handle_page_bids(&settings, &orchestrator, &services, &slots_file, req) + let response = handle_page_bids(&settings, &orchestrator, &services, &slots, req) .await .expect("should return ok response"); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 386f0d54..b32201ba 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -522,14 +522,29 @@ impl Settings { /// # Errors /// /// Returns a configuration error if any cached runtime artifact cannot be prepared. - pub fn prepare_runtime(&self) -> Result<(), Report> { + pub fn prepare_runtime(&mut self) -> Result<(), Report> { for handler in &self.handlers { handler.prepare_runtime()?; } + if let Some(co) = &mut self.creative_opportunities { + co.compile_slots(); + } + Ok(()) } + /// Returns compiled creative opportunity slots, or empty slice if feature is disabled. + #[must_use] + pub fn creative_opportunity_slots( + &self, + ) -> &[crate::creative_opportunities::CreativeOpportunitySlot] { + self.creative_opportunities + .as_ref() + .map(|co| co.slot.as_slice()) + .unwrap_or(&[]) + } + /// Resolve the first handler whose regex matches the request path. /// /// # Errors diff --git a/creative-opportunities.toml b/creative-opportunities.toml deleted file mode 100644 index da1ed23e..00000000 --- a/creative-opportunities.toml +++ /dev/null @@ -1,47 +0,0 @@ -# Slot templates for server-side ad auction. -# Empty file = feature disabled (no auction fired, no globals injected). - -[[slot]] -id = "atf_sidebar_ad" -gam_unit_path = "/a/b/news" -div_id = "ad-atf_sidebar-0-_r_2_" -page_patterns = ["/20**", "/news/**"] -formats = [{ width = 300, height = 250 }] -floor_price = 0.50 - -[slot.targeting] -pos = "atf" -zone = "atfSidebar" - -[slot.providers.aps] -slot_id = "aps-slot-atf-sidebar" - -[[slot]] -id = "homepage_header_ad" -gam_unit_path = "/a/b/homepage" -div_id = "ad-header-0-_R_jpalubtak5lb_" -page_patterns = ["/"] -formats = [{ width = 970, height = 90 }, { width = 728, height = 90 }, { width = 970, height = 250 }] -floor_price = 0.50 - -[slot.targeting] -pos = "atf" -zone = "header" - -[slot.providers.aps] -slot_id = "aps-slot-homepage-header" - -[[slot]] -id = "homepage_footer_ad" -gam_unit_path = "/a/b/homepage" -div_id = "ad-fixed_bottom-0-_R_klubtak5lb_" -page_patterns = ["/"] -formats = [{ width = 728, height = 90 }] -floor_price = 0.50 - -[slot.targeting] -pos = "btf" -zone = "fixedBottom" - -[slot.providers.aps] -slot_id = "aps-slot-homepage-footer" diff --git a/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md b/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md new file mode 100644 index 00000000..c65c0f1a --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md @@ -0,0 +1,613 @@ +# PR #680 Reviewer Findings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Address the two reviewer-required findings from PR #680 plus low-effort cleanups: consolidate slot config into `trusted-server.toml`, namespace `window.__ts*` globals under `window._ts`, and fix the TypeScript `formats` type cast and `ts_initial` hardcoded string. + +**Architecture:** Slot templates move from the standalone `creative-opportunities.toml` (embedded via `include_str!`) into the `[creative_opportunities]` section of `trusted-server.toml`, using the existing `vec_from_seq_or_map` deserializer pattern already used for `BID_PARAM_ZONE_OVERRIDES`. The window globals rename is a coordinated change across `gpt_bootstrap.js`, `index.ts`, and `publisher.rs` — all three must change together since they share a runtime contract. + +**Tech Stack:** Rust (serde, toml), TypeScript, vanilla JS, `cargo test --workspace`, `npx vitest run` + +--- + +## Context for all tasks + +- **Branch:** create `fix/pr680-review-findings` off `server-side-ad-templates-impl` before starting +- **Current codebase:** `crates/trusted-server-core/`, `crates/trusted-server-adapter-fastly/`, `crates/js/lib/` +- **CI gates:** `cargo fmt`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace`, `npx vitest run`, `npm run format` +- **Error handling:** use `error-stack` (`Report`), not anyhow. Use `derive_more::Display`, not thiserror. +- **No `unwrap()` in production code** — use `expect("should ...")`. +- **Do not** add `println!` / `eprintln!` — use `log::` macros. + +--- + +## Task 1: Consolidate slot config into `trusted-server.toml` + +**What:** Delete `creative-opportunities.toml`. Move `[[slot]]` arrays into `trusted-server.toml` as `[[creative_opportunities.slot]]`. Wire the `vec_from_seq_or_map` deserializer so env var JSON blobs also work. Remove the `SLOTS_FILE` static and `include_str!` from `main.rs`. Update `build.rs` to validate slot IDs from settings instead of a separate file. + +**Files:** +- Modify: `crates/trusted-server-core/src/creative_opportunities.rs` +- Modify: `crates/trusted-server-core/src/settings.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` +- Modify: `crates/trusted-server-core/build.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` (function signatures) +- Modify: `trusted-server.toml` +- Delete: `creative-opportunities.toml` + +**Steps:** + +- [ ] **Step 1: Create the branch** + +```bash +git checkout -b fix/pr680-review-findings +``` + +- [ ] **Step 2: Add `Serialize` and `slot` field to structs** + +In `crates/trusted-server-core/src/creative_opportunities.rs`: + +1. Add `Serialize` to `CreativeOpportunitySlot` derive — it already has `#[serde(skip, default)]` on `compiled_patterns` so that field won't serialize. + +```rust +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CreativeOpportunitySlot { ... } +``` + +Also add `Serialize` to `CreativeOpportunityFormat`, `SlotProviders`, `ApsSlotParams` (any struct used inside `CreativeOpportunitySlot`). + +2. Add a `slot` field to `CreativeOpportunitiesConfig`: + +```rust +use crate::settings::vec_from_seq_or_map; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreativeOpportunitiesConfig { + pub gam_network_id: String, + #[serde(default)] + pub auction_timeout_ms: Option, + #[serde(default = "PriceGranularity::dense")] + pub price_granularity: PriceGranularity, + /// Slot templates. Empty = feature disabled. + #[serde(default, deserialize_with = "vec_from_seq_or_map")] + pub slot: Vec, +} +``` + +Note: the field is named `slot` (not `slots`) to match the TOML key `[[creative_opportunities.slot]]`. + +- [ ] **Step 3: Delete `CreativeOpportunitiesFile`** + +Remove the `CreativeOpportunitiesFile` struct and its `impl` from `creative_opportunities.rs`. The `compile` logic moves to a free function or into `CreativeOpportunitiesConfig`: + +```rust +impl CreativeOpportunitiesConfig { + /// Pre-compile glob patterns for all slots. Call once after deserialization. + pub fn compile_slots(&mut self) { + for slot in &mut self.slot { + slot.compile_patterns(); + } + } +} +``` + +- [ ] **Step 4: Wire slot compilation into `Settings::prepare_runtime`** + +Glob pattern pre-compilation must happen once at startup, not per-request. `Settings::prepare_runtime` is already called after deserialization in both `from_toml_and_env` (build time) and `get_settings()` (runtime). Add slot compilation there: + +```rust +// In settings.rs, inside Settings::prepare_runtime +pub fn prepare_runtime(&mut self) -> Result<(), Report> { + for handler in &self.handlers { + handler.prepare_runtime()?; + } + // Pre-compile slot glob patterns for hot-path matching. + if let Some(co) = &mut self.creative_opportunities { + co.compile_slots(); + } + Ok(()) +} +``` + +Note: `prepare_runtime` must take `&mut self` for this change. Check current signature — if it takes `&self`, change it to `&mut self` and update call sites. + +Also add a helper method for call sites that need the slot slice: + +```rust +impl Settings { + /// Returns compiled creative opportunity slots, or empty slice if disabled. + pub fn creative_opportunity_slots(&self) -> &[CreativeOpportunitySlot] { + self.creative_opportunities + .as_ref() + .map(|co| co.slot.as_slice()) + .unwrap_or(&[]) + } +} +``` + +- [ ] **Step 5: Update `build.rs` stub and slot validation** + +First update the `creative_opportunities` stub in `build.rs` to add the `slot` field — without this the settings parse will fail at build time when `trusted-server.toml` contains `[[creative_opportunities.slot]]` entries: + +```rust +mod creative_opportunities { + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Deserialize, Serialize)] + pub struct CreativeOpportunitiesConfig { + pub gam_network_id: String, + #[serde(default)] + pub auction_timeout_ms: Option, + #[serde(default = "default_price_granularity")] + pub price_granularity: String, + // Use serde_json::Value to avoid pulling in full slot type in build context. + #[serde(default)] + pub slot: Vec, + } + + fn default_price_granularity() -> String { + "dense".to_string() + } +} +``` + +Then replace the separate-file validation block with reading slots from `Settings`: + +```rust +// After settings are parsed, validate slot IDs +let slot_id_re = regex::Regex::new(r"^[A-Za-z0-9_\-]+$").expect("should compile regex"); +if let Some(co) = &settings.creative_opportunities { + for slot in &co.slot { + if let Err(e) = trusted_server_core::creative_opportunities::validate_slot_id(&slot.id) { + panic!("trusted-server.toml [creative_opportunities.slot]: {e}"); + } + } + if !co.slot.is_empty() { + println!( + "cargo:warning=creative_opportunities: {} slot(s) validated", + co.slot.len() + ); + } +} +``` + +Remove: `CREATIVE_OPPORTUNITIES_PATH` const, the `co_path.exists()` block, and the `println!("cargo:rerun-if-changed={}", CREATIVE_OPPORTUNITIES_PATH)` line. + +Note: `build.rs` already pulls in `src/creative_opportunities.rs` as a module — make sure the module stub includes the new `Serialize` derive (it may need the serde `Serialize` import). + +- [ ] **Step 6: Update `main.rs` — remove `SLOTS_FILE` static** + +Remove: +```rust +const CREATIVE_OPPORTUNITIES_TOML: &str = include_str!("../../../creative-opportunities.toml"); +static SLOTS_FILE: std::sync::LazyLock<...> = ...; +``` + +Replace `slots_file` parameter threading with deriving slots from `settings`: + +Where `slots_file` was passed as `&*SLOTS_FILE`, pass `settings.creative_opportunity_slots()` instead. This requires `settings` to be available at that call site (it is — `settings` is already in scope). + +Update function signatures in `main.rs` that reference `CreativeOpportunitiesFile` to accept `&[CreativeOpportunitySlot]` instead. + +- [ ] **Step 7: Update `publisher.rs` function signatures** + +Functions that take `&crate::creative_opportunities::CreativeOpportunitiesFile` change to `&[crate::creative_opportunities::CreativeOpportunitySlot]`: + +```rust +// Before +pub(crate) fn handle_page_bids( + ... + slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + ... +) + +// After +pub(crate) fn handle_page_bids( + ... + slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + ... +) +``` + +Inside the function body, replace `slots_file.slots` with `slots`. + +Update all call sites and test helpers in `publisher.rs` that construct `CreativeOpportunitiesFile { slots: vec![...] }` to pass `&[slot]` directly. + +- [ ] **Step 8: Update `trusted-server.toml`** + +Move the slots from `creative-opportunities.toml` into `trusted-server.toml` under `[creative_opportunities]`. Use `[[creative_opportunities.slot]]` syntax. Use only example/fictional values per project convention (example.com domains, fictional IDs): + +```toml +[creative_opportunities] +gam_network_id = "88059007" +auction_timeout_ms = 1500 +price_granularity = "dense" + +[[creative_opportunities.slot]] +id = "atf_sidebar_ad" +gam_unit_path = "/a/b/news" +div_id = "div-ad-atf-sidebar" +page_patterns = ["/news/**"] +formats = [{ width = 300, height = 250 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "atf" +zone = "atfSidebar" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-atf-sidebar" +``` + +- [ ] **Step 9: Delete `creative-opportunities.toml`** + +```bash +git rm creative-opportunities.toml +``` + +- [ ] **Step 10: Run tests** + +```bash +cargo test --workspace +``` + +Expected: all tests pass. Fix any compile errors from the signature changes. + +- [ ] **Step 11: Run clippy and fmt** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +- [ ] **Step 12: Commit** + +```bash +git add -p +git commit -m "Move slot templates from creative-opportunities.toml into trusted-server.toml" +``` + +--- + +## Task 2: Namespace `window.__ts*` globals under `window._ts` + +**What:** All `window.__ts*` globals become properties on a single `window._ts` namespace object. Changes must be coordinated across three files: `gpt_bootstrap.js`, `index.ts`, and `publisher.rs`. Tests in `index.test.ts` must be updated too. + +**Rename table:** + +| Old global | New property | Notes | +|---|---|---| +| `window.__ts_ad_slots` | `window._ts.adSlots` | Array, set at head-open | +| `window.__ts_bids` | `window._ts.bids` | Object, set before `` | +| `window.__tsAdInit` | `window._ts.adInit` | Function | +| `window.__tsPrevGptSlots` | `window._ts.prevGptSlots` | Array | +| `window.__tsServicesEnabled` | `window._ts.servicesEnabled` | Boolean | +| `window.__tsDivToSlotId` | `window._ts.divToSlotId` | Object | +| `window.__tsSpaHookInstalled` | `window._ts.spaHookInstalled` | Boolean | + +**Files:** +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` +- Modify: `crates/js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/js/lib/src/integrations/gpt/index.test.ts` +- Modify: `crates/js/lib/test/integrations/gpt/index.test.ts` (if exists) + +**Steps:** + +- [ ] **Step 1: Update `publisher.rs` injected scripts** + +`build_ad_slots_script` generates the `", escaped) + +// After — initialise _ts if absent, then set adSlots +format!("", escaped) +``` + +`build_bids_script` generates the script injected before ``. Change: + +```rust +// Before +format!( + "", + escaped +) + +// After +format!( + "", + escaped +) +``` + +Note: `{{}}` is the Rust format-string escape for a literal `{}`. + +Update any test assertions in `publisher.rs` that check for the old global names. + +- [ ] **Step 2: Update `gpt_bootstrap.js`** + +Replace all `window.__ts*` references. The bootstrap IIFE runs before the TS bundle, so it must initialise `window._ts` if absent: + +```js +(function () { + if (typeof window === "undefined") return; + // Initialise namespace; adInit guard prevents double-install. + var ts = (window._ts = window._ts || {}); + if (ts.adInit) return; + + ts.adInit = function () { + var slots = ts.adSlots || []; + var bids = ts.bids || {}; + var divToSlotId = {}; + googletag.cmd.push(function () { + var newSlots = []; + slots.forEach(function (slot) { + var s = googletag.defineSlot(slot.gam_unit_path, slot.formats, slot.div_id); + if (!s) return; + s.addService(googletag.pubads()); + Object.entries(slot.targeting || {}).forEach(function (e) { + s.setTargeting(e[0], e[1]); + }); + var b = bids[slot.id] || {}; + ["hb_pb", "hb_bidder", "hb_adid"].forEach(function (k) { + if (b[k]) s.setTargeting(k, b[k]); + }); + s.setTargeting("ts_initial", "1"); + divToSlotId[slot.div_id] = slot.id; + newSlots.push(s); + }); + ts.prevGptSlots = newSlots; + ts.divToSlotId = divToSlotId; + if (!ts.servicesEnabled) { + googletag.pubads().enableSingleRequest(); + googletag.enableServices(); + ts.servicesEnabled = true; + googletag.pubads().addEventListener("slotRenderEnded", function (ev) { + var divId = ev.slot.getSlotElementId(); + var slotId = (ts.divToSlotId || {})[divId]; + if (!slotId) return; + var b = (ts.bids || {})[slotId] || {}; + var ourBidWon = + !ev.isEmpty && + (b.hb_adid + ? ev.slot.getTargeting("hb_adid")[0] === b.hb_adid + : !!b.hb_bidder); + if (ourBidWon) { + if (b.nurl) navigator.sendBeacon(b.nurl); + if (b.burl) navigator.sendBeacon(b.burl); + } + }); + } + if (newSlots.length > 0) { + googletag.pubads().refresh(newSlots); + } + }); + }; +})(); +``` + +- [ ] **Step 3: Update `index.ts` — rename `TsWindow` type** + +Replace the `TsWindow` interface: + +```typescript +type TsNamespace = { + adSlots?: TsAdSlot[]; + bids?: Record; + adInit?: () => void; + prevGptSlots?: GoogleTagSlot[]; + servicesEnabled?: boolean; + divToSlotId?: Record; + spaHookInstalled?: boolean; +}; + +type TsWindow = Window & { + _ts?: TsNamespace; +}; +``` + +- [ ] **Step 4: Update `installTsAdInit` in `index.ts`** + +Change every `w.__ts*` access to `w._ts.*`. Initialise `w._ts` at function entry: + +```typescript +export function installTsAdInit(): void { + const w = window as TsWindow; + const ts = (w._ts = w._ts ?? {}); + ts.adInit = function () { + const slots = ts.adSlots ?? []; + const bids = ts.bids ?? {}; + const g = (window as GptWindow).googletag; + if (!g) return; + + g.cmd?.push(() => { + if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { + g.destroySlots?.(ts.prevGptSlots); + ts.prevGptSlots = []; + } + const newSlots: GoogleTagSlot[] = []; + const divToSlotId: Record = {}; + + slots.forEach((slot) => { + const gptSlot = g.defineSlot?.(slot.gam_unit_path, slot.formats as Array, slot.div_id); + if (!gptSlot) return; + gptSlot.addService(g.pubads!()); + Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); + const bid = bids[slot.id] ?? {}; + (['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, bid[key]!); + }); + gptSlot.setTargeting('ts_initial', '1'); + divToSlotId[slot.div_id] = slot.id; + newSlots.push(gptSlot); + }); + + ts.prevGptSlots = newSlots; + ts.divToSlotId = divToSlotId; + + if (!ts.servicesEnabled) { + g.pubads!().enableSingleRequest(); + g.enableServices?.(); + ts.servicesEnabled = true; + g.pubads!().addEventListener?.('slotRenderEnded', (event: SlotRenderEndedEvent) => { + const divId: string = event.slot?.getSlotElementId?.() ?? ''; + const slotId = (ts.divToSlotId ?? {})[divId]; + if (!slotId) return; + const bid = (ts.bids ?? {})[slotId] ?? {}; + const ourBidWon = + !event.isEmpty && + (bid.hb_adid + ? event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid + : !!bid.hb_bidder); + if (ourBidWon) { + if (bid.nurl) navigator.sendBeacon(bid.nurl); + if (bid.burl) navigator.sendBeacon(bid.burl); + } + }); + } + if (newSlots.length > 0) { + g.pubads!().refresh(newSlots); + } + }); + }; +} +``` + +- [ ] **Step 5: Update `installSpaHook` in `index.ts`** + +Replace `__tsSpaHookInstalled` and `__ts_ad_slots`/`__ts_bids` reads: + +```typescript +export function installSpaHook(): void { + const win = window as TsWindow; + const ts = (win._ts = win._ts ?? {}); + if (ts.spaHookInstalled) return; + ts.spaHookInstalled = true; + // ... rest of SPA hook logic uses ts.adSlots, ts.bids, ts.adInit +} +``` + +- [ ] **Step 6: Update tests in `index.test.ts`** + +Find all test assertions that reference `window.__ts_ad_slots`, `window.__ts_bids`, `window.__tsAdInit`, etc. and update to `window._ts.adSlots`, `window._ts.bids`, `window._ts.adInit` etc. + +Run tests first to see what fails: + +```bash +cd crates/js/lib && npx vitest run +``` + +Fix each failing assertion. + +- [ ] **Step 7: Run JS tests and format** + +```bash +cd crates/js/lib && npx vitest run +cd crates/js/lib && npm run format +``` + +Expected: all tests pass, no format errors. + +- [ ] **Step 8: Run Rust tests** + +```bash +cargo test --workspace +``` + +Update any test assertions in `publisher.rs` that check for old global names (e.g. `script.contains("window.__ts_ad_slots")`). + +- [ ] **Step 9: Run clippy and fmt** + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +- [ ] **Step 10: Commit** + +```bash +git commit -m "Namespace window globals under window._ts" +``` + +--- + +## Task 3: Fix `formats` type and extract `ts_initial` constant + +**What:** Two small TypeScript/JS cleanups. `TsAdSlot.formats` should be typed as `Array<[number, number]>` (tuple, not array-of-array) to match GPT's actual input. The string `'ts_initial'` is hardcoded in both `gpt_bootstrap.js` and `index.ts` — extract as a named constant in `index.ts` (no JS equivalent needed since the bootstrap is vanilla JS). + +**Files:** +- Modify: `crates/js/lib/src/integrations/gpt/index.ts` +- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` (comment only — JS can't share TS constants) + +**Steps:** + +- [ ] **Step 1: Fix `TsAdSlot.formats` type** + +In `index.ts`, change: + +```typescript +// Before +interface TsAdSlot { + ... + formats: Array; +} + +// After +interface TsAdSlot { + ... + formats: Array<[number, number]>; +} +``` + +Update the cast at the GPT `defineSlot` call site — `[number, number]` satisfies `number | number[]` so the cast can be removed or simplified: + +```typescript +// Before +slot.formats as Array + +// After — [number, number][] already satisfies Array +slot.formats +``` + +- [ ] **Step 2: Extract `ts_initial` constant in `index.ts`** + +Near the top of `index.ts`, add: + +```typescript +const TS_INITIAL_TARGETING_KEY = 'ts_initial'; +``` + +Replace both occurrences of `'ts_initial'` in `installTsAdInit` with `TS_INITIAL_TARGETING_KEY`. + +Add a comment in `gpt_bootstrap.js` where `'ts_initial'` appears: + +```js +// Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts +s.setTargeting("ts_initial", "1"); +``` + +- [ ] **Step 3: Run JS tests and format** + +```bash +cd crates/js/lib && npx vitest run +cd crates/js/lib && npm run format +``` + +- [ ] **Step 4: Commit** + +```bash +git commit -m "Fix TsAdSlot formats type and extract ts_initial constant" +``` + +--- + +## Final verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace` +- [ ] `cd crates/js/lib && npx vitest run` +- [ ] `cd crates/js/lib && npm run format` +- [ ] `cd docs && npm run format` diff --git a/trusted-server.toml b/trusted-server.toml index 899c8c89..438ab682 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -245,3 +245,48 @@ gam_network_id = "88059007" auction_timeout_ms = 1500 price_granularity = "dense" +[[creative_opportunities.slot]] +id = "atf_sidebar_ad" +gam_unit_path = "/a/b/news" +div_id = "ad-atf_sidebar-0-_r_2_" +page_patterns = ["/20**", "/news/**"] +formats = [{ width = 300, height = 250 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "atf" +zone = "atfSidebar" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-atf-sidebar" + +[[creative_opportunities.slot]] +id = "homepage_header_ad" +gam_unit_path = "/a/b/homepage" +div_id = "ad-header-0-_R_jpalubtak5lb_" +page_patterns = ["/"] +formats = [{ width = 970, height = 90 }, { width = 728, height = 90 }, { width = 970, height = 250 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "atf" +zone = "header" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-homepage-header" + +[[creative_opportunities.slot]] +id = "homepage_footer_ad" +gam_unit_path = "/a/b/homepage" +div_id = "ad-fixed_bottom-0-_R_klubtak5lb_" +page_patterns = ["/"] +formats = [{ width = 728, height = 90 }] +floor_price = 0.50 + +[creative_opportunities.slot.targeting] +pos = "btf" +zone = "fixedBottom" + +[creative_opportunities.slot.providers.aps] +slot_id = "aps-slot-homepage-footer" + From 9fe0f344a93a68c2e117bb8e565666398c5d3aa1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 29 May 2026 19:21:01 +0530 Subject: [PATCH 02/18] Namespace window globals under window._ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 7 flat `window.__ts*` globals with properties on a single `window._ts` namespace object: window.__ts_ad_slots → window._ts.adSlots window.__ts_bids → window._ts.bids window.__tsAdInit → window._ts.adInit window.__tsPrevGptSlots → window._ts.prevGptSlots window.__tsServicesEnabled → window._ts.servicesEnabled window.__tsDivToSlotId → window._ts.divToSlotId window.__tsSpaHookInstalled → window._ts.spaHookInstalled Both publisher.rs injected scripts and the JS/TS bundle now initialise `window._ts` with `||{}` before accessing any property, so the bootstrap script and TSJS bundle are safe to run in either order. --- .../js/lib/src/integrations/gpt/index.test.ts | 222 +++++++++--------- crates/js/lib/src/integrations/gpt/index.ts | 65 ++--- .../src/auction/endpoints.rs | 2 +- .../trusted-server-core/src/html_processor.rs | 59 +++-- .../src/integrations/gpt.rs | 23 +- .../src/integrations/gpt_bootstrap.js | 52 ++-- crates/trusted-server-core/src/publisher.rs | 20 +- 7 files changed, 232 insertions(+), 211 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.test.ts b/crates/js/lib/src/integrations/gpt/index.test.ts index 87455591..7c5a19f2 100644 --- a/crates/js/lib/src/integrations/gpt/index.test.ts +++ b/crates/js/lib/src/integrations/gpt/index.test.ts @@ -8,27 +8,27 @@ interface SlotRenderEvent { }; } +type TsNamespace = { + adSlots?: unknown; + bids?: unknown; + adInit?: () => void; + prevGptSlots?: unknown; + servicesEnabled?: boolean; + spaHookInstalled?: boolean; + divToSlotId?: Record; +}; + type TestWindow = Window & { googletag?: unknown; - __ts_ad_slots?: unknown; - __ts_bids?: unknown; - __tsAdInit?: () => void; - __tsPrevGptSlots?: unknown; - __tsServicesEnabled?: boolean; - __tsSpaHookInstalled?: boolean; - __tsDivToSlotId?: Record; + _ts?: TsNamespace; }; describe('installTsAdInit', () => { beforeEach(() => { vi.resetModules(); - delete (window as TestWindow).__ts_ad_slots; - delete (window as TestWindow).__ts_bids; - delete (window as TestWindow).__tsAdInit; - delete (window as TestWindow).__tsPrevGptSlots; - delete (window as TestWindow).__tsSpaHookInstalled; - delete (window as TestWindow).__tsDivToSlotId; - (window as TestWindow).__tsServicesEnabled = false; + const tw = window as TestWindow; + delete tw._ts; + (tw._ts as TsNamespace | undefined) = undefined; // jsdom does not implement navigator.sendBeacon; polyfill it for tests if (!('sendBeacon' in navigator)) { Object.defineProperty(navigator, 'sendBeacon', { @@ -39,7 +39,7 @@ describe('installTsAdInit', () => { } }); - it('reads window.__ts_bids synchronously and applies bid targeting before refresh', async () => { + it('reads window._ts.bids synchronously and applies bid targeting before refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), @@ -57,22 +57,24 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: { pos: 'atf' }, - }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', + (window as TestWindow)._ts = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, }, }; @@ -80,7 +82,7 @@ describe('installTsAdInit', () => { const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow)._ts!.adInit!(); expect(fetchSpy).not.toHaveBeenCalled(); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); @@ -114,28 +116,30 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', + (window as TestWindow)._ts = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow)._ts!.adInit!(); expect(capturedListener).toBeDefined(); capturedListener!({ isEmpty: false, slot: mockSlot }); @@ -168,27 +172,29 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.50', - hb_bidder: 'aps', - nurl: 'https://aps/win', - burl: 'https://aps/bill', + (window as TestWindow)._ts = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.50', + hb_bidder: 'aps', + nurl: 'https://aps/win', + burl: 'https://aps/bill', + }, }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow)._ts!.adInit!(); expect(capturedListener).toBeDefined(); capturedListener!({ isEmpty: false, slot: mockSlot }); @@ -226,28 +232,30 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - nurl: 'https://ssp/win', - burl: 'https://ssp/bill', + (window as TestWindow)._ts = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow)._ts!.adInit!(); capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }); expect(beaconSpy).not.toHaveBeenCalled(); @@ -281,22 +289,24 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, + (window as TestWindow)._ts = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: { + atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc' }, }, - ]; - (window as TestWindow).__ts_bids = { - atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', hb_adid: 'abc' }, }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow)._ts!.adInit!(); capturedListener!({ isEmpty: false, slot: arenaSlot }); @@ -304,7 +314,7 @@ describe('installTsAdInit', () => { beaconSpy.mockRestore(); }); - it('calls refresh even when __ts_bids is empty (graceful fallback)', async () => { + it('calls refresh even when _ts.bids is empty (graceful fallback)', async () => { const mockPubads = { enableSingleRequest: vi.fn(), addEventListener: vi.fn(), @@ -319,20 +329,22 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as TestWindow).__ts_ad_slots = [ - { - id: 'atf_sidebar_ad', - gam_unit_path: '/123/atf', - div_id: 'div-atf-sidebar', - formats: [[300, 250]], - targeting: {}, - }, - ]; - (window as TestWindow).__ts_bids = {}; + (window as TestWindow)._ts = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: {}, + }, + ], + bids: {}, + }; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as TestWindow).__tsAdInit!(); + (window as TestWindow)._ts!.adInit!(); expect(mockPubads.refresh).toHaveBeenCalled(); }); diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index e1a1ee26..629b9b2b 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -199,40 +199,46 @@ interface TsBidData { burl?: string; } +type TsNamespace = { + adSlots?: TsAdSlot[]; + bids?: Record; + adInit?: () => void; + prevGptSlots?: GoogleTagSlot[]; + servicesEnabled?: boolean; + divToSlotId?: Record; + spaHookInstalled?: boolean; +}; + type TsWindow = Window & { - __ts_ad_slots?: TsAdSlot[]; - __ts_bids?: Record; - __tsAdInit?: () => void; - __tsPrevGptSlots?: GoogleTagSlot[]; - __tsServicesEnabled?: boolean; - __tsDivToSlotId?: Record; + _ts?: TsNamespace; }; /** - * Install `window.__tsAdInit`. + * Install `window._ts.adInit`. * - * Reads `window.__ts_ad_slots` (injected at head-open) and `window.__ts_bids` + * Reads `window._ts.adSlots` (injected at head-open) and `window._ts.bids` * (injected before ) synchronously — no fetch, no Promise. Applies bid * targeting to GPT slots, sets the `ts_initial` sentinel, registers * `slotRenderEnded` to fire both nurl and burl via sendBeacon when our * specific Prebid bid wins the GAM line item match, then calls refresh(). * * Idempotent: destroys previously created TS-managed slots before redefining them, - * so it is safe to call again after SPA navigation updates `__ts_ad_slots`/`__ts_bids`. + * so it is safe to call again after SPA navigation updates `_ts.adSlots`/`_ts.bids`. */ export function installTsAdInit(): void { const w = window as TsWindow; - w.__tsAdInit = function () { - const slots = w.__ts_ad_slots ?? []; - const bids = w.__ts_bids ?? {}; + const ts = (w._ts = w._ts ?? {}); + ts.adInit = function () { + const slots = ts.adSlots ?? []; + const bids = ts.bids ?? {}; const g = (window as GptWindow).googletag; if (!g) return; g.cmd?.push(() => { // Destroy previously defined TS slots before redefining for the new page. - if (w.__tsPrevGptSlots && w.__tsPrevGptSlots.length > 0) { - g.destroySlots?.(w.__tsPrevGptSlots); - w.__tsPrevGptSlots = []; + if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { + g.destroySlots?.(ts.prevGptSlots); + ts.prevGptSlots = []; } const newSlots: GoogleTagSlot[] = []; @@ -256,21 +262,21 @@ export function installTsAdInit(): void { newSlots.push(gptSlot); }); - w.__tsPrevGptSlots = newSlots; + ts.prevGptSlots = newSlots; // Replace (not merge) so destroyed slots from previous navigation don't linger. - w.__tsDivToSlotId = divToSlotId; + ts.divToSlotId = divToSlotId; // enableSingleRequest and enableServices must only be called once per page load. - if (!w.__tsServicesEnabled) { + if (!ts.servicesEnabled) { g.pubads!().enableSingleRequest(); g.enableServices?.(); - w.__tsServicesEnabled = true; + ts.servicesEnabled = true; g.pubads!().addEventListener?.('slotRenderEnded', (event: SlotRenderEndedEvent) => { const divId: string = event.slot?.getSlotElementId?.() ?? ''; - const slotId = (w.__tsDivToSlotId ?? {})[divId]; + const slotId = (ts.divToSlotId ?? {})[divId]; if (!slotId) return; - const bid = (w.__ts_bids ?? {})[slotId] ?? {}; + const bid = (ts.bids ?? {})[slotId] ?? {}; // Prebid: compare hb_adid targeting to verify the specific creative won. // APS: no hb_adid equivalent — fires if bidder exists and slot is non-empty. // Known limitation: APS path may over-fire if a non-APS line item wins. @@ -304,15 +310,16 @@ interface PageBidsResponse { * Patches `history.pushState` and `history.replaceState`, and listens to * `popstate`, so that after each client-side route change the trusted server * fetches fresh slots + bids from `/__ts/page-bids?path=`, updates - * `window.__ts_ad_slots` / `window.__ts_bids`, and calls `window.__tsAdInit()`. + * `window._ts.adSlots` / `window._ts.bids`, and calls `window._ts.adInit()`. * - * Idempotent: guarded by `window.__tsSpaHookInstalled` so multiple calls are safe. + * Idempotent: guarded by `window._ts.spaHookInstalled` so multiple calls are safe. */ export function installSpaAuctionHook(): void { if (typeof window === 'undefined') return; - const win = window as TsWindow & { __tsSpaHookInstalled?: boolean }; - if (win.__tsSpaHookInstalled) return; - win.__tsSpaHookInstalled = true; + const win = window as TsWindow; + const ts = (win._ts = win._ts ?? {}); + if (ts.spaHookInstalled) return; + ts.spaHookInstalled = true; let inflight: AbortController | null = null; @@ -329,9 +336,9 @@ export function installSpaAuctionHook(): void { if (!res.ok) return; const data = (await res.json()) as PageBidsResponse; if (inflight !== controller) return; - win.__ts_ad_slots = data.slots; - win.__ts_bids = data.bids; - win.__tsAdInit?.(); + ts.adSlots = data.slots; + ts.bids = data.bids; + ts.adInit?.(); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; log.warn('SPA auction hook: fetch failed', err); diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 22fc11e8..6030b75e 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -64,7 +64,7 @@ use super::AuctionOrchestrator; /// **SPA navigation** is handled by `GET /__ts/page-bids`: the client-side SPA /// hook (`installSpaAuctionHook`) intercepts `pushState`/`replaceState`/`popstate` /// events and calls that endpoint to fetch fresh slots and bids for each new -/// route, then invokes `window.__tsAdInit()` with the updated data. +/// route, then invokes `window._ts.adInit()` with the updated data. /// /// **Scroll and GPT refresh** are owned by slim-Prebid in Phase 1: it runs /// post-`window.load`, listens for GPT refresh events, and runs client-side diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 6005e3cc..e6944fc6 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -140,12 +140,12 @@ pub struct HtmlProcessorConfig { pub request_host: String, pub request_scheme: String, pub integrations: IntegrationRegistry, - /// Pre-computed ``. + /// Pre-computed ``. /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, /// Shared auction result — written by auction task before HTML processing begins. /// Handler reads this in `el.on_end_tag()` on the body element. - /// `None` means no auction ran; inject empty `__ts_bids = {}` as fallback. + /// `None` means no auction ran; inject empty `_ts.bids = {}` as fallback. pub ad_bids_state: std::sync::Arc>>, } @@ -311,10 +311,10 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso Ok(()) } }), - // Inject __ts_bids before via end_tag_handlers — only when + // Inject _ts.bids before via end_tag_handlers — only when // slots matched this URL. When no slots matched, skip injection entirely // so the publisher's existing client-side Prebid/GPT flow is unmodified - // (dual-mode rollout: calling __tsAdInit with empty slots would invoke + // (dual-mode rollout: calling _ts.adInit with empty slots would invoke // enableSingleRequest/enableServices and conflict with the publisher's GPT init). // Guard with AtomicBool so the script is only injected once even if // the origin HTML contains multiple elements (e.g. template fragments). @@ -1278,7 +1278,8 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: Some( - r#""#.to_string(), + r#""# + .to_string(), ), ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), }; @@ -1291,8 +1292,12 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains("window.__ts_ad_slots"), - "should inject ad slots at head-open" + html.contains("window._ts=window._ts||{}"), + "should inject ad slots namespace at head-open" + ); + assert!( + html.contains(".adSlots=JSON.parse"), + "should inject adSlots at head-open" ); assert!( !html.contains("__ts_request_id"), @@ -1302,15 +1307,16 @@ mod tests { #[test] fn injects_ts_bids_before_body_close() { - let bids_script = - r#""#; + let bids_script = r#""#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: Some("".to_string()), + ad_slots_script: Some( + r#""#.to_string(), + ), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1319,27 +1325,32 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains("window.__ts_bids"), + html.contains("window._ts=window._ts||{}"), + "should inject _ts namespace for bids before " + ); + assert!( + html.contains(".bids=JSON.parse"), "should inject bids before " ); let bids_pos = html - .find("window.__ts_bids") - .expect("bids should be in output"); + .find("window._ts=window._ts||{}") + .expect("bids namespace should be in output"); let body_close_pos = html.find("").expect(" should be in output"); assert!(bids_pos < body_close_pos, "bids must appear before "); } #[test] fn injects_ts_bids_only_once_with_multiple_body_elements() { - let bids_script = - r#""#; + let bids_script = r#""#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: Some("".to_string()), + ad_slots_script: Some( + r#""#.to_string(), + ), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1349,9 +1360,9 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert_eq!( - html.matches("window.__ts_bids").count(), + html.matches(".bids=JSON.parse").count(), 1, - "should inject __ts_bids exactly once even with multiple elements" + "should inject _ts.bids exactly once even with multiple elements" ); } @@ -1365,7 +1376,9 @@ mod tests { request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), - ad_slots_script: Some("".to_string()), + ad_slots_script: Some( + r#""#.to_string(), + ), ad_bids_state: state, }; let mut processor = create_html_processor(config); @@ -1374,14 +1387,14 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains("__ts_bids=JSON.parse(\"{}\")"), + html.contains(".bids=JSON.parse(\"{}\")"), "should inject empty bids fallback when auction produced nothing" ); } #[test] fn does_not_inject_ts_bids_when_no_slots_matched() { - // No slots matched this URL — ad_slots_script is None. __ts_bids must be + // No slots matched this URL — ad_slots_script is None. _ts.bids must be // omitted entirely so the publisher's existing client-side GPT flow is // unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified"). let state = std::sync::Arc::new(std::sync::Mutex::new(None)); @@ -1399,8 +1412,8 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - !html.contains("__ts_bids"), - "should NOT inject __ts_bids when no slots matched" + !html.contains(".bids=JSON.parse"), + "should NOT inject _ts.bids when no slots matched" ); } } diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index cb099402..a18e5123 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -437,11 +437,11 @@ impl IntegrationHeadInjector for GptIntegration { GPT_INTEGRATION_ID } - /// Injects the `__tsAdInit` bootstrap script into ``. + /// Injects the `_ts.adInit` bootstrap script into ``. /// /// ## Scroll / refresh handoff contract (Phase 1) /// - /// `__tsAdInit` handles **initial render only**: it wires server-side bid + /// `_ts.adInit` handles **initial render only**: it wires server-side bid /// targeting into GPT slots and fires win beacons (`nurl`/`burl`) via /// `slotRenderEnded`. It does **not** trigger refresh auctions or handle /// GPT slot refresh events. @@ -460,13 +460,13 @@ impl IntegrationHeadInjector for GptIntegration { } } -/// Inline `window.__tsAdInit` bootstrap injected at `` so the bids +/// Inline `window._ts.adInit` bootstrap injected at `` so the bids /// script at `` can call it before the TSJS bundle has loaded. /// /// The bundle's idempotent implementation in /// `crates/js/lib/src/integrations/gpt/index.ts` later overwrites this stub. /// Both implementations guard the one-time-per-page setup with -/// `window.__tsServicesEnabled` so neither double-enables services if the +/// `window._ts.servicesEnabled` so neither double-enables services if the /// publisher's own init code also calls `googletag.enableServices()`. const GPT_BOOTSTRAP_JS: &str = include_str!("gpt_bootstrap.js"); @@ -1062,10 +1062,10 @@ mod tests { }; let inserts = integration.head_inserts(&ctx); let combined = inserts.join(""); - assert!(combined.contains("__tsAdInit"), "should define __tsAdInit"); + assert!(combined.contains("ts.adInit"), "should define _ts.adInit"); assert!( - combined.contains("window.__ts_bids"), - "should read window.__ts_bids synchronously" + combined.contains("ts.bids"), + "should read _ts.bids synchronously" ); assert!( combined.contains("ts_initial"), @@ -1110,13 +1110,10 @@ mod tests { }; let combined = integration.head_inserts(&ctx).join(""); assert!( - combined.contains("__tsServicesEnabled"), - "should guard enableServices/enableSingleRequest with the __tsServicesEnabled flag" - ); - assert!( - combined.contains("window.__tsAdInit"), - "should install __tsAdInit on window" + combined.contains("ts.servicesEnabled"), + "should guard enableServices/enableSingleRequest with the _ts.servicesEnabled flag" ); + assert!(combined.contains("ts.adInit"), "should install _ts.adInit"); assert!( !combined.contains("googletag.pubads().refresh()"), "should never call unbounded refresh() — only refresh(newSlots)" diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 85109d72..08bb366c 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -1,30 +1,27 @@ // Edge-injected GPT auction bootstrap. // -// This is the minimal `window.__tsAdInit` that runs on first page load +// This is the minimal `window._ts.adInit` that runs on first page load // before the TSJS bundle has had a chance to install its richer // idempotent implementation. The bundle in -// crates/js/lib/src/integrations/gpt/index.ts overwrites `__tsAdInit` +// crates/js/lib/src/integrations/gpt/index.ts overwrites `_ts.adInit` // once it loads. // // Contract with the bundle: -// - Both implementations must set `window.__tsServicesEnabled = true` +// - Both implementations must set `window._ts.servicesEnabled = true` // after calling `enableSingleRequest()`/`enableServices()` so a -// subsequent call from any source (the bundle's `__tsAdInit`, the -// publisher's own GPT init code) becomes a no-op. +// subsequent call becomes a no-op. // - `refresh()` is called only for the slots defined in this pass, -// never the global slot list, so we never accidentally refresh -// publisher-managed slots that we don't own. +// never the global slot list. // -// Only installed if `window.__tsAdInit` isn't already defined — that -// way the bundle (or anything else) can preempt this fallback by -// installing first. +// Only installed if `window._ts.adInit` isn't already defined. (function () { - if (typeof window === "undefined" || window.__tsAdInit) { - return; - } - window.__tsAdInit = function () { - var slots = window.__ts_ad_slots || []; - var bids = window.__ts_bids || {}; + if (typeof window === "undefined") return; + var ts = (window._ts = window._ts || {}); + if (ts.adInit) return; + + ts.adInit = function () { + var slots = ts.adSlots || []; + var bids = ts.bids || {}; var divToSlotId = {}; googletag.cmd.push(function () { var newSlots = []; @@ -43,33 +40,24 @@ ["hb_pb", "hb_bidder", "hb_adid"].forEach(function (k) { if (b[k]) s.setTargeting(k, b[k]); }); + // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); divToSlotId[slot.div_id] = slot.id; newSlots.push(s); }); - // Expose slot metadata on window so later calls (SPA navigation, - // the bundle's __tsAdInit) can destroy stale slots and the render - // listener can resolve slot IDs after navigation updates these maps. - window.__tsPrevGptSlots = newSlots; - window.__tsDivToSlotId = divToSlotId; - // Guard the one-time-per-page setup so a follow-up call (e.g. - // publisher's own init code or the bundle's `__tsAdInit` after - // it overwrites this stub) doesn't double-enable services. - if (!window.__tsServicesEnabled) { + ts.prevGptSlots = newSlots; + ts.divToSlotId = divToSlotId; + if (!ts.servicesEnabled) { googletag.pubads().enableSingleRequest(); googletag.enableServices(); - window.__tsServicesEnabled = true; + ts.servicesEnabled = true; googletag .pubads() .addEventListener("slotRenderEnded", function (ev) { var divId = ev.slot.getSlotElementId(); - // Read from window so SPA navigation updates are picked up; - // early-return for slots not managed by Trusted Server. - var slotId = (window.__tsDivToSlotId || {})[divId]; + var slotId = (ts.divToSlotId || {})[divId]; if (!slotId) return; - var b = (window.__ts_bids || {})[slotId] || {}; - // Prebid: verify the specific creative via hb_adid targeting. - // APS: no hb_adid — fire if any TS bidder is present and slot is non-empty. + var b = (ts.bids || {})[slotId] || {}; var ourBidWon = !ev.isEmpty && (b.hb_adid diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4d955273..6e82b2e8 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -428,7 +428,7 @@ pub struct OwnedProcessResponseParams { /// The streaming phase collects these and writes bids to `ad_bids_state` /// before processing the last body chunk, so `` injection sees live bids. pub(crate) dispatched_auction: Option, - /// Price granularity used to bucket bids when building `__ts_bids`. + /// Price granularity used to bucket bids when building `_ts.bids`. pub(crate) price_granularity: PriceGranularity, } @@ -1341,7 +1341,7 @@ pub(crate) fn build_bid_map( .collect() } -/// Build the `__ts_bids` `` sequences inside the string. @@ -1350,7 +1350,7 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map should be infallible"); let escaped = html_escape_for_script(&json); format!( - "", + "", escaped ) } @@ -1363,7 +1363,7 @@ pub(crate) fn build_empty_bids_script() -> String { build_bids_script(&serde_json::Map::new()) } -/// Build the `__ts_ad_slots` `", + "", escaped ) } @@ -2635,11 +2635,15 @@ mod tests { let config = make_config(); let script = build_ad_slots_script(&slots, &config); assert!( - script.contains("window.__ts_ad_slots=JSON.parse"), - "should use JSON.parse" + script.contains("window._ts=window._ts||{}"), + "should initialise _ts namespace" + ); + assert!( + script.contains(".adSlots=JSON.parse"), + "should use JSON.parse for adSlots" ); assert!(script.contains("atf_sidebar_ad"), "should include slot id"); - assert!(!script.contains("__ts_bids"), "must NOT contain bids"); + assert!(!script.contains("adInit"), "must NOT contain adInit"); assert!( !script.contains("__ts_request_id"), "must NOT contain request_id" From cac4fd06a5cf4a03a53aeb903fbcf26a4d48b807 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 29 May 2026 19:33:37 +0530 Subject: [PATCH 03/18] Fix ts.bids comment and test teardown in GPT globals rename Add clarifying comments around the ts.bids snapshot vs live-read distinction in installTsAdInit() so the intentional SPA correctness design is self-documenting. Remove the erroneous re-assignment of tw._ts after delete in beforeEach so '_ts' in window is reliably false between tests. --- crates/js/lib/src/integrations/gpt/index.test.ts | 1 - crates/js/lib/src/integrations/gpt/index.ts | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/js/lib/src/integrations/gpt/index.test.ts b/crates/js/lib/src/integrations/gpt/index.test.ts index 7c5a19f2..ec0b1814 100644 --- a/crates/js/lib/src/integrations/gpt/index.test.ts +++ b/crates/js/lib/src/integrations/gpt/index.test.ts @@ -28,7 +28,6 @@ describe('installTsAdInit', () => { vi.resetModules(); const tw = window as TestWindow; delete tw._ts; - (tw._ts as TsNamespace | undefined) = undefined; // jsdom does not implement navigator.sendBeacon; polyfill it for tests if (!('sendBeacon' in navigator)) { Object.defineProperty(navigator, 'sendBeacon', { diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 629b9b2b..b6433c96 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -230,6 +230,9 @@ export function installTsAdInit(): void { const ts = (w._ts = w._ts ?? {}); ts.adInit = function () { const slots = ts.adSlots ?? []; + // Snapshot bids at adInit() call time — correct for targeting setup. + // The slotRenderEnded listener below reads ts.bids live so SPA navigation + // updates (new ts.bids injected before ) are picked up at render time. const bids = ts.bids ?? {}; const g = (window as GptWindow).googletag; if (!g) return; @@ -276,6 +279,7 @@ export function installTsAdInit(): void { const divId: string = event.slot?.getSlotElementId?.() ?? ''; const slotId = (ts.divToSlotId ?? {})[divId]; if (!slotId) return; + // Read ts.bids live (not the snapshot above) so post-navigation bid data is used. const bid = (ts.bids ?? {})[slotId] ?? {}; // Prebid: compare hb_adid targeting to verify the specific creative won. // APS: no hb_adid equivalent — fires if bidder exists and slot is non-empty. From 9aa7e896e72139a14c02ae00de4711bf99053e37 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 29 May 2026 19:36:28 +0530 Subject: [PATCH 04/18] Fix TsAdSlot formats type and extract ts_initial constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Narrow `formats` from `Array` to `Array<[number, number]>` so each format is statically typed as exactly width + height. - Remove the now-unnecessary `as Array` cast at the `defineSlot` call site — `[number, number]` satisfies `number[]` which satisfies `number | number[]`. - Extract `TS_INITIAL_TARGETING_KEY = 'ts_initial' as const` and use it in the `setTargeting` call; the bootstrap JS already carries the "Keep in sync with TS_INITIAL_TARGETING_KEY" comment. --- crates/js/lib/src/integrations/gpt/index.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index b6433c96..985cd7e0 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -23,6 +23,8 @@ import { installGptGuard } from './script_guard'; * - Rewrite ad-unit paths for A/B testing. */ +const TS_INITIAL_TARGETING_KEY = 'ts_initial' as const; + // ------------------------------------------------------------------ // googletag type stubs (minimal surface needed by the shim) // ------------------------------------------------------------------ @@ -187,7 +189,7 @@ interface TsAdSlot { id: string; gam_unit_path: string; div_id: string; - formats: Array; + formats: Array<[number, number]>; targeting: Record; } @@ -248,11 +250,7 @@ export function installTsAdInit(): void { const divToSlotId: Record = {}; slots.forEach((slot) => { - const gptSlot = g.defineSlot?.( - slot.gam_unit_path, - slot.formats as Array, - slot.div_id - ); + const gptSlot = g.defineSlot?.(slot.gam_unit_path, slot.formats, slot.div_id); if (!gptSlot) return; gptSlot.addService(g.pubads!()); Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); @@ -260,7 +258,7 @@ export function installTsAdInit(): void { (['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { if (bid[key]) gptSlot.setTargeting(key, bid[key]!); }); - gptSlot.setTargeting('ts_initial', '1'); + gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); divToSlotId[slot.div_id] = slot.id; newSlots.push(gptSlot); }); From 2fa3eb84cce9e2bf6cff87c3bd16f5d89cc97ed9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 29 May 2026 19:47:25 +0530 Subject: [PATCH 05/18] Fix prettier formatting in reviewer findings plan doc --- .../2026-05-29-pr680-reviewer-findings.md | 237 ++++++++++-------- 1 file changed, 127 insertions(+), 110 deletions(-) diff --git a/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md b/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md index c65c0f1a..480151d9 100644 --- a/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md +++ b/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md @@ -26,6 +26,7 @@ **What:** Delete `creative-opportunities.toml`. Move `[[slot]]` arrays into `trusted-server.toml` as `[[creative_opportunities.slot]]`. Wire the `vec_from_seq_or_map` deserializer so env var JSON blobs also work. Remove the `SLOTS_FILE` static and `include_str!` from `main.rs`. Update `build.rs` to validate slot IDs from settings instead of a separate file. **Files:** + - Modify: `crates/trusted-server-core/src/creative_opportunities.rs` - Modify: `crates/trusted-server-core/src/settings.rs` - Modify: `crates/trusted-server-adapter-fastly/src/main.rs` @@ -178,6 +179,7 @@ Note: `build.rs` already pulls in `src/creative_opportunities.rs` as a module - [ ] **Step 6: Update `main.rs` — remove `SLOTS_FILE` static** Remove: + ```rust const CREATIVE_OPPORTUNITIES_TOML: &str = include_str!("../../../creative-opportunities.toml"); static SLOTS_FILE: std::sync::LazyLock<...> = ...; @@ -275,17 +277,18 @@ git commit -m "Move slot templates from creative-opportunities.toml into trusted **Rename table:** -| Old global | New property | Notes | -|---|---|---| -| `window.__ts_ad_slots` | `window._ts.adSlots` | Array, set at head-open | -| `window.__ts_bids` | `window._ts.bids` | Object, set before `` | -| `window.__tsAdInit` | `window._ts.adInit` | Function | -| `window.__tsPrevGptSlots` | `window._ts.prevGptSlots` | Array | -| `window.__tsServicesEnabled` | `window._ts.servicesEnabled` | Boolean | -| `window.__tsDivToSlotId` | `window._ts.divToSlotId` | Object | -| `window.__tsSpaHookInstalled` | `window._ts.spaHookInstalled` | Boolean | +| Old global | New property | Notes | +| ----------------------------- | ----------------------------- | ---------------------------- | +| `window.__ts_ad_slots` | `window._ts.adSlots` | Array, set at head-open | +| `window.__ts_bids` | `window._ts.bids` | Object, set before `` | +| `window.__tsAdInit` | `window._ts.adInit` | Function | +| `window.__tsPrevGptSlots` | `window._ts.prevGptSlots` | Array | +| `window.__tsServicesEnabled` | `window._ts.servicesEnabled` | Boolean | +| `window.__tsDivToSlotId` | `window._ts.divToSlotId` | Object | +| `window.__tsSpaHookInstalled` | `window._ts.spaHookInstalled` | Boolean | **Files:** + - Modify: `crates/trusted-server-core/src/publisher.rs` - Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` - Modify: `crates/js/lib/src/integrations/gpt/index.ts` @@ -331,61 +334,65 @@ Update any test assertions in `publisher.rs` that check for the old global names Replace all `window.__ts*` references. The bootstrap IIFE runs before the TS bundle, so it must initialise `window._ts` if absent: ```js -(function () { - if (typeof window === "undefined") return; +;(function () { + if (typeof window === 'undefined') return // Initialise namespace; adInit guard prevents double-install. - var ts = (window._ts = window._ts || {}); - if (ts.adInit) return; + var ts = (window._ts = window._ts || {}) + if (ts.adInit) return ts.adInit = function () { - var slots = ts.adSlots || []; - var bids = ts.bids || {}; - var divToSlotId = {}; + var slots = ts.adSlots || [] + var bids = ts.bids || {} + var divToSlotId = {} googletag.cmd.push(function () { - var newSlots = []; + var newSlots = [] slots.forEach(function (slot) { - var s = googletag.defineSlot(slot.gam_unit_path, slot.formats, slot.div_id); - if (!s) return; - s.addService(googletag.pubads()); + var s = googletag.defineSlot( + slot.gam_unit_path, + slot.formats, + slot.div_id + ) + if (!s) return + s.addService(googletag.pubads()) Object.entries(slot.targeting || {}).forEach(function (e) { - s.setTargeting(e[0], e[1]); - }); - var b = bids[slot.id] || {}; - ["hb_pb", "hb_bidder", "hb_adid"].forEach(function (k) { - if (b[k]) s.setTargeting(k, b[k]); - }); - s.setTargeting("ts_initial", "1"); - divToSlotId[slot.div_id] = slot.id; - newSlots.push(s); - }); - ts.prevGptSlots = newSlots; - ts.divToSlotId = divToSlotId; + s.setTargeting(e[0], e[1]) + }) + var b = bids[slot.id] || {} + ;['hb_pb', 'hb_bidder', 'hb_adid'].forEach(function (k) { + if (b[k]) s.setTargeting(k, b[k]) + }) + s.setTargeting('ts_initial', '1') + divToSlotId[slot.div_id] = slot.id + newSlots.push(s) + }) + ts.prevGptSlots = newSlots + ts.divToSlotId = divToSlotId if (!ts.servicesEnabled) { - googletag.pubads().enableSingleRequest(); - googletag.enableServices(); - ts.servicesEnabled = true; - googletag.pubads().addEventListener("slotRenderEnded", function (ev) { - var divId = ev.slot.getSlotElementId(); - var slotId = (ts.divToSlotId || {})[divId]; - if (!slotId) return; - var b = (ts.bids || {})[slotId] || {}; + googletag.pubads().enableSingleRequest() + googletag.enableServices() + ts.servicesEnabled = true + googletag.pubads().addEventListener('slotRenderEnded', function (ev) { + var divId = ev.slot.getSlotElementId() + var slotId = (ts.divToSlotId || {})[divId] + if (!slotId) return + var b = (ts.bids || {})[slotId] || {} var ourBidWon = !ev.isEmpty && (b.hb_adid - ? ev.slot.getTargeting("hb_adid")[0] === b.hb_adid - : !!b.hb_bidder); + ? ev.slot.getTargeting('hb_adid')[0] === b.hb_adid + : !!b.hb_bidder) if (ourBidWon) { - if (b.nurl) navigator.sendBeacon(b.nurl); - if (b.burl) navigator.sendBeacon(b.burl); + if (b.nurl) navigator.sendBeacon(b.nurl) + if (b.burl) navigator.sendBeacon(b.burl) } - }); + }) } if (newSlots.length > 0) { - googletag.pubads().refresh(newSlots); + googletag.pubads().refresh(newSlots) } - }); - }; -})(); + }) + } +})() ``` - [ ] **Step 3: Update `index.ts` — rename `TsWindow` type** @@ -394,18 +401,18 @@ Replace the `TsWindow` interface: ```typescript type TsNamespace = { - adSlots?: TsAdSlot[]; - bids?: Record; - adInit?: () => void; - prevGptSlots?: GoogleTagSlot[]; - servicesEnabled?: boolean; - divToSlotId?: Record; - spaHookInstalled?: boolean; -}; + adSlots?: TsAdSlot[] + bids?: Record + adInit?: () => void + prevGptSlots?: GoogleTagSlot[] + servicesEnabled?: boolean + divToSlotId?: Record + spaHookInstalled?: boolean +} type TsWindow = Window & { - _ts?: TsNamespace; -}; + _ts?: TsNamespace +} ``` - [ ] **Step 4: Update `installTsAdInit` in `index.ts`** @@ -414,64 +421,73 @@ Change every `w.__ts*` access to `w._ts.*`. Initialise `w._ts` at function entry ```typescript export function installTsAdInit(): void { - const w = window as TsWindow; - const ts = (w._ts = w._ts ?? {}); + const w = window as TsWindow + const ts = (w._ts = w._ts ?? {}) ts.adInit = function () { - const slots = ts.adSlots ?? []; - const bids = ts.bids ?? {}; - const g = (window as GptWindow).googletag; - if (!g) return; + const slots = ts.adSlots ?? [] + const bids = ts.bids ?? {} + const g = (window as GptWindow).googletag + if (!g) return g.cmd?.push(() => { if (ts.prevGptSlots && ts.prevGptSlots.length > 0) { - g.destroySlots?.(ts.prevGptSlots); - ts.prevGptSlots = []; + g.destroySlots?.(ts.prevGptSlots) + ts.prevGptSlots = [] } - const newSlots: GoogleTagSlot[] = []; - const divToSlotId: Record = {}; + const newSlots: GoogleTagSlot[] = [] + const divToSlotId: Record = {} slots.forEach((slot) => { - const gptSlot = g.defineSlot?.(slot.gam_unit_path, slot.formats as Array, slot.div_id); - if (!gptSlot) return; - gptSlot.addService(g.pubads!()); - Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); - const bid = bids[slot.id] ?? {}; - (['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { - if (bid[key]) gptSlot.setTargeting(key, bid[key]!); - }); - gptSlot.setTargeting('ts_initial', '1'); - divToSlotId[slot.div_id] = slot.id; - newSlots.push(gptSlot); - }); - - ts.prevGptSlots = newSlots; - ts.divToSlotId = divToSlotId; + const gptSlot = g.defineSlot?.( + slot.gam_unit_path, + slot.formats as Array, + slot.div_id + ) + if (!gptSlot) return + gptSlot.addService(g.pubads!()) + Object.entries(slot.targeting ?? {}).forEach(([k, v]) => + gptSlot.setTargeting(k, v) + ) + const bid = bids[slot.id] ?? {} + ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, bid[key]!) + }) + gptSlot.setTargeting('ts_initial', '1') + divToSlotId[slot.div_id] = slot.id + newSlots.push(gptSlot) + }) + + ts.prevGptSlots = newSlots + ts.divToSlotId = divToSlotId if (!ts.servicesEnabled) { - g.pubads!().enableSingleRequest(); - g.enableServices?.(); - ts.servicesEnabled = true; - g.pubads!().addEventListener?.('slotRenderEnded', (event: SlotRenderEndedEvent) => { - const divId: string = event.slot?.getSlotElementId?.() ?? ''; - const slotId = (ts.divToSlotId ?? {})[divId]; - if (!slotId) return; - const bid = (ts.bids ?? {})[slotId] ?? {}; - const ourBidWon = - !event.isEmpty && - (bid.hb_adid - ? event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid - : !!bid.hb_bidder); - if (ourBidWon) { - if (bid.nurl) navigator.sendBeacon(bid.nurl); - if (bid.burl) navigator.sendBeacon(bid.burl); + g.pubads!().enableSingleRequest() + g.enableServices?.() + ts.servicesEnabled = true + g.pubads!().addEventListener?.( + 'slotRenderEnded', + (event: SlotRenderEndedEvent) => { + const divId: string = event.slot?.getSlotElementId?.() ?? '' + const slotId = (ts.divToSlotId ?? {})[divId] + if (!slotId) return + const bid = (ts.bids ?? {})[slotId] ?? {} + const ourBidWon = + !event.isEmpty && + (bid.hb_adid + ? event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid + : !!bid.hb_bidder) + if (ourBidWon) { + if (bid.nurl) navigator.sendBeacon(bid.nurl) + if (bid.burl) navigator.sendBeacon(bid.burl) + } } - }); + ) } if (newSlots.length > 0) { - g.pubads!().refresh(newSlots); + g.pubads!().refresh(newSlots) } - }); - }; + }) + } } ``` @@ -481,10 +497,10 @@ Replace `__tsSpaHookInstalled` and `__ts_ad_slots`/`__ts_bids` reads: ```typescript export function installSpaHook(): void { - const win = window as TsWindow; - const ts = (win._ts = win._ts ?? {}); - if (ts.spaHookInstalled) return; - ts.spaHookInstalled = true; + const win = window as TsWindow + const ts = (win._ts = win._ts ?? {}) + if (ts.spaHookInstalled) return + ts.spaHookInstalled = true // ... rest of SPA hook logic uses ts.adSlots, ts.bids, ts.adInit } ``` @@ -538,6 +554,7 @@ git commit -m "Namespace window globals under window._ts" **What:** Two small TypeScript/JS cleanups. `TsAdSlot.formats` should be typed as `Array<[number, number]>` (tuple, not array-of-array) to match GPT's actual input. The string `'ts_initial'` is hardcoded in both `gpt_bootstrap.js` and `index.ts` — extract as a named constant in `index.ts` (no JS equivalent needed since the bootstrap is vanilla JS). **Files:** + - Modify: `crates/js/lib/src/integrations/gpt/index.ts` - Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js` (comment only — JS can't share TS constants) @@ -576,7 +593,7 @@ slot.formats Near the top of `index.ts`, add: ```typescript -const TS_INITIAL_TARGETING_KEY = 'ts_initial'; +const TS_INITIAL_TARGETING_KEY = 'ts_initial' ``` Replace both occurrences of `'ts_initial'` in `installTsAdInit` with `TS_INITIAL_TARGETING_KEY`. @@ -585,7 +602,7 @@ Add a comment in `gpt_bootstrap.js` where `'ts_initial'` appears: ```js // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts -s.setTargeting("ts_initial", "1"); +s.setTargeting('ts_initial', '1') ``` - [ ] **Step 3: Run JS tests and format** From e592c3b6e10a1f373bb6b29178f37388d16a3464 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 29 May 2026 22:05:19 +0530 Subject: [PATCH 06/18] Add spec: Prebid creative rendering fix (hb_adid cache UUID + cache host/path) --- ...026-05-29-prebid-creative-rendering-fix.md | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md diff --git a/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md b/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md new file mode 100644 index 00000000..f996a23c --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md @@ -0,0 +1,315 @@ +# Prebid Creative Rendering Fix Design + +_Author · 2026-05-29_ + +--- + +## 1. Problem Statement + +The Trusted Server server-side auction returns winning bids from PBS, but ads never +render on the Prebid path because `hb_adid` carries the wrong value. + +The Prebid Universal Creative in GAM constructs the creative fetch URL as: + +``` +https://?uuid= +``` + +TS currently sets `hb_adid` from `bid.adid` or `bid.id` (the OpenRTB bid ID / +impression ID). PBS actually caches the creative markup and returns the cache UUID +in `ext.prebid.cache.bids.cacheId`. The Universal Creative needs the **cache UUID**, +not the bid ID. The cache host and path are also not forwarded today. + +**Effect:** GAM receives a wrong UUID, fetches nothing, and the slot renders empty. + +--- + +## 2. Root Cause — Two Extraction Gaps + +### Gap 1: Wrong `hb_adid` source + +`prebid.rs` extracts: +```rust +let ad_id = bid_obj + .get("adid") + .or_else(|| bid_obj.get("id")) // ← falls back to impression ID + .and_then(|v| v.as_str()) + .map(String::from); +``` + +Real PBS response has (in `ext.prebid.cache.bids`): +```json +{ + "url": "https://openads.adsrvr.org/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570", + "cacheId": "f47447a0-b759-4f2f-9887-af458b79b570" +} +``` + +`bid.id` = `"ad-header-0-_R_4uapbsnql8alb_"` — the impression ID, useless to the +creative renderer. + +### Gap 2: Cache host and path not forwarded + +`build_bid_map` in `publisher.rs` emits `hb_pb`, `hb_bidder`, `hb_adid`, `nurl`, +`burl`. It does not emit `hb_cache_host` or `hb_cache_path`. The Prebid Universal +Creative needs both to construct the fetch URL. + +--- + +## 3. Non-Goals + +- APS creative rendering — APS does not use PBS Cache. APS creative delivery is + Amazon-owned and not addressed here. +- APS win detection over-fire — separate known limitation, separate issue. +- Dual bootstrap sync risk — separate maintenance issue. +- Slim-Prebid bundle — out of scope for Phase 1. + +--- + +## 4. Design + +### 4.1 New Fields on `Bid` (types.rs) + +Add three fields to `Bid` to carry the PBS Cache coordinates extracted from the bid +response: + +```rust +/// Prebid Cache UUID for this bid. Populated from +/// `ext.prebid.cache.bids.cacheId` in the PBS response. +/// Used as `hb_adid` targeting value in `window._ts.bids`. +/// None for non-PBS providers (e.g., APS) and PBS bids without cache enabled. +pub cache_id: Option, + +/// Prebid Cache host (e.g., `"openads.adsrvr.org"`). Populated from +/// the host component of `ext.prebid.cache.bids.url`. +/// Used as `hb_cache_host` targeting value. +pub cache_host: Option, + +/// Prebid Cache path (e.g., `"/cache"`). Populated from +/// the path component of `ext.prebid.cache.bids.url`. +/// Used as `hb_cache_path` targeting value. +pub cache_path: Option, +``` + +### 4.2 Extraction in `prebid.rs` + +In `parse_bid_object`, after extracting `nurl`/`burl`, extract the cache fields from +`ext.prebid.cache.bids`: + +```rust +// Extract PBS Cache coordinates from ext.prebid.cache.bids +let cache_entry = bid_obj + .get("ext") + .and_then(|e| e.get("prebid")) + .and_then(|p| p.get("cache")) + .and_then(|c| c.get("bids")); + +let cache_id = cache_entry + .and_then(|c| c.get("cacheId")) + .and_then(|v| v.as_str()) + .map(String::from); + +let (cache_host, cache_path) = cache_entry + .and_then(|c| c.get("url")) + .and_then(|v| v.as_str()) + .and_then(|url_str| { + url::Url::parse(url_str) + .map_err(|e| log::debug!("PBS cache URL parse failed: {}", e)) + .ok() + }) + .map(|u| { + let host = u.host_str().map(String::from); + // path() returns "/" for root — only use if non-trivial + let path = u.path().to_string(); + let path = if path.is_empty() || path == "/" { None } else { Some(path) }; + (host, path) + }) + .unwrap_or((None, None)); +``` + +Note: `url` crate is already a workspace dependency. If not, parse host/path manually +by splitting on the first `/` after the scheme. + +The `ad_id` field (from `bid.adid` / `bid.id`) is **kept** — it maps to the OpenRTB +`adid` / `id` field that APS and other non-PBS providers may use. The cache fields are +**in addition**, not replacing `ad_id`. + +Populate all three fields on `AuctionBid`: +```rust +Ok(AuctionBid { + ..., + ad_id, + cache_id, + cache_host, + cache_path, + ... +}) +``` + +### 4.3 `build_bid_map` in `publisher.rs` + +Priority for `hb_adid`: use `cache_id` when present (PBS path), fall back to `ad_id` +(APS / other providers, backward compat): + +```rust +// hb_adid: cache UUID when available (PBS), bid adid otherwise (APS/other) +let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref()); +if let Some(id) = hb_adid { + obj.insert("hb_adid".to_string(), serde_json::Value::String(id.to_string())); +} + +// Cache coordinates — only present for PBS bids with Prebid Cache enabled +if let Some(ref host) = bid.cache_host { + obj.insert("hb_cache_host".to_string(), serde_json::Value::String(host.clone())); +} +if let Some(ref path) = bid.cache_path { + obj.insert("hb_cache_path".to_string(), serde_json::Value::String(path.clone())); +} +``` + +### 4.4 What `window._ts.bids` looks like after the fix + +```json +{ + "atf_sidebar_ad": { + "hb_pb": "0.01", + "hb_bidder": "thetradedesk", + "hb_adid": "f47447a0-b759-4f2f-9887-af458b79b570", + "hb_cache_host": "openads.adsrvr.org", + "hb_cache_path": "/cache", + "nurl": "https://...", + "burl": "https://..." + } +} +``` + +### 4.5 Win detection — no change required + +`slotRenderEnded` checks: +```js +event.slot.getTargeting('hb_adid')[0] === bid.hb_adid +``` + +`adInit()` calls `setTargeting('hb_adid', cacheId)` with the cache UUID. +`event.slot.getTargeting('hb_adid')[0]` returns that same cache UUID. +`bid.hb_adid` is now also the cache UUID. +Match holds. No change to the win detection logic. + +### 4.6 GAM line item creative requirement (publisher action — not TS code) + +This is a **hard dependency outside the TS codebase**. The publisher must configure +GAM line items with a server-side compatible Prebid creative. The standard +client-side Universal Creative calls `pbjs.renderAd()` which requires Prebid.js to be +loaded — it will not be at first render (slim-Prebid loads post-`window.load`). + +The server-side compatible creative uses the `hb_cache_*` macros to fetch the markup +directly from PBS Cache: + +```html + +``` + +Alternatively, publishers using the Prebid Universal Creative package can use: +```html + + +``` + +> **This creative configuration is a publisher/ad ops action, not a TS code change.** +> Document it in the integration guide and verify during onboarding. + +--- + +## 5. APS — Out of Scope + +APS does not use PBS Cache. APS bids will have `cache_id = None`, `cache_host = None`, +`cache_path = None`. The existing `ad_id` fallback path remains for APS. APS creative +rendering depends on Amazon's own GAM creative tag — separate from the Prebid path. + +APS win detection over-fires on the `!!bid.hb_bidder` fallback remain a known +limitation tracked separately. + +--- + +## 6. Files Changed + +| File | Change | +|---|---| +| `crates/trusted-server-core/src/auction/types.rs` | Add `cache_id`, `cache_host`, `cache_path` to `Bid` struct | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Extract `ext.prebid.cache.bids.{cacheId,url}` in `parse_bid_object` | +| `crates/trusted-server-core/src/publisher.rs` | `build_bid_map`: use `cache_id` for `hb_adid`, emit `hb_cache_host`/`hb_cache_path` | + +Test files: +| File | Change | +|---|---| +| `crates/trusted-server-core/src/integrations/prebid.rs` tests | Add test: PBS response with cache entry → correct `hb_adid`, `hb_cache_host`, `hb_cache_path` injected | +| `crates/trusted-server-core/src/publisher.rs` tests | Add test: `build_bid_map` emits cache fields when present; falls back to `ad_id` when absent | + +--- + +## 7. Testing + +**Unit tests:** + +1. `prebid.rs`: bid with `ext.prebid.cache.bids.cacheId` → `bid.cache_id = Some(uuid)`, `bid.cache_host = Some("openads.adsrvr.org")`, `bid.cache_path = Some("/cache")` +2. `prebid.rs`: bid without `ext.prebid.cache` → `bid.cache_id = None`, `bid.cache_host = None`, `bid.cache_path = None` +3. `prebid.rs`: bid with only `adid` (no cache) → `bid.ad_id = Some(...)`, `bid.cache_id = None` +4. `prebid.rs`: bid with malformed cache URL → `cache_host = None`, `cache_path = None`, no panic +5. `publisher.rs` `build_bid_map`: bid with `cache_id` → `hb_adid` uses `cache_id`, `hb_cache_host`/`hb_cache_path` emitted +6. `publisher.rs` `build_bid_map`: bid with no `cache_id` but has `ad_id` → `hb_adid` falls back to `ad_id`, no cache keys emitted +7. `publisher.rs` `build_bid_map`: APS bid (no `cache_id`, no `ad_id`) → no `hb_adid` emitted +8. `types.rs`: `Bid` with all three cache fields round-trips through `serde_json::to_string` / `from_str` + +> **Note for implementer:** `make_bid()` or equivalent `Bid` construction helpers in test modules +> must be updated to initialise `cache_id`, `cache_host`, `cache_path` to `None` +> (they will fail to compile otherwise once the fields are added to the struct). + +**Integration verification (manual):** + +After deploying, verify `window._ts.bids` in browser devtools shows `hb_cache_host` +and `hb_cache_path` present. Verify `hb_adid` matches the UUID in +`ext.prebid.cache.bids.cacheId` from the raw PBS response. + +--- + +## 8. Rollout Dependency Checklist + +Before this fix has end-to-end effect: + +- [ ] TS: this PR merged and deployed +- [ ] GAM: publisher ad ops updates all Prebid line item creatives to the server-side + cache-fetch variant (see §4.6) +- [ ] PBS: Prebid Cache enabled and populated (confirmed from real response — already + working) +- [ ] Verify: `window._ts.bids` shows correct cache UUID in `hb_adid` after deploy + +--- + +## 9. Known Remaining Gaps (not in scope) + +| Gap | Severity | Tracking | +|---|---|---| +| APS win detection over-fires nurl/burl | P1 | Separate issue | +| Dual bootstrap (`gpt_bootstrap.js` + `installTsAdInit`) sync risk | P2 | Separate issue | +| Slim-Prebid bundle not yet built | Phase 2 | §9.8 of design doc | From be0ca38cca593d2bc999bc30ea62dd1d61a7e643 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 29 May 2026 22:08:32 +0530 Subject: [PATCH 07/18] Update spec: add AuctionBid conversion note, partial cache warning, TTL note, clean up PBUC example --- ...026-05-29-prebid-creative-rendering-fix.md | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md b/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md index f996a23c..b583ab29 100644 --- a/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md +++ b/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md @@ -125,6 +125,16 @@ let (cache_host, cache_path) = cache_entry (host, path) }) .unwrap_or((None, None)); + +// Guard: if we extracted a cache UUID but couldn't extract the host, +// the bid will have hb_adid set but no endpoint to fetch from — creative will fail. +if cache_id.is_some() && cache_host.is_none() { + log::warn!( + "PBS bid has cache UUID but cache URL could not be parsed — \ + creative will fail to render for slot '{}'", + slot_id + ); +} ``` Note: `url` crate is already a workspace dependency. If not, parse host/path manually @@ -152,7 +162,9 @@ Priority for `hb_adid`: use `cache_id` when present (PBS path), fall back to `ad (APS / other providers, backward compat): ```rust -// hb_adid: cache UUID when available (PBS), bid adid otherwise (APS/other) +// hb_adid: use PBS Cache UUID when present — the Prebid Universal Creative uses +// this as the cache lookup key, NOT the OpenRTB bid ID (bid.ad_id). Fall back to +// bid.ad_id for APS and other non-PBS providers. let hb_adid = bid.cache_id.as_deref().or(bid.ad_id.as_deref()); if let Some(id) = hb_adid { obj.insert("hb_adid".to_string(), serde_json::Value::String(id.to_string())); @@ -227,7 +239,6 @@ Alternatively, publishers using the Prebid Universal Creative package can use: ```html `. + /// Pre-computed ``. /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, /// Shared auction result — written by auction task before HTML processing begins. /// Handler reads this in `el.on_end_tag()` on the body element. - /// `None` means no auction ran; inject empty `_ts.bids = {}` as fallback. + /// `None` means no auction ran; inject empty `tsjs.bids = {}` as fallback. pub ad_bids_state: std::sync::Arc>>, } @@ -311,10 +311,10 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso Ok(()) } }), - // Inject _ts.bids before via end_tag_handlers — only when + // Inject tsjs.bids before via end_tag_handlers — only when // slots matched this URL. When no slots matched, skip injection entirely // so the publisher's existing client-side Prebid/GPT flow is unmodified - // (dual-mode rollout: calling _ts.adInit with empty slots would invoke + // (dual-mode rollout: calling tsjs.adInit with empty slots would invoke // enableSingleRequest/enableServices and conflict with the publisher's GPT init). // Guard with AtomicBool so the script is only injected once even if // the origin HTML contains multiple elements (e.g. template fragments). @@ -1278,7 +1278,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: Some( - r#""# + r#""# .to_string(), ), ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), @@ -1292,7 +1292,7 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains("window._ts=window._ts||{}"), + html.contains("window.tsjs=window.tsjs||{}"), "should inject ad slots namespace at head-open" ); assert!( @@ -1307,7 +1307,7 @@ mod tests { #[test] fn injects_ts_bids_before_body_close() { - let bids_script = r#""#; + let bids_script = r#""#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), @@ -1315,7 +1315,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: Some( - r#""#.to_string(), + r#""#.to_string(), ), ad_bids_state: state, }; @@ -1325,7 +1325,7 @@ mod tests { .expect("should process"); let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( - html.contains("window._ts=window._ts||{}"), + html.contains("window.tsjs=window.tsjs||{}"), "should inject _ts namespace for bids before " ); assert!( @@ -1333,7 +1333,7 @@ mod tests { "should inject bids before " ); let bids_pos = html - .find("window._ts=window._ts||{}") + .find("window.tsjs=window.tsjs||{}") .expect("bids namespace should be in output"); let body_close_pos = html.find("").expect(" should be in output"); assert!(bids_pos < body_close_pos, "bids must appear before "); @@ -1341,7 +1341,7 @@ mod tests { #[test] fn injects_ts_bids_only_once_with_multiple_body_elements() { - let bids_script = r#""#; + let bids_script = r#""#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), @@ -1349,7 +1349,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: Some( - r#""#.to_string(), + r#""#.to_string(), ), ad_bids_state: state, }; @@ -1362,7 +1362,7 @@ mod tests { assert_eq!( html.matches(".bids=JSON.parse").count(), 1, - "should inject _ts.bids exactly once even with multiple elements" + "should inject tsjs.bids exactly once even with multiple elements" ); } @@ -1377,7 +1377,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: Some( - r#""#.to_string(), + r#""#.to_string(), ), ad_bids_state: state, }; @@ -1394,7 +1394,7 @@ mod tests { #[test] fn does_not_inject_ts_bids_when_no_slots_matched() { - // No slots matched this URL — ad_slots_script is None. _ts.bids must be + // No slots matched this URL — ad_slots_script is None. tsjs.bids must be // omitted entirely so the publisher's existing client-side GPT flow is // unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified"). let state = std::sync::Arc::new(std::sync::Mutex::new(None)); @@ -1413,7 +1413,7 @@ mod tests { let html = std::str::from_utf8(&output).expect("should be utf8"); assert!( !html.contains(".bids=JSON.parse"), - "should NOT inject _ts.bids when no slots matched" + "should NOT inject tsjs.bids when no slots matched" ); } } diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index a18e5123..847d2fd5 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -437,11 +437,11 @@ impl IntegrationHeadInjector for GptIntegration { GPT_INTEGRATION_ID } - /// Injects the `_ts.adInit` bootstrap script into ``. + /// Injects the `tsjs.adInit` bootstrap script into ``. /// /// ## Scroll / refresh handoff contract (Phase 1) /// - /// `_ts.adInit` handles **initial render only**: it wires server-side bid + /// `tsjs.adInit` handles **initial render only**: it wires server-side bid /// targeting into GPT slots and fires win beacons (`nurl`/`burl`) via /// `slotRenderEnded`. It does **not** trigger refresh auctions or handle /// GPT slot refresh events. @@ -460,13 +460,13 @@ impl IntegrationHeadInjector for GptIntegration { } } -/// Inline `window._ts.adInit` bootstrap injected at `` so the bids +/// Inline `window.tsjs.adInit` bootstrap injected at `` so the bids /// script at `` can call it before the TSJS bundle has loaded. /// /// The bundle's idempotent implementation in /// `crates/js/lib/src/integrations/gpt/index.ts` later overwrites this stub. /// Both implementations guard the one-time-per-page setup with -/// `window._ts.servicesEnabled` so neither double-enables services if the +/// `window.tsjs.servicesEnabled` so neither double-enables services if the /// publisher's own init code also calls `googletag.enableServices()`. const GPT_BOOTSTRAP_JS: &str = include_str!("gpt_bootstrap.js"); @@ -1062,10 +1062,10 @@ mod tests { }; let inserts = integration.head_inserts(&ctx); let combined = inserts.join(""); - assert!(combined.contains("ts.adInit"), "should define _ts.adInit"); + assert!(combined.contains("ts.adInit"), "should define tsjs.adInit"); assert!( combined.contains("ts.bids"), - "should read _ts.bids synchronously" + "should read tsjs.bids synchronously" ); assert!( combined.contains("ts_initial"), @@ -1111,9 +1111,9 @@ mod tests { let combined = integration.head_inserts(&ctx).join(""); assert!( combined.contains("ts.servicesEnabled"), - "should guard enableServices/enableSingleRequest with the _ts.servicesEnabled flag" + "should guard enableServices/enableSingleRequest with the tsjs.servicesEnabled flag" ); - assert!(combined.contains("ts.adInit"), "should install _ts.adInit"); + assert!(combined.contains("ts.adInit"), "should install tsjs.adInit"); assert!( !combined.contains("googletag.pubads().refresh()"), "should never call unbounded refresh() — only refresh(newSlots)" diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 08bb366c..dedc40ac 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -1,22 +1,22 @@ // Edge-injected GPT auction bootstrap. // -// This is the minimal `window._ts.adInit` that runs on first page load +// This is the minimal `window.tsjs.adInit` that runs on first page load // before the TSJS bundle has had a chance to install its richer // idempotent implementation. The bundle in -// crates/js/lib/src/integrations/gpt/index.ts overwrites `_ts.adInit` +// crates/js/lib/src/integrations/gpt/index.ts overwrites `tsjs.adInit` // once it loads. // // Contract with the bundle: -// - Both implementations must set `window._ts.servicesEnabled = true` +// - Both implementations must set `window.tsjs.servicesEnabled = true` // after calling `enableSingleRequest()`/`enableServices()` so a // subsequent call becomes a no-op. // - `refresh()` is called only for the slots defined in this pass, // never the global slot list. // -// Only installed if `window._ts.adInit` isn't already defined. +// Only installed if `window.tsjs.adInit` isn't already defined. (function () { if (typeof window === "undefined") return; - var ts = (window._ts = window._ts || {}); + var ts = (window.tsjs = window.tsjs || {}); if (ts.adInit) return; ts.adInit = function () { @@ -24,15 +24,46 @@ var bids = ts.bids || {}; var divToSlotId = {}; googletag.cmd.push(function () { + // Slots TS defined itself — tracked for SPA destroy. Publisher-owned + // slots are reused but never destroyed by TS on navigation. var newSlots = []; + // All slots to refresh (TS-defined + publisher-owned reused). + var slotsToRefresh = []; slots.forEach(function (slot) { - var s = googletag.defineSlot( - slot.gam_unit_path, - slot.formats, - slot.div_id, - ); - if (!s) return; - s.addService(googletag.pubads()); + // Resolve actual div ID: exact match first, then prefix query. + // div_id in config may be a stable prefix (e.g. "ad-header-0-") when + // the suffix is dynamically generated by the framework at render time. + var el = + document.getElementById(slot.div_id) || + document.querySelector( + "[id^='" + slot.div_id + "']:not([id$='-container'])", + ); + if (!el) return; + var actualDivId = el.id; + + // Reuse publisher's existing GPT slot when present — avoids duplicate + // slot definitions on the same div and lets TS inject bid targeting + // onto the slot the publisher already configured in GAM. + // If no publisher slot exists yet (async framework race), fall back to + // defining TS's own slot so bid targeting still reaches GAM. + var existingSlots = googletag.pubads().getSlots(); + var s = + existingSlots.find(function (gs) { + return gs.getSlotElementId() === actualDivId; + }) || null; + var tsOwned = false; + if (!s) { + // Use outer container div for TS's slot when publisher hasn't defined + // theirs yet — keeps both slots on separate divs so publisher's + // later defineSlot on the inner div doesn't conflict. + var containerEl = document.getElementById(actualDivId + "-container"); + var slotDivId = containerEl ? containerEl.id : actualDivId; + s = googletag.defineSlot(slot.gam_unit_path, slot.formats, slotDivId); + if (!s) return; + s.addService(googletag.pubads()); + tsOwned = true; + } + Object.entries(slot.targeting || {}).forEach(function (e) { s.setTargeting(e[0], e[1]); }); @@ -42,8 +73,9 @@ }); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); - divToSlotId[slot.div_id] = slot.id; - newSlots.push(s); + divToSlotId[actualDivId] = slot.id; + if (tsOwned) newSlots.push(s); + slotsToRefresh.push(s); }); ts.prevGptSlots = newSlots; ts.divToSlotId = divToSlotId; @@ -69,8 +101,8 @@ } }); } - if (newSlots.length > 0) { - googletag.pubads().refresh(newSlots); + if (slotsToRefresh.length > 0) { + googletag.pubads().refresh(slotsToRefresh); } }); }; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 850f437a..28c94f68 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -428,7 +428,7 @@ pub struct OwnedProcessResponseParams { /// The streaming phase collects these and writes bids to `ad_bids_state` /// before processing the last body chunk, so `` injection sees live bids. pub(crate) dispatched_auction: Option, - /// Price granularity used to bucket bids when building `_ts.bids`. + /// Price granularity used to bucket bids when building `tsjs.bids`. pub(crate) price_granularity: PriceGranularity, } @@ -1361,7 +1361,7 @@ pub(crate) fn build_bid_map( .collect() } -/// Build the `_ts.bids` `` sequences inside the string. @@ -1370,7 +1370,7 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Map should be infallible"); let escaped = html_escape_for_script(&json); format!( - "", + "", escaped ) } @@ -1383,7 +1383,7 @@ pub(crate) fn build_empty_bids_script() -> String { build_bids_script(&serde_json::Map::new()) } -/// Build the `_ts.adSlots` `", + "", escaped ) } @@ -2658,8 +2658,8 @@ mod tests { let config = make_config(); let script = build_ad_slots_script(&slots, &config); assert!( - script.contains("window._ts=window._ts||{}"), - "should initialise _ts namespace" + script.contains("window.tsjs=window.tsjs||{}"), + "should initialise tsjs namespace" ); assert!( script.contains(".adSlots=JSON.parse"), diff --git a/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md b/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md index 480151d9..83866e3b 100644 --- a/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md +++ b/docs/superpowers/plans/2026-05-29-pr680-reviewer-findings.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Address the two reviewer-required findings from PR #680 plus low-effort cleanups: consolidate slot config into `trusted-server.toml`, namespace `window.__ts*` globals under `window._ts`, and fix the TypeScript `formats` type cast and `ts_initial` hardcoded string. +**Goal:** Address the two reviewer-required findings from PR #680 plus low-effort cleanups: consolidate slot config into `trusted-server.toml`, consolidate `window.__ts*` globals under `window.tsjs`, and fix the TypeScript `formats` type cast and `ts_initial` hardcoded string. **Architecture:** Slot templates move from the standalone `creative-opportunities.toml` (embedded via `include_str!`) into the `[creative_opportunities]` section of `trusted-server.toml`, using the existing `vec_from_seq_or_map` deserializer pattern already used for `BID_PARAM_ZONE_OVERRIDES`. The window globals rename is a coordinated change across `gpt_bootstrap.js`, `index.ts`, and `publisher.rs` — all three must change together since they share a runtime contract. @@ -271,21 +271,21 @@ git commit -m "Move slot templates from creative-opportunities.toml into trusted --- -## Task 2: Namespace `window.__ts*` globals under `window._ts` +## Task 2: Consolidate `window.__ts*` globals under `window.tsjs` **What:** All `window.__ts*` globals become properties on a single `window._ts` namespace object. Changes must be coordinated across three files: `gpt_bootstrap.js`, `index.ts`, and `publisher.rs`. Tests in `index.test.ts` must be updated too. **Rename table:** -| Old global | New property | Notes | -| ----------------------------- | ----------------------------- | ---------------------------- | -| `window.__ts_ad_slots` | `window._ts.adSlots` | Array, set at head-open | -| `window.__ts_bids` | `window._ts.bids` | Object, set before `` | -| `window.__tsAdInit` | `window._ts.adInit` | Function | -| `window.__tsPrevGptSlots` | `window._ts.prevGptSlots` | Array | -| `window.__tsServicesEnabled` | `window._ts.servicesEnabled` | Boolean | -| `window.__tsDivToSlotId` | `window._ts.divToSlotId` | Object | -| `window.__tsSpaHookInstalled` | `window._ts.spaHookInstalled` | Boolean | +| Old global | New property | Notes | +| ----------------------------- | ------------------------------ | ---------------------------- | +| `window.__ts_ad_slots` | `window.tsjs.adSlots` | Array, set at head-open | +| `window.__ts_bids` | `window.tsjs.bids` | Object, set before `` | +| `window.__tsAdInit` | `window.tsjs.adInit` | Function | +| `window.__tsPrevGptSlots` | `window.tsjs.prevGptSlots` | Array | +| `window.__tsServicesEnabled` | `window.tsjs.servicesEnabled` | Boolean | +| `window.__tsDivToSlotId` | `window.tsjs.divToSlotId` | Object | +| `window.__tsSpaHookInstalled` | `window.tsjs.spaHookInstalled` | Boolean | **Files:** @@ -306,7 +306,7 @@ git commit -m "Move slot templates from creative-opportunities.toml into trusted format!("", escaped) // After — initialise _ts if absent, then set adSlots -format!("", escaped) +format!("", escaped) ``` `build_bids_script` generates the script injected before ``. Change: @@ -320,7 +320,7 @@ format!( // After format!( - "", + "", escaped ) ``` @@ -417,7 +417,7 @@ type TsWindow = Window & { - [ ] **Step 4: Update `installTsAdInit` in `index.ts`** -Change every `w.__ts*` access to `w._ts.*`. Initialise `w._ts` at function entry: +Update all properties to live under `window.tsjs`. Use `window.tsjs` directly: ```typescript export function installTsAdInit(): void { @@ -507,7 +507,7 @@ export function installSpaHook(): void { - [ ] **Step 6: Update tests in `index.test.ts`** -Find all test assertions that reference `window.__ts_ad_slots`, `window.__ts_bids`, `window.__tsAdInit`, etc. and update to `window._ts.adSlots`, `window._ts.bids`, `window._ts.adInit` etc. +Find all test assertions that reference `window.__ts_ad_slots`, `window.__ts_bids`, `window.__tsAdInit`, etc. and update to `window.tsjs.adSlots`, `window.tsjs.bids`, `window.tsjs.adInit` etc. Run tests first to see what fails: diff --git a/docs/superpowers/plans/2026-05-29-prebid-creative-rendering-fix.md b/docs/superpowers/plans/2026-05-29-prebid-creative-rendering-fix.md index a7941b4f..7a3f3420 100644 --- a/docs/superpowers/plans/2026-05-29-prebid-creative-rendering-fix.md +++ b/docs/superpowers/plans/2026-05-29-prebid-creative-rendering-fix.md @@ -26,6 +26,7 @@ **What:** Add three new `Option` fields to `Bid`. Since Rust struct literals are exhaustive, every place that constructs a `Bid { ... }` in the codebase will fail to compile until the new fields are added. Fix all of them with `None` defaults (except the APS provider which constructs a real `Bid` — also `None` since APS doesn't use PBS Cache). **Files:** + - Modify: `crates/trusted-server-core/src/auction/types.rs:200` (after `ad_id` field) - Modify (test helpers/literals — add `None` fields): - `crates/trusted-server-core/src/auction/types.rs:314` (`make_bid` helper) @@ -97,6 +98,7 @@ - [ ] **Step 4: Fix inline `Bid` literal in `types.rs` (line ~445)** Find the `Bid {` literal around line 445 in the test section of `types.rs`. Add: + ```rust cache_id: None, cache_host: None, @@ -106,6 +108,7 @@ - [ ] **Step 5: Fix `make_bid` helper in `publisher.rs` (line ~2616)** In the `make_bid` test helper function in `publisher.rs`, add to the `Bid {}` literal: + ```rust cache_id: None, cache_host: None, @@ -115,6 +118,7 @@ - [ ] **Step 6: Fix inline `Bid` literal in `publisher.rs` (line ~2714)** Find the `Bid {` literal around line 2714 in `publisher.rs` tests. Add: + ```rust cache_id: None, cache_host: None, @@ -124,6 +128,7 @@ - [ ] **Step 7: Fix five `Bid` literals in `orchestrator.rs` (lines ~1121,1138,1278,1325,1358)** Add to each of the five `Bid {}` literals in the test section of `orchestrator.rs`: + ```rust cache_id: None, cache_host: None, @@ -133,6 +138,7 @@ - [ ] **Step 8: Fix APS production `Bid` construction in `aps.rs` (line ~442)** In `aps.rs`, inside `parse_aps_response` (or wherever the `Ok(Bid { ... })` is around line 442), add: + ```rust cache_id: None, cache_host: None, @@ -183,6 +189,7 @@ **What:** After extracting `ad_id` in `parse_bid`, extract `ext.prebid.cache.bids.cacheId` as `cache_id` and split `ext.prebid.cache.bids.url` into `cache_host` + `cache_path`. Populate all three new fields on the returned `AuctionBid`. Add TDD tests first. **Files:** + - Modify: `crates/trusted-server-core/src/integrations/prebid.rs:1362–1391` (extraction + struct literal) - Test: `crates/trusted-server-core/src/integrations/prebid.rs` (test module near bottom) @@ -441,6 +448,7 @@ **What:** Change `build_bid_map` to use `bid.cache_id` for `hb_adid` (falling back to `bid.ad_id` for APS/other providers), and emit `hb_cache_host`/`hb_cache_path` when present. Update the existing `bid_map_includes_nurl_and_burl` test (which currently passes `"abc123"` as `ad_id` and asserts `hb_adid = "abc123"`) to use a cache-based bid. Add new tests covering cache fields and fallback path. **Files:** + - Modify: `crates/trusted-server-core/src/publisher.rs:1311–1342` (`build_bid_map`) - Modify: `crates/trusted-server-core/src/publisher.rs:2608–2630` (`make_bid` helper — add cache params) - Modify: `crates/trusted-server-core/src/publisher.rs:2666–2707` (existing `bid_map_includes_nurl_and_burl` test) @@ -708,9 +716,11 @@ ``` Run: + ```bash cargo test --package trusted-server-core bid_with_cache_fields_round_trips 2>&1 | tail -5 ``` + Expected: PASS. - [ ] **Step 7: Run full CI suite** diff --git a/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md b/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md index b583ab29..a21ec4d2 100644 --- a/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md +++ b/docs/superpowers/specs/2026-05-29-prebid-creative-rendering-fix.md @@ -29,6 +29,7 @@ not the bid ID. The cache host and path are also not forwarded today. ### Gap 1: Wrong `hb_adid` source `prebid.rs` extracts: + ```rust let ad_id = bid_obj .get("adid") @@ -38,6 +39,7 @@ let ad_id = bid_obj ``` Real PBS response has (in `ext.prebid.cache.bids`): + ```json { "url": "https://openads.adsrvr.org/cache?uuid=f47447a0-b759-4f2f-9887-af458b79b570", @@ -76,7 +78,7 @@ response: ```rust /// Prebid Cache UUID for this bid. Populated from /// `ext.prebid.cache.bids.cacheId` in the PBS response. -/// Used as `hb_adid` targeting value in `window._ts.bids`. +/// Used as `hb_adid` targeting value in `window.tsjs.bids`. /// None for non-PBS providers (e.g., APS) and PBS bids without cache enabled. pub cache_id: Option, @@ -145,6 +147,7 @@ The `ad_id` field (from `bid.adid` / `bid.id`) is **kept** — it maps to the Op **in addition**, not replacing `ad_id`. Populate all three fields on `AuctionBid`: + ```rust Ok(AuctionBid { ..., @@ -179,7 +182,7 @@ if let Some(ref path) = bid.cache_path { } ``` -### 4.4 What `window._ts.bids` looks like after the fix +### 4.4 What `window.tsjs.bids` looks like after the fix ```json { @@ -198,6 +201,7 @@ if let Some(ref path) = bid.cache_path { ### 4.5 Win detection — no change required `slotRenderEnded` checks: + ```js event.slot.getTargeting('hb_adid')[0] === bid.hb_adid ``` @@ -219,31 +223,32 @@ directly from PBS Cache: ```html ``` Alternatively, publishers using the Prebid Universal Creative package can use: + ```html ``` @@ -271,11 +276,11 @@ limitation tracked separately. ## 6. Files Changed -| File | Change | -|---|---| -| `crates/trusted-server-core/src/auction/types.rs` | Add `cache_id`, `cache_host`, `cache_path` to `Bid` struct | +| File | Change | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/auction/types.rs` | Add `cache_id`, `cache_host`, `cache_path` to `Bid` struct | | `crates/trusted-server-core/src/integrations/prebid.rs` | Extract `ext.prebid.cache.bids.{cacheId,url}` in `parse_bid_object`; update `AuctionBid` → `Bid` conversion to carry the three new fields | -| `crates/trusted-server-core/src/publisher.rs` | `build_bid_map`: use `cache_id` for `hb_adid`, emit `hb_cache_host`/`hb_cache_path` | +| `crates/trusted-server-core/src/publisher.rs` | `build_bid_map`: use `cache_id` for `hb_adid`, emit `hb_cache_host`/`hb_cache_path` | > **Implementer note — `AuctionBid` → `Bid` conversion:** `prebid.rs` constructs an > intermediate `AuctionBid` type that is later converted to the shared `Bid` type from @@ -312,7 +317,7 @@ Test files: **Integration verification (manual):** -After deploying, verify `window._ts.bids` in browser devtools shows `hb_cache_host` +After deploying, verify `window.tsjs.bids` in browser devtools shows `hb_cache_host` and `hb_cache_path` present. Verify `hb_adid` matches the UUID in `ext.prebid.cache.bids.cacheId` from the raw PBS response. @@ -327,14 +332,14 @@ Before this fix has end-to-end effect: cache-fetch variant (see §4.6) - [ ] PBS: Prebid Cache enabled and populated (confirmed from real response — already working) -- [ ] Verify: `window._ts.bids` shows correct cache UUID in `hb_adid` after deploy +- [ ] Verify: `window.tsjs.bids` shows correct cache UUID in `hb_adid` after deploy --- ## 9. Known Remaining Gaps (not in scope) -| Gap | Severity | Tracking | -|---|---|---| -| APS win detection over-fires nurl/burl | P1 | Separate issue | -| Dual bootstrap (`gpt_bootstrap.js` + `installTsAdInit`) sync risk | P2 | Separate issue | -| Slim-Prebid bundle not yet built | Phase 2 | §9.8 of design doc | +| Gap | Severity | Tracking | +| ----------------------------------------------------------------- | -------- | ------------------ | +| APS win detection over-fires nurl/burl | P1 | Separate issue | +| Dual bootstrap (`gpt_bootstrap.js` + `installTsAdInit`) sync risk | P2 | Separate issue | +| Slim-Prebid bundle not yet built | Phase 2 | §9.8 of design doc | diff --git a/trusted-server.toml b/trusted-server.toml index 438ab682..35b6daf3 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -186,7 +186,7 @@ rewrite_script = true enabled = true providers = ["prebid", "aps"] mediator = "adserver_mock" -timeout_ms = 2000 +timeout_ms = 2000 # override per-publisher via TRUSTED_SERVER__AUCTION__TIMEOUT_MS # Context keys the JS client is allowed to forward into auction requests. # Keys not in this list are silently dropped. An empty list blocks all keys. allowed_context_keys = ["permutive_segments"] @@ -195,7 +195,7 @@ allowed_context_keys = ["permutive_segments"] enabled = true pub_id = "test-pub" endpoint = "https://origin-mocktioneer.cdintel.com/e/dtb/bid" -timeout_ms = 1000 +timeout_ms = 1000 # override per-publisher via TRUSTED_SERVER__INTEGRATIONS__APS__TIMEOUT_MS [integrations.google_tag_manager] enabled = false @@ -242,13 +242,16 @@ gam_network_id = "88059007" # drains in <50 ms but the auction runs to the limit. 500 ms is the recommended # default; raise only if your SSPs need more headroom and your analytics confirm # the DCL slip is acceptable. -auction_timeout_ms = 1500 +auction_timeout_ms = 1500 # override via TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__AUCTION_TIMEOUT_MS price_granularity = "dense" +# Slot templates — override entire array via: +# TRUSTED_SERVER__CREATIVE_OPPORTUNITIES__SLOT='[{"id":"...","gam_unit_path":"...",...}]' + [[creative_opportunities.slot]] id = "atf_sidebar_ad" gam_unit_path = "/a/b/news" -div_id = "ad-atf_sidebar-0-_r_2_" +div_id = "div-ad-atf-sidebar" page_patterns = ["/20**", "/news/**"] formats = [{ width = 300, height = 250 }] floor_price = 0.50 @@ -263,9 +266,9 @@ slot_id = "aps-slot-atf-sidebar" [[creative_opportunities.slot]] id = "homepage_header_ad" gam_unit_path = "/a/b/homepage" -div_id = "ad-header-0-_R_jpalubtak5lb_" +div_id = "div-ad-homepage-header" page_patterns = ["/"] -formats = [{ width = 970, height = 90 }, { width = 728, height = 90 }, { width = 970, height = 250 }] +formats = [{ width = 728, height = 90 }] floor_price = 0.50 [creative_opportunities.slot.targeting] @@ -278,9 +281,9 @@ slot_id = "aps-slot-homepage-header" [[creative_opportunities.slot]] id = "homepage_footer_ad" gam_unit_path = "/a/b/homepage" -div_id = "ad-fixed_bottom-0-_R_klubtak5lb_" +div_id = "div-ad-homepage-footer" page_patterns = ["/"] -formats = [{ width = 728, height = 90 }] +formats = [{ width = 728, height = 90 }, { width = 768, height = 66 }] floor_price = 0.50 [creative_opportunities.slot.targeting] From af6341d8bd9601246e87023df546f7e2b4502458 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 1 Jun 2026 19:17:50 +0530 Subject: [PATCH 13/18] Forward hb_cache_host/hb_cache_path to GPT targeting and add test coverage --- crates/js/lib/src/integrations/gpt/index.test.ts | 10 ++++++++-- crates/js/lib/src/integrations/gpt/index.ts | 8 +++++--- crates/trusted-server-core/build.rs | 8 ++++++-- .../trusted-server-core/src/creative_opportunities.rs | 2 +- .../src/integrations/gpt_bootstrap.js | 8 +++++--- 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.test.ts b/crates/js/lib/src/integrations/gpt/index.test.ts index 0366cac4..0c1ee8e0 100644 --- a/crates/js/lib/src/integrations/gpt/index.test.ts +++ b/crates/js/lib/src/integrations/gpt/index.test.ts @@ -82,12 +82,15 @@ describe('installTsAdInit', () => { atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo', - hb_adid: 'abc', + hb_adid: 'abc-uuid', + hb_cache_host: 'cache.example.com', + hb_cache_path: '/pbc/v1/cache', nurl: 'https://ssp/win', burl: 'https://ssp/bill', }, }, - }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; const fetchSpy = vi.spyOn(global, 'fetch'); @@ -98,6 +101,9 @@ describe('installTsAdInit', () => { expect(fetchSpy).not.toHaveBeenCalled(); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'abc-uuid'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_host', 'cache.example.com'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_cache_path', '/pbc/v1/cache'); expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); expect(mockPubads.refresh).toHaveBeenCalled(); diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index ac0ca485..65901af8 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -262,9 +262,11 @@ export function installTsAdInit(): void { Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); const bid = bids[slot.id] ?? {}; - (['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { - if (bid[key]) gptSlot.setTargeting(key, bid[key]!); - }); + (['hb_pb', 'hb_bidder', 'hb_adid', 'hb_cache_host', 'hb_cache_path'] as const).forEach( + (key) => { + if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); + }, + ); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); divToSlotId[actualDivId] = slot.id; if (tsOwned) newSlots.push(gptSlot); diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 145cf941..a0cc07b3 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -49,9 +49,13 @@ mod creative_opportunities { #[serde(default = "default_price_granularity")] pub price_granularity: String, /// Deserialized as raw JSON values so build.rs can validate slot IDs - /// without pulling in the full runtime type. Uses vec_from_seq_or_map + /// without pulling in the full runtime type. Uses `vec_from_seq_or_map` /// so env var JSON blobs (strings) deserialize correctly. - #[serde(default, rename = "slot", deserialize_with = "crate::settings::vec_from_seq_or_map")] + #[serde( + default, + rename = "slot", + deserialize_with = "crate::settings::vec_from_seq_or_map" + )] pub slot_raw: Vec, /// Typed slot vec — always empty in the build context; exists only so /// settings.rs (included via #[path]) compiles against the stub. diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index ac020041..03ac0376 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -235,7 +235,7 @@ pub struct SlotProviders { /// Prebid Server inline bidder parameters. /// /// When present, these are forwarded directly as `ext.prebid.bidder.*` - /// in the OpenRTB request, bypassing PBS stored-request lookup for this slot. + /// in the `OpenRTB` request, bypassing PBS stored request lookup for this slot. /// Useful in development environments where stored requests are not available. pub prebid: Option, } diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index dedc40ac..04cbda5e 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -68,9 +68,11 @@ s.setTargeting(e[0], e[1]); }); var b = bids[slot.id] || {}; - ["hb_pb", "hb_bidder", "hb_adid"].forEach(function (k) { - if (b[k]) s.setTargeting(k, b[k]); - }); + ["hb_pb", "hb_bidder", "hb_adid", "hb_cache_host", "hb_cache_path"].forEach( + function (k) { + if (b[k]) s.setTargeting(k, b[k]); + }, + ); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); divToSlotId[actualDivId] = slot.id; From d511897059f3bc60b3dfdf013432bd59403bd45d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 1 Jun 2026 20:35:58 +0530 Subject: [PATCH 14/18] Add inject_adm_for_testing debug flag for end-to-end creative validation --- crates/js/lib/src/core/types.ts | 2 + crates/js/lib/src/integrations/gpt/index.ts | 10 ++++- .../src/integrations/gpt_bootstrap.js | 10 ++++- crates/trusted-server-core/src/publisher.rs | 42 +++++++++++++++---- crates/trusted-server-core/src/settings.rs | 8 ++++ trusted-server.toml | 5 +++ 6 files changed, 66 insertions(+), 11 deletions(-) diff --git a/crates/js/lib/src/core/types.ts b/crates/js/lib/src/core/types.ts index 49741861..a4167cd3 100644 --- a/crates/js/lib/src/core/types.ts +++ b/crates/js/lib/src/core/types.ts @@ -38,6 +38,8 @@ export interface AuctionBidData { hb_cache_path?: string; nurl?: string; burl?: string; + /** Raw creative markup. Only present when `[debug] inject_adm_for_testing = true`. */ + adm?: string; } export interface TsjsApi { diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 65901af8..3c49bf5f 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -260,8 +260,16 @@ export function installTsAdInit(): void { tsOwned = true; } - Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); + // Debug: if adm is present, inject creative directly into div and skip GAM. + // Only populated when [debug] inject_adm_for_testing = true in config. const bid = bids[slot.id] ?? {}; + if (bid.adm && el) { + el.innerHTML = String(bid.adm); + divToSlotId[actualDivId] = slot.id; + return; + } + + Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); (['hb_pb', 'hb_bidder', 'hb_adid', 'hb_cache_host', 'hb_cache_path'] as const).forEach( (key) => { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index 04cbda5e..b3293b48 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -64,10 +64,18 @@ tsOwned = true; } + // Debug: if adm is present, inject creative directly into div and skip GAM. + // Only populated when [debug] inject_adm_for_testing = true in config. + var b = bids[slot.id] || {}; + if (b.adm) { + el.innerHTML = b.adm; + divToSlotId[actualDivId] = slot.id; + return; + } + Object.entries(slot.targeting || {}).forEach(function (e) { s.setTargeting(e[0], e[1]); }); - var b = bids[slot.id] || {}; ["hb_pb", "hb_bidder", "hb_adid", "hb_cache_host", "hb_cache_path"].forEach( function (k) { if (b[k]) s.setTargeting(k, b[k]); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 28c94f68..c40fa851 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -516,6 +516,7 @@ pub async fn stream_publisher_body_async( &result.winning_bids, params.price_granularity, ¶ms.ad_bids_state, + settings.debug.inject_adm_for_testing, ); return stream_publisher_body(body, output, params, settings, integration_registry); } @@ -611,13 +612,14 @@ pub(crate) fn write_bids_to_state( winning_bids: &std::collections::HashMap, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, + inject_adm: bool, ) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") ); - let bid_map = build_bid_map(winning_bids, price_granularity); + let bid_map = build_bid_map(winning_bids, price_granularity, inject_adm); let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } @@ -757,7 +759,12 @@ async fn one_behind_loop( "one_behind_loop: collect complete — {} winning bid(s)", result.winning_bids.len() ); - write_bids_to_state(&result.winning_bids, price_granularity, ad_bids_state); + write_bids_to_state( + &result.winning_bids, + price_granularity, + ad_bids_state, + settings.debug.inject_adm_for_testing, + ); if settings.debug.auction_html_comment { prepend_auction_debug_comment("stream", &result, ad_bids_state); @@ -1192,7 +1199,12 @@ pub async fn handle_publisher_request( "BufferedProcessed: auction collected — {} winning bid(s)", result.winning_bids.len() ); - write_bids_to_state(&result.winning_bids, price_granularity, &ad_bids_state); + write_bids_to_state( + &result.winning_bids, + price_granularity, + &ad_bids_state, + settings.debug.inject_adm_for_testing, + ); if settings.debug.auction_html_comment { prepend_auction_debug_comment("buffered", &result, &ad_bids_state); @@ -1311,6 +1323,7 @@ fn html_escape_for_script(s: &str) -> String { pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, + inject_adm: bool, ) -> serde_json::Map { winning_bids .iter() @@ -1355,6 +1368,13 @@ pub(crate) fn build_bid_map( if let Some(ref burl) = bid.burl { obj.insert("burl".to_string(), serde_json::Value::String(burl.clone())); } + // Debug: include raw creative markup so adInit() can bypass GAM. + // Only set when inject_adm_for_testing is enabled — never in production. + if inject_adm { + if let Some(ref adm) = bid.creative { + obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); + } + } (slot_id.clone(), serde_json::Value::Object(obj)) }) }) @@ -1619,7 +1639,11 @@ pub async fn handle_page_bids( std::collections::HashMap::new() }; - let bid_map = build_bid_map(&winning_bids, co_config.price_granularity); + let bid_map = build_bid_map( + &winning_bids, + co_config.price_granularity, + settings.debug.inject_adm_for_testing, + ); let slots_json: Vec = matched_slots .iter() @@ -2699,7 +2723,7 @@ mod tests { "https://ssp/bill", ), ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); let obj = entry.as_object().expect("should be object"); assert_eq!( @@ -2752,7 +2776,7 @@ mod tests { metadata: Default::default(), }, ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); let obj = map .get("atf_sidebar_ad") .expect("should have bid entry") @@ -2798,7 +2822,7 @@ mod tests { metadata: Default::default(), }, ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); let obj = map .get("atf_sidebar_ad") .expect("should have bid entry") @@ -2842,7 +2866,7 @@ mod tests { metadata: Default::default(), }, ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); let obj = map .get("atf_sidebar_ad") .expect("should have bid entry") @@ -2877,7 +2901,7 @@ mod tests { metadata: Default::default(), }, ); - let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); assert!( map.is_empty(), "slot with no price should be excluded from bid map" diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index b32201ba..ae915d77 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -416,6 +416,14 @@ pub struct DebugConfig { /// Never enable in production — visible in page source. #[serde(default)] pub auction_html_comment: bool, + + /// Include raw `adm` creative markup in `window.tsjs.bids` and have + /// `adInit()` render it directly into the slot div, bypassing GAM entirely. + /// + /// Use this to validate the full auction→creative pipeline without GAM + /// line items. Never enable in production — injects raw HTML from SSPs. + #[serde(default)] + pub inject_adm_for_testing: bool, } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] diff --git a/trusted-server.toml b/trusted-server.toml index 35b6daf3..b32deb0c 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -212,6 +212,11 @@ timeout_ms = 1000 # Inject before . # Visible in page source. Disable after investigation. # auction_html_comment = true +# +# Inject raw adm creative markup into window.tsjs.bids and render it directly +# into slot divs, bypassing GAM entirely. Use to validate the full +# auction→creative pipeline without GAM line items. NEVER enable in production. +# inject_adm_for_testing = true # Enable the JA4/TLS fingerprint debug endpoint at GET /_ts/debug/ja4. # Returns a plain-text response with the following fields (Fastly-observed values): # ja4 — JA4 TLS client fingerprint From 53e4fe870d821871411b17ec0208b80460e699d5 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 1 Jun 2026 22:04:14 +0530 Subject: [PATCH 15/18] Add TS pbRender bridge and APS apstag.setDisplayBids() integration --- crates/js/lib/src/integrations/gpt/index.ts | 39 +++++++- .../js/lib/src/integrations/prebid/index.ts | 98 +++++++++++++++++++ 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 3c49bf5f..618c1049 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -279,6 +279,14 @@ export function installTsAdInit(): void { divToSlotId[actualDivId] = slot.id; if (tsOwned) newSlots.push(gptSlot); slotsToRefresh.push(gptSlot); + + // APS: signal to apstag that bids are ready so Amazon's GAM creative + // can render. apstag must already be initialised on the page (which it + // is on production publisher pages). Safe no-op if apstag is absent. + if (bid.hb_bidder === 'aps' || bid.hb_bidder === 'amazon-aps') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).apstag?.setDisplayBids?.(); + } }); ts.prevGptSlots = newSlots as unknown[]; @@ -386,12 +394,14 @@ export function installSpaAuctionHook(): void { /** * Register the slim-Prebid lazy loader. Fires after window.load — off the - * critical path. slim-Prebid handles refresh auctions and userID module - * warm-up (ID5, sharedID, LiveRamp ATS, Lockr). It skips initial-render slots - * (ts_initial=1) and registers as the GPT refresh handler for scroll/sticky auctions. + * critical path. Slim-Prebid handles scroll/refresh auctions and userID + * module warm-up (ID5, sharedID, LiveRamp ATS, Lockr). + * + * Phase 1: no-op unless `window.__tsjs_slim_prebid_url` is set (the slim + * bundle build target ships in a later phase). * - * Phase 1: no-op unless window.__tsjs_slim_prebid_url is set (it won't be until - * the slim-Prebid bundle build target ships in a later phase). + * The TS pbRender bridge (`installTsRenderBridge`) is registered separately + * at module init so it activates with the existing tsjs-prebid.min.js bundle. */ export function installSlimPrebidLoader(): void { const url = (window as GptWindow).__tsjs_slim_prebid_url; @@ -404,6 +414,24 @@ export function installSlimPrebidLoader(): void { }); } +/** + * Install the TS pbRender bridge post-load. + * + * Intercepts Prebid cross-domain `"Prebid Request"` messages for TS + * server-side bids, fetches the creative from PBS Cache, and replies so the + * GAM Prebid Universal Creative renders without Prebid.js's local bid store. + * Runs with the existing tsjs-prebid.min.js bundle — no slim-Prebid needed. + */ +function installTsRenderBridgePostLoad(): void { + window.addEventListener('load', () => { + import('../prebid/index').then(({ installTsRenderBridge }) => { + installTsRenderBridge(); + }).catch(() => { + // Prebid bundle not available — bridge skipped silently. + }); + }); +} + // Register the activation function on `window` so the server-injected inline // script can call it explicitly. The server emits: // " .to_string(), format!("", GPT_BOOTSTRAP_JS), - ] + ]; + + if let Some(ref url) = self.config.slim_prebid_url { + scripts.push(format!( + "", + serde_json::to_string(url).expect("should serialize string") + )); + } + + scripts } } @@ -502,6 +521,7 @@ mod tests { script_url: default_script_url(), cache_ttl_seconds: 3600, rewrite_script: true, + slim_prebid_url: None, } } @@ -1128,4 +1148,59 @@ mod tests { "gpt" ); } + + #[test] + fn head_inserts_emits_slim_prebid_url_when_configured() { + let config = GptConfig { + slim_prebid_url: Some("https://cdn.example.com/tsjs-prebid.min.js".to_string()), + ..test_config() + }; + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!( + inserts.len(), + 3, + "should emit three head inserts when slim_prebid_url is set" + ); + assert_eq!( + inserts[2], + r#""#, + "should emit the slim-Prebid URL as a JSON-encoded string assignment" + ); + } + + #[test] + fn head_inserts_omits_slim_prebid_url_when_not_configured() { + let integration = GptIntegration::new(test_config()); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!( + inserts.len(), + 2, + "should emit exactly two head inserts when slim_prebid_url is absent" + ); + assert!( + inserts + .iter() + .all(|s| !s.contains("__tsjs_slim_prebid_url")), + "should not emit slim-Prebid URL tag when not configured" + ); + } } diff --git a/trusted-server.toml b/trusted-server.toml index b32deb0c..d569349e 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -125,6 +125,7 @@ enabled = false script_url = "https://securepubads.g.doubleclick.net/tag/js/gpt.js" cache_ttl_seconds = 3600 rewrite_script = true +# slim_prebid_url = "https://cdn.example.com/tsjs-prebid.min.js" # Consent forwarding configuration # Controls how Trusted Server interprets and forwards privacy consent signals. From 9d76c31dfe032984a35c36983badb7243aabb826 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 3 Jun 2026 16:59:25 +0530 Subject: [PATCH 18/18] Add server-side ad render bridge diagnostics --- crates/js/lib/src/core/types.ts | 21 +++ .../js/lib/src/integrations/gpt/index.test.ts | 164 +++++++++++++++--- crates/js/lib/src/integrations/gpt/index.ts | 58 ++++--- .../js/lib/src/integrations/prebid/index.ts | 24 ++- .../src/integrations/adserver_mock.rs | 112 +++++++++++- .../src/integrations/gpt_bootstrap.js | 62 +++---- crates/trusted-server-core/src/publisher.rs | 147 +++++++++++++++- crates/trusted-server-core/src/settings.rs | 9 +- trusted-server.toml | 8 +- 9 files changed, 496 insertions(+), 109 deletions(-) diff --git a/crates/js/lib/src/core/types.ts b/crates/js/lib/src/core/types.ts index a4167cd3..31f66d0a 100644 --- a/crates/js/lib/src/core/types.ts +++ b/crates/js/lib/src/core/types.ts @@ -29,6 +29,25 @@ export interface AuctionSlot { targeting?: Record; } +/** Debug-only copy of server-side bid fields exposed for pipeline inspection. */ +export interface AuctionDebugBidData { + slot_id?: string; + price?: number | null; + currency?: string; + creative?: string | null; + adomain?: string[] | null; + bidder?: string; + width?: number; + height?: number; + nurl?: string | null; + burl?: string | null; + ad_id?: string | null; + cache_id?: string | null; + cache_host?: string | null; + cache_path?: string | null; + metadata?: Record; +} + /** Bid targeting data from the server-side auction, injected into `window.tsjs.bids`. */ export interface AuctionBidData { hb_pb?: string; @@ -40,6 +59,8 @@ export interface AuctionBidData { burl?: string; /** Raw creative markup. Only present when `[debug] inject_adm_for_testing = true`. */ adm?: string; + /** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */ + debug_bid?: AuctionDebugBidData; } export interface TsjsApi { diff --git a/crates/js/lib/src/integrations/gpt/index.test.ts b/crates/js/lib/src/integrations/gpt/index.test.ts index 2de0cc73..af57772b 100644 --- a/crates/js/lib/src/integrations/gpt/index.test.ts +++ b/crates/js/lib/src/integrations/gpt/index.test.ts @@ -10,7 +10,7 @@ const _origWindowAddEventListener = window.addEventListener.bind(window); (window as any).addEventListener = function ( type: string, handler: EventListenerOrEventListenerObject, - options?: unknown, + options?: unknown ) { if (type === 'message') { allMessageHandlers.push(handler as EventListener); @@ -29,6 +29,7 @@ interface SlotRenderEvent { type TestWindow = Window & { googletag?: unknown; + apstag?: { setDisplayBids?: () => void }; // Typed as `any` to avoid the TypeScript intersection with the global // Window.tsjs declaration (TsjsApi from core/types.ts), which would require // every test fixture to satisfy the full TsjsApi shape. @@ -102,7 +103,7 @@ describe('installTsAdInit', () => { burl: 'https://ssp/bill', }, }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; const fetchSpy = vi.spyOn(global, 'fetch'); @@ -123,6 +124,61 @@ describe('installTsAdInit', () => { fetchSpy.mockRestore(); }); + it('keeps the GAM path when debug adm is present', async () => { + const slotEl = document.getElementById('div-atf-sidebar')!; + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('div-atf-sidebar'), + getTargeting: vi.fn().mockReturnValue(['debug-uuid']), + }; + const mockPubads = { + enableSingleRequest: vi.fn(), + getSlots: vi.fn().mockReturnValue([mockSlot]), + addEventListener: vi.fn(), + refresh: vi.fn(), + }; + const destroySlots = vi.fn(); + (window as TestWindow).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + destroySlots, + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + }; + (window as TestWindow).tsjs = { + adSlots: [ + { + id: 'atf_sidebar_ad', + gam_unit_path: '/123/atf', + div_id: 'div-atf-sidebar', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ], + bids: { + atf_sidebar_ad: { + hb_pb: '0.20', + hb_bidder: 'mocktioneer', + hb_adid: 'debug-uuid', + adm: '
Debug creative
', + }, + }, + }; + + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as TestWindow).tsjs!.adInit!(); + + expect(slotEl.innerHTML).toBe(''); + expect(destroySlots).not.toHaveBeenCalledWith([mockSlot]); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '0.20'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'mocktioneer'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_adid', 'debug-uuid'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockPubads.refresh).toHaveBeenCalledWith([mockSlot]); + }); + it('fires both nurl and burl via sendBeacon on slotRenderEnded when our bid won', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); let capturedListener: ((e: SlotRenderEvent) => void) | undefined; @@ -350,7 +406,7 @@ describe('installTsAdInit', () => { it('calls apstag.setDisplayBids when hb_bidder is aps', async () => { const setDisplayBidsSpy = vi.fn(); - (window as any).apstag = { setDisplayBids: setDisplayBidsSpy }; + (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; const mockSlot = { addService: vi.fn().mockReturnThis(), @@ -383,7 +439,7 @@ describe('installTsAdInit', () => { bids: { atf_sidebar_ad: { hb_pb: '1.50', hb_bidder: 'aps', nurl: '', burl: '' }, }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; const { installTsAdInit } = await import('./index'); @@ -392,12 +448,12 @@ describe('installTsAdInit', () => { expect(setDisplayBidsSpy).toHaveBeenCalled(); - delete (window as any).apstag; + delete (window as TestWindow).apstag; }); it('does not call apstag.setDisplayBids when hb_bidder is not aps', async () => { const setDisplayBidsSpy = vi.fn(); - (window as any).apstag = { setDisplayBids: setDisplayBidsSpy }; + (window as TestWindow).apstag = { setDisplayBids: setDisplayBidsSpy }; const mockSlot = { addService: vi.fn().mockReturnThis(), @@ -430,7 +486,7 @@ describe('installTsAdInit', () => { bids: { atf_sidebar_ad: { hb_pb: '1.00', hb_bidder: 'kargo' }, }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; const { installTsAdInit } = await import('./index'); @@ -439,7 +495,7 @@ describe('installTsAdInit', () => { expect(setDisplayBidsSpy).not.toHaveBeenCalled(); - delete (window as any).apstag; + delete (window as TestWindow).apstag; }); it('calls refresh even when tsjs.bids is empty (graceful fallback)', async () => { @@ -500,7 +556,7 @@ describe('installTsRenderBridge', () => { fetchStub = vi.fn(); vi.stubGlobal('fetch', fetchStub); - (window as any).tsjs = { + (window as TestWindow).tsjs = { bids: { homepage_header: { hb_adid: 'test-cache-uuid', @@ -524,7 +580,7 @@ describe('installTsRenderBridge', () => { afterEach(() => { vi.unstubAllGlobals(); - delete (window as any).tsjs; + delete (window as TestWindow).tsjs; }); it('calls stopImmediatePropagation and fetches PBS Cache for a TS bid', async () => { @@ -537,13 +593,15 @@ describe('installTsRenderBridge', () => { // Capture the bridge's 'message' listener at module-init time. let bridgeListener: ((e: MessageEvent) => unknown) | undefined; const origAdd = window.addEventListener.bind(window); - const addSpy = vi.spyOn(window, 'addEventListener').mockImplementation( - (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { - if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - origAdd(type, handler as EventListener, opts as any); - }, - ); + const addSpy = vi + .spyOn(window, 'addEventListener') + .mockImplementation( + (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { + if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + origAdd(type, handler as EventListener, opts as any); + } + ); await import('./index'); addSpy.mockRestore(); // Restore only addEventListener — fetchStub must stay stubbed @@ -560,7 +618,7 @@ describe('installTsRenderBridge', () => { data: JSON.stringify({ message: 'Prebid Request', adId: 'test-cache-uuid' }), ports: [fakePort], stopImmediatePropagation: stopSpy, - }) as unknown as MessageEvent, + }) as unknown as MessageEvent ); // Flush microtasks so the fetch mock resolves and .then chains fire. @@ -568,7 +626,7 @@ describe('installTsRenderBridge', () => { expect(fetchStub).toHaveBeenCalledWith( 'https://openads.example.com/cache?uuid=test-cache-uuid', - { mode: 'cors' }, + { mode: 'cors' } ); expect(stopSpy).toHaveBeenCalled(); expect(portMessages).toHaveLength(1); @@ -579,6 +637,70 @@ describe('installTsRenderBridge', () => { expect(parsed.ad).toBe(mockAd); }); + it('responds with adm without fetching PBS Cache when debug adm is available', async () => { + const debugAdm = '
Debug Creative
'; + (window as TestWindow).tsjs = { + bids: { + homepage_header: { + hb_adid: 'debug-adid', + hb_bidder: 'mocktioneer', + hb_pb: '0.20', + adm: debugAdm, + }, + }, + adSlots: [ + { + id: 'homepage_header', + formats: [[728, 90]] as [number, number][], + gam_unit_path: '/a/b/c', + div_id: 'div-header', + targeting: {}, + }, + ], + }; + + let bridgeListener: ((e: MessageEvent) => unknown) | undefined; + const origAdd = window.addEventListener.bind(window); + const addSpy = vi + .spyOn(window, 'addEventListener') + .mockImplementation( + (type: string, handler: EventListenerOrEventListenerObject, opts?: unknown) => { + if (type === 'message') bridgeListener = handler as (e: MessageEvent) => unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + origAdd(type, handler as EventListener, opts as any); + } + ); + await import('./index'); + addSpy.mockRestore(); + + expect(bridgeListener, 'bridge listener should be registered').toBeDefined(); + + const stopSpy = vi.fn(); + const portMessages: string[] = []; + const fakePort = { postMessage: (s: string) => portMessages.push(s) }; + + bridgeListener!( + Object.assign(new Event('message'), { + data: JSON.stringify({ message: 'Prebid Request', adId: 'debug-adid' }), + ports: [fakePort], + stopImmediatePropagation: stopSpy, + }) as unknown as MessageEvent + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchStub).not.toHaveBeenCalled(); + expect(stopSpy).toHaveBeenCalled(); + expect(portMessages).toHaveLength(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = JSON.parse(portMessages[0]) as Record; + expect(parsed.message).toBe('Prebid Response'); + expect(parsed.adId).toBe('debug-adid'); + expect(parsed.ad).toBe(debugAdm); + expect(parsed.width).toBe(728); + expect(parsed.height).toBe(90); + }); + it('ignores message when adId does not match any TS bid', async () => { await import('./index'); fetchStub.mockResolvedValue({ ok: true, text: () => Promise.resolve('') } as Response); @@ -587,7 +709,7 @@ describe('installTsRenderBridge', () => { new MessageEvent('message', { data: JSON.stringify({ message: 'Prebid Request', adId: 'unknown-id' }), ports: [], - }), + }) ); await new Promise((r) => setTimeout(r, 100)); @@ -597,7 +719,7 @@ describe('installTsRenderBridge', () => { it('ignores non-Prebid messages', async () => { await import('./index'); window.dispatchEvent( - new MessageEvent('message', { data: JSON.stringify({ message: 'Other' }) }), + new MessageEvent('message', { data: JSON.stringify({ message: 'Other' }) }) ); await new Promise((r) => setTimeout(r, 50)); expect(fetchStub).not.toHaveBeenCalled(); diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 34acacbd..f893148b 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -1,4 +1,5 @@ import { log } from '../../core/log'; +import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; import { installGptGuard } from './script_guard'; @@ -186,8 +187,6 @@ export function installGptShim(): boolean { // Trusted Server ad-init // ------------------------------------------------------------------ -import type { AuctionSlot, AuctionBidData, TsjsApi } from '../../core/types'; - /** * Install `window.tsjs.adInit`. * @@ -234,15 +233,11 @@ export function installTsAdInit(): void { document.querySelector(`[id^='${slot.div_id}']:not([id$='-container'])`); if (!el) return; const actualDivId = el.id; + const bid = bids[slot.id] ?? {}; - // Reuse publisher's existing GPT slot when present — avoids duplicate - // slot definitions on the same div and lets TS inject bid targeting - // onto the slot the publisher already configured in GAM. - // If no publisher slot exists yet (async framework race), fall back to - // defining TS's own slot so bid targeting still reaches GAM. - const existingSlot = g.pubads!().getSlots?.()?.find?.( - (s: GoogleTagSlot) => s.getSlotElementId() === actualDivId, - ); + const existingSlot = g.pubads!() + .getSlots?.() + ?.find?.((s: GoogleTagSlot) => s.getSlotElementId() === actualDivId); let gptSlot: GoogleTagSlot; let tsOwned = false; if (existingSlot) { @@ -260,20 +255,11 @@ export function installTsAdInit(): void { tsOwned = true; } - // Debug: if adm is present, inject creative directly into div and skip GAM. - // Only populated when [debug] inject_adm_for_testing = true in config. - const bid = bids[slot.id] ?? {}; - if (bid.adm && el) { - el.innerHTML = String(bid.adm); - divToSlotId[actualDivId] = slot.id; - return; - } - Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); (['hb_pb', 'hb_bidder', 'hb_adid', 'hb_cache_host', 'hb_cache_path'] as const).forEach( (key) => { if (bid[key]) gptSlot.setTargeting(key, String(bid[key]!)); - }, + } ); gptSlot.setTargeting(TS_INITIAL_TARGETING_KEY, '1'); divToSlotId[actualDivId] = slot.id; @@ -426,8 +412,8 @@ const TS_DISPLAY_RENDERER = * post-load would miss first-impression `"Prebid Request"` messages. * * When `adId` matches a TS server-side bid in `window.tsjs.bids` AND the bid - * has PBS Cache coordinates, the bridge: - * 1. Fetches the ad markup from PBS Cache. + * has renderable markup, the bridge: + * 1. Uses debug `adm` directly when present, otherwise fetches from PBS Cache. * 2. Replies via the MessageChannel port with a `"Prebid Response"`. * 3. Calls `stopImmediatePropagation()` so Prebid.js does not also process * the message and log spurious failures. @@ -469,14 +455,34 @@ export function installTsRenderBridge(): void { } // Not a TS bid — let Prebid.js handle it. - if (!slotId || !matchedBid?.hb_cache_host || !matchedBid?.hb_cache_path) return; + if (!slotId || !matchedBid) return; + + const slot = window.tsjs?.adSlots?.find((s) => s.id === slotId); + const [width, height] = slot?.formats?.[0] ?? [728, 90]; + + if (matchedBid.adm) { + e.stopImmediatePropagation(); + port.postMessage( + JSON.stringify({ + message: 'Prebid Response', + adId, + ad: matchedBid.adm, + renderer: TS_DISPLAY_RENDERER, + width, + height, + }) + ); + log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from debug adm`); + return; + } + + // No TS render source — let Prebid.js handle it. + if (!matchedBid.hb_cache_host || !matchedBid.hb_cache_path) return; // TS owns this adId — stop Prebid from also processing it. e.stopImmediatePropagation(); const cacheUrl = `https://${matchedBid.hb_cache_host}${matchedBid.hb_cache_path}?uuid=${encodeURIComponent(adId)}`; - const slot = window.tsjs?.adSlots?.find((s) => s.id === slotId); - const [width, height] = slot?.formats?.[0] ?? [728, 90]; fetch(cacheUrl, { mode: 'cors' }) .then((res) => (res.ok ? res.text() : Promise.reject(res.status))) @@ -489,7 +495,7 @@ export function installTsRenderBridge(): void { renderer: TS_DISPLAY_RENDERER, width, height, - }), + }) ); log.debug(`[tsjs-gpt] pbRender bridge served '${slotId}' from PBS Cache`); }) diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index 62c49ee9..1670c6f7 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -349,7 +349,17 @@ export function installPrebidNpm(config?: Partial): typeof pbjs */ export function installRefreshHandler(timeoutMs = 1500): void { if (typeof window === 'undefined') return; - const g = (window as unknown as { googletag?: { cmd?: { push(fn: () => void): void }; pubads?(): { refresh(slots?: unknown[], opts?: unknown): void; getTargeting?(key: string): string[] } } }).googletag; + const g = ( + window as unknown as { + googletag?: { + cmd?: { push(fn: () => void): void }; + pubads?(): { + refresh(slots?: unknown[], opts?: unknown): void; + getTargeting?(key: string): string[]; + }; + }; + } + ).googletag; if (!g?.cmd) return; g.cmd.push(() => { @@ -365,10 +375,9 @@ export function installRefreshHandler(timeoutMs = 1500): void { const targetSlots: any[] = slots ?? (pubads as any).getSlots?.() ?? []; // Filter out TS first-impression slots — they don't need client-side refresh auctions. - // eslint-disable-next-line @typescript-eslint/no-explicit-any const nonTsSlots = targetSlots.filter( // eslint-disable-next-line @typescript-eslint/no-explicit-any - (s: any) => !s.getTargeting?.('ts_initial')?.includes('1'), + (s: any) => !s.getTargeting?.('ts_initial')?.includes('1') ); if (!nonTsSlots.length) { @@ -379,7 +388,14 @@ export function installRefreshHandler(timeoutMs = 1500): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any const adUnits = nonTsSlots.map((s: any) => ({ code: s.getSlotElementId?.() ?? s, - mediaTypes: { banner: { sizes: [[728, 90], [300, 250]] as [number, number][] } }, + mediaTypes: { + banner: { + sizes: [ + [728, 90], + [300, 250], + ] as [number, number][], + }, + }, bids: [{ bidder: ADAPTER_CODE, params: { zone: 'refresh' } }], })); diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 149753d7..483e4499 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -89,7 +89,7 @@ impl IntegrationConfig for AdServerMockConfig { // ============================================================================ /// Lookup index built from original SSP bids during `request_bids`, consumed -/// during `parse_response` to restore `nurl`/`burl`/`ad_id` that the mock +/// during `parse_response` to restore render/accounting fields that the mock /// mediator endpoint does not echo back. /// /// Keyed by `(provider_name, slot_id, bidder_name)`. @@ -98,7 +98,7 @@ type BidIndex = HashMap<(String, String, String), Bid>; /// Mock ad server mediator provider. pub struct AdServerMockProvider { config: AdServerMockConfig, - /// Bridges SSP bid metadata (`nurl`/`burl`/`ad_id`) from `request_bids` to `parse_response`. + /// Bridges SSP bid metadata from `request_bids` to `parse_response`. bid_index: Mutex>, } @@ -226,7 +226,7 @@ impl AdServerMockProvider { /// Mediation returns decoded prices for all bids (including APS bids that were encoded). /// /// `bid_index` is the SSP-bid lookup built in `request_bids`. The mock mediator - /// does not echo `nurl`/`burl`/`ad_id` back, so they are restored from the index + /// does not echo render/accounting fields back, so they are restored from the index /// using `(seat, impid, bidder)` where bidder is recovered from the echoed `crid` /// field (`"{bidder}-creative"` format set during request construction). fn parse_mediation_response( @@ -249,16 +249,18 @@ impl AdServerMockProvider { let slot_id = bid["impid"].as_str().unwrap_or("").to_string(); // Recover bidder name from crid ("{bidder}-creative") to look up the - // original SSP bid and restore nurl/burl/ad_id the mediator drops. + // original SSP bid and restore render/accounting fields the mediator drops. let crid = bid["crid"].as_str().unwrap_or(""); let bidder = crid.strip_suffix("-creative").unwrap_or_else(|| { log::debug!( - "adserver_mock: crid '{crid}' does not match '-creative' — dropping nurl/burl/ad_id" + "adserver_mock: crid '{crid}' does not match '-creative'; render/accounting fields may be missing" ); "" }); let key = (seat_name.to_string(), slot_id.clone(), bidder.to_string()); let original = bid_index.get(&key); + let restored_bidder = + original.map_or_else(|| seat_name.to_string(), |b| b.bidder.clone()); let width = bid["w"].as_u64().unwrap_or(0) as u32; let height = bid["h"].as_u64().unwrap_or(0) as u32; @@ -276,7 +278,7 @@ impl AdServerMockProvider { creative: bid["adm"].as_str().map(String::from), width, height, - bidder: seat_name.to_string(), + bidder: restored_bidder, adomain: bid["adomain"].as_array().map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) @@ -285,9 +287,9 @@ impl AdServerMockProvider { nurl: original.and_then(|b| b.nurl.clone()), burl: original.and_then(|b| b.burl.clone()), ad_id: original.and_then(|b| b.ad_id.clone()), - cache_id: None, - cache_host: None, - cache_path: None, + cache_id: original.and_then(|b| b.cache_id.clone()), + cache_host: original.and_then(|b| b.cache_host.clone()), + cache_path: original.and_then(|b| b.cache_path.clone()), metadata: HashMap::new(), }); } @@ -665,6 +667,98 @@ mod tests { assert_eq!(bid.height, 90); } + #[test] + fn parse_mediation_response_restores_original_bid_render_fields() { + let provider = AdServerMockProvider::new(AdServerMockConfig::default()); + let mediation_response = json!({ + "id": "test-auction-123", + "seatbid": [ + { + "seat": "prebid", + "bid": [ + { + "id": "mediated-bid-001", + "impid": "header-banner", + "price": 0.20, + "adm": "
Mediated Ad
", + "w": 728, + "h": 90, + "crid": "mocktioneer-creative", + "adomain": ["example.com"] + } + ] + } + ], + "cur": "USD" + }); + let mut bid_index = BidIndex::new(); + bid_index.insert( + ( + "prebid".to_string(), + "header-banner".to_string(), + "mocktioneer".to_string(), + ), + Bid { + slot_id: "header-banner".to_string(), + price: Some(0.20), + currency: "USD".to_string(), + creative: Some("
Original Ad
".to_string()), + adomain: Some(vec!["example.com".to_string()]), + bidder: "mocktioneer".to_string(), + width: 728, + height: 90, + nurl: Some("https://ssp.example/win".to_string()), + burl: Some("https://ssp.example/bill".to_string()), + ad_id: Some("bid-impression-id".to_string()), + cache_id: Some("cache-uuid".to_string()), + cache_host: Some("cache.example".to_string()), + cache_path: Some("/cache".to_string()), + metadata: HashMap::new(), + }, + ); + + let auction_response = + provider.parse_mediation_response(&mediation_response, 42, &bid_index); + + assert_eq!(auction_response.status, BidStatus::Success); + assert_eq!(auction_response.bids.len(), 1); + let bid = &auction_response.bids[0]; + assert_eq!( + bid.bidder, "mocktioneer", + "should preserve underlying bidder for hb_bidder targeting" + ); + assert_eq!( + bid.nurl.as_deref(), + Some("https://ssp.example/win"), + "should restore nurl" + ); + assert_eq!( + bid.burl.as_deref(), + Some("https://ssp.example/bill"), + "should restore burl" + ); + assert_eq!( + bid.ad_id.as_deref(), + Some("bid-impression-id"), + "should restore ad_id" + ); + assert_eq!( + bid.cache_id.as_deref(), + Some("cache-uuid"), + "should restore PBS cache UUID" + ); + assert_eq!( + bid.cache_host.as_deref(), + Some("cache.example"), + "should restore PBS cache host" + ); + assert_eq!( + bid.cache_path.as_deref(), + Some("/cache"), + "should restore PBS cache path" + ); + } + #[test] fn test_parse_empty_mediation_response() { let config = AdServerMockConfig::default(); diff --git a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js index b3293b48..0c7ea0dd 100644 --- a/crates/trusted-server-core/src/integrations/gpt_bootstrap.js +++ b/crates/trusted-server-core/src/integrations/gpt_bootstrap.js @@ -23,6 +23,7 @@ var slots = ts.adSlots || []; var bids = ts.bids || {}; var divToSlotId = {}; + googletag.cmd.push(function () { // Slots TS defined itself — tracked for SPA destroy. Publisher-owned // slots are reused but never destroyed by TS on navigation. @@ -40,12 +41,8 @@ ); if (!el) return; var actualDivId = el.id; + var b = bids[slot.id] || {}; - // Reuse publisher's existing GPT slot when present — avoids duplicate - // slot definitions on the same div and lets TS inject bid targeting - // onto the slot the publisher already configured in GAM. - // If no publisher slot exists yet (async framework race), fall back to - // defining TS's own slot so bid targeting still reaches GAM. var existingSlots = googletag.pubads().getSlots(); var s = existingSlots.find(function (gs) { @@ -64,23 +61,18 @@ tsOwned = true; } - // Debug: if adm is present, inject creative directly into div and skip GAM. - // Only populated when [debug] inject_adm_for_testing = true in config. - var b = bids[slot.id] || {}; - if (b.adm) { - el.innerHTML = b.adm; - divToSlotId[actualDivId] = slot.id; - return; - } - Object.entries(slot.targeting || {}).forEach(function (e) { s.setTargeting(e[0], e[1]); }); - ["hb_pb", "hb_bidder", "hb_adid", "hb_cache_host", "hb_cache_path"].forEach( - function (k) { - if (b[k]) s.setTargeting(k, b[k]); - }, - ); + [ + "hb_pb", + "hb_bidder", + "hb_adid", + "hb_cache_host", + "hb_cache_path", + ].forEach(function (k) { + if (b[k]) s.setTargeting(k, b[k]); + }); // Keep in sync with TS_INITIAL_TARGETING_KEY in index.ts s.setTargeting("ts_initial", "1"); divToSlotId[actualDivId] = slot.id; @@ -93,23 +85,21 @@ googletag.pubads().enableSingleRequest(); googletag.enableServices(); ts.servicesEnabled = true; - googletag - .pubads() - .addEventListener("slotRenderEnded", function (ev) { - var divId = ev.slot.getSlotElementId(); - var slotId = (ts.divToSlotId || {})[divId]; - if (!slotId) return; - var b = (ts.bids || {})[slotId] || {}; - var ourBidWon = - !ev.isEmpty && - (b.hb_adid - ? ev.slot.getTargeting("hb_adid")[0] === b.hb_adid - : !!b.hb_bidder); - if (ourBidWon) { - if (b.nurl) navigator.sendBeacon(b.nurl); - if (b.burl) navigator.sendBeacon(b.burl); - } - }); + googletag.pubads().addEventListener("slotRenderEnded", function (ev) { + var divId = ev.slot.getSlotElementId(); + var slotId = (ts.divToSlotId || {})[divId]; + if (!slotId) return; + var b = (ts.bids || {})[slotId] || {}; + var ourBidWon = + !ev.isEmpty && + (b.hb_adid + ? ev.slot.getTargeting("hb_adid")[0] === b.hb_adid + : !!b.hb_bidder); + if (ourBidWon) { + if (b.nurl) navigator.sendBeacon(b.nurl); + if (b.burl) navigator.sendBeacon(b.burl); + } + }); } if (slotsToRefresh.length > 0) { googletag.pubads().refresh(slotsToRefresh); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c40fa851..12a368c4 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1323,7 +1323,7 @@ fn html_escape_for_script(s: &str) -> String { pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, - inject_adm: bool, + include_adm: bool, ) -> serde_json::Map { winning_bids .iter() @@ -1368,12 +1368,32 @@ pub(crate) fn build_bid_map( if let Some(ref burl) = bid.burl { obj.insert("burl".to_string(), serde_json::Value::String(burl.clone())); } - // Debug: include raw creative markup so adInit() can bypass GAM. - // Only set when inject_adm_for_testing is enabled — never in production. - if inject_adm { + // Include raw creative markup only for explicit debug injection. + // The pbRender bridge can use it while PBS Cache is unavailable. + if include_adm { if let Some(ref adm) = bid.creative { obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); } + obj.insert( + "debug_bid".to_string(), + serde_json::json!({ + "slot_id": bid.slot_id, + "price": bid.price, + "currency": bid.currency, + "creative": bid.creative, + "adomain": bid.adomain, + "bidder": bid.bidder, + "width": bid.width, + "height": bid.height, + "nurl": bid.nurl, + "burl": bid.burl, + "ad_id": bid.ad_id, + "cache_id": bid.cache_id, + "cache_host": bid.cache_host, + "cache_path": bid.cache_path, + "metadata": bid.metadata, + }), + ); } (slot_id.clone(), serde_json::Value::Object(obj)) }) @@ -2753,6 +2773,125 @@ mod tests { ); } + #[test] + fn client_bid_map_omits_adm_by_default() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + + assert!( + obj.get("adm").is_none(), + "should omit adm when debug injection is disabled" + ); + assert!( + obj.get("debug_bid").is_none(), + "should omit debug bid when debug injection is disabled" + ); + } + + #[test] + fn client_bid_map_includes_adm_when_debug_injection_enabled() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + + assert_eq!( + obj.get("adm").and_then(|v| v.as_str()), + Some("
Creative
"), + "should include adm when debug injection is enabled" + ); + } + + #[test] + fn client_bid_map_includes_debug_bid_when_debug_injection_enabled() { + let mut winning_bids = HashMap::new(); + let mut bid = make_bid( + "atf_sidebar_ad", + 1.50, + "mocktioneer", + "bid-ad-id", + "https://ssp/win", + "https://ssp/bill", + ); + bid.creative = Some("
Creative
".to_string()); + bid.adomain = Some(vec!["example.com".to_string()]); + bid.cache_id = Some("cache-uuid".to_string()); + bid.cache_host = Some("cache.example".to_string()); + bid.cache_path = Some("/cache".to_string()); + bid.metadata.insert( + "raw_field".to_string(), + serde_json::Value::String("raw-value".to_string()), + ); + winning_bids.insert("atf_sidebar_ad".to_string(), bid); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); + let obj = map + .get("atf_sidebar_ad") + .expect("should have bid entry") + .as_object() + .expect("should be object"); + let debug_bid = obj + .get("debug_bid") + .and_then(|v| v.as_object()) + .expect("should include debug bid when debug injection is enabled"); + + assert_eq!( + debug_bid.get("slot_id").and_then(|v| v.as_str()), + Some("atf_sidebar_ad"), + "should expose original slot id" + ); + assert_eq!( + debug_bid.get("bidder").and_then(|v| v.as_str()), + Some("mocktioneer"), + "should expose original bidder" + ); + assert_eq!( + debug_bid.get("ad_id").and_then(|v| v.as_str()), + Some("bid-ad-id"), + "should expose original bid ad id" + ); + assert_eq!( + debug_bid.get("cache_id").and_then(|v| v.as_str()), + Some("cache-uuid"), + "should expose original PBS cache id" + ); + assert_eq!( + debug_bid.get("metadata").and_then(|v| v.get("raw_field")), + Some(&serde_json::Value::String("raw-value".to_string())), + "should expose provider metadata" + ); + } + #[test] fn bid_map_uses_cache_id_for_hb_adid_when_present() { let mut winning_bids = HashMap::new(); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index ae915d77..b221e0ea 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -417,11 +417,12 @@ pub struct DebugConfig { #[serde(default)] pub auction_html_comment: bool, - /// Include raw `adm` creative markup in `window.tsjs.bids` and have - /// `adInit()` render it directly into the slot div, bypassing GAM entirely. + /// Include raw `adm` creative markup in `window.tsjs.bids` for GPT/GAM + /// debug rendering through the Prebid Universal Creative bridge. /// - /// Use this to validate the full auction→creative pipeline without GAM - /// line items. Never enable in production — injects raw HTML from SSPs. + /// Use this to validate the server-side auction→GAM targeting→creative + /// rendering pipeline while PBS Cache is unavailable. Never enable in + /// production — injects raw HTML from SSPs. #[serde(default)] pub inject_adm_for_testing: bool, } diff --git a/trusted-server.toml b/trusted-server.toml index d569349e..ed86a2df 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -214,10 +214,9 @@ timeout_ms = 1000 # Visible in page source. Disable after investigation. # auction_html_comment = true # -# Inject raw adm creative markup into window.tsjs.bids and render it directly -# into slot divs, bypassing GAM entirely. Use to validate the full -# auction→creative pipeline without GAM line items. NEVER enable in production. -# inject_adm_for_testing = true +# Inject raw adm creative markup into window.tsjs.bids for GPT/GAM bridge +# debugging while PBS Cache is unavailable. NEVER enable in production. +inject_adm_for_testing = true # Enable the JA4/TLS fingerprint debug endpoint at GET /_ts/debug/ja4. # Returns a plain-text response with the following fields (Fastly-observed values): # ja4 — JA4 TLS client fingerprint @@ -298,4 +297,3 @@ zone = "fixedBottom" [creative_opportunities.slot.providers.aps] slot_id = "aps-slot-homepage-footer" -