From 428d4ed1c2cfce668dc9741741756df78144135b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 29 May 2026 18:06:06 +0530 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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**