diff --git a/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md b/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md new file mode 100644 index 00000000..bffedbf6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-server-side-ad-templates.md @@ -0,0 +1,2399 @@ +# Server-Side Ad Templates 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:** Enable the Fastly edge to fire a full header-bidding auction (PBS + APS) in parallel with the origin fetch, injecting `window.__ts_ad_slots` and `window.__ts_request_id` into ``, then serving cached bid results via a new `/ts-bids` endpoint so the client can drive GPT directly — without Prebid.js and without blocking page rendering. + +**Architecture:** A new `creative-opportunities.toml` holds per-URL slot templates. At request time, the publisher path matches the URL, mints a UUID (`request_id`), and fires the auction + origin fetch concurrently via `send_async()`. Only `window.__ts_ad_slots` and `window.__ts_request_id` are injected at `` open — **`` flushes immediately with no auction wait**. When the auction completes, results are stored in a new in-process `BidCache` keyed by `request_id`. The browser's tsjs bundle fetches `GET /ts-bids?rid=` to retrieve bid targeting; this endpoint long-polls until the auction completes or the deadline fires. + +**Tech Stack:** Rust 2024, `lol_html` 2.7.2 (existing), `glob` crate (new workspace dep), `serde`/`toml` (existing), `uuid` v4 (existing workspace dep), `std::sync::Mutex` + `std::time::Instant` for in-process cache (30s TTL), `AuctionOrchestrator::run_auction` (existing `async fn`), TypeScript for GPT shim extension. + +--- + +## File Map + +### New files + +| File | Responsibility | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `creative-opportunities.toml` | Slot template definitions (page patterns, formats, floor prices, per-provider params) | +| `crates/trusted-server-core/src/creative_opportunities.rs` | Config types, TOML parsing, URL glob matching, slot→`AdSlot` conversion, startup validation | +| `crates/trusted-server-core/src/price_bucket.rs` | Prebid price granularity tables; converts `f64` CPM to `hb_pb` string | +| `crates/trusted-server-core/src/bid_cache.rs` | In-process auction result cache keyed by `request_id`; 30s TTL; long-poll via blocking poll | + +### Modified files + +| File | Change summary | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Cargo.toml` | Add `glob = "0.3"` to `[workspace.dependencies]` | +| `crates/trusted-server-core/Cargo.toml` | Add `glob = { workspace = true }` | +| `crates/trusted-server-core/src/auction/types.rs` | Add `MediaType::banner()` constructor; add `ad_id: Option` to `Bid` | +| `crates/trusted-server-core/src/settings.rs` | Add `creative_opportunities: Option` to `Settings` | +| `trusted-server.toml` | Add `[creative_opportunities]` section | +| `crates/trusted-server-core/build.rs` | Validate slot IDs at build time using inline regex (no module import) | +| `crates/trusted-server-core/src/html_processor.rs` | Add `ad_slots_script: Option` (contains both `__ts_ad_slots` + `__ts_request_id`) to `HtmlProcessorConfig`; inject at head-open only — **no `` hold** | +| `crates/trusted-server-core/src/publisher.rs` | Convert `handle_publisher_request` to `async fn`; add `orchestrator` + `bid_cache` params; fire auction + origin concurrently; write result to `bid_cache`; inject head globals; set `Cache-Control` when slots matched | +| `crates/trusted-server-adapter-fastly/src/main.rs` | Await the now-async handler; pass orchestrator + bid_cache references; add `/ts-bids` route handler (long-poll, returns bid JSON from `bid_cache`) | +| `crates/trusted-server-core/src/integrations/gpt.rs` | Extend `head_inserts()` to emit `__tsAdInit` that fetches `/ts-bids?rid=` and applies bid targeting after resolution | +| `crates/js/lib/src/integrations/gpt/index.ts` | Add `installTsAdInit` with `/ts-bids` fetch + `bidsPromise` pattern + `slotRenderEnded` burl-firing logic | +| `crates/trusted-server-core/src/integrations/prebid.rs` | Add `fire_nurl_at_edge` config key; fire nurl fire-and-forget after auction | + +--- + +## Task 1: Add `glob` workspace dependency + +**Files:** + +- Modify: `Cargo.toml` +- Modify: `crates/trusted-server-core/Cargo.toml` + +- [ ] **Step 1: Write a failing compile test** + + In `crates/trusted-server-core/src/lib.rs`, temporarily add: + + ```rust + // Compilation test — remove after Step 4 + use glob::Pattern as _; + ``` + + Run: `cargo check --package trusted-server-core` + Expected: error `use of undeclared crate or module 'glob'` + +- [ ] **Step 2: Add glob to workspace `Cargo.toml`** + + Under `[workspace.dependencies]`, add: + + ```toml + glob = "0.3" + ``` + +- [ ] **Step 3: Add glob to core crate** + + In `crates/trusted-server-core/Cargo.toml` under `[dependencies]`, add: + + ```toml + glob = { workspace = true } + ``` + +- [ ] **Step 4: Remove temp import, verify compile** + + Remove the temp `use glob::Pattern as _;` from `lib.rs`. + Run: `cargo check --package trusted-server-core` + Expected: clean compile + +- [ ] **Step 5: Commit** + + ```bash + git add Cargo.toml crates/trusted-server-core/Cargo.toml + git commit -m "Add glob workspace dependency for URL pattern matching" + ``` + +--- + +## Task 2: `price_bucket.rs` — Prebid price granularity + +**Files:** + +- Create: `crates/trusted-server-core/src/price_bucket.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` + +The `hb_pb` value in bid responses is a discretized bucket string from Prebid's granularity tables. "Dense" is the default used in most Prebid deployments. + +- [ ] **Step 1: Write failing tests** + + Create `crates/trusted-server-core/src/price_bucket.rs` with only the tests: + + ```rust + //! Prebid price granularity bucketing. + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn dense_below_3_increments_by_0_01() { + assert_eq!(price_bucket(0.0, PriceGranularity::Dense), "0.00"); + assert_eq!(price_bucket(0.01, PriceGranularity::Dense), "0.01"); + assert_eq!(price_bucket(0.015, PriceGranularity::Dense), "0.01"); + assert_eq!(price_bucket(1.23, PriceGranularity::Dense), "1.23"); + assert_eq!(price_bucket(2.99, PriceGranularity::Dense), "2.99"); + } + + #[test] + fn dense_3_to_8_increments_by_0_05() { + assert_eq!(price_bucket(3.00, PriceGranularity::Dense), "3.00"); + assert_eq!(price_bucket(3.03, PriceGranularity::Dense), "3.00"); + assert_eq!(price_bucket(3.05, PriceGranularity::Dense), "3.05"); + assert_eq!(price_bucket(7.99, PriceGranularity::Dense), "7.95"); + } + + #[test] + fn dense_8_to_20_increments_by_0_50() { + assert_eq!(price_bucket(8.00, PriceGranularity::Dense), "8.00"); + assert_eq!(price_bucket(8.49, PriceGranularity::Dense), "8.00"); + assert_eq!(price_bucket(8.50, PriceGranularity::Dense), "8.50"); + assert_eq!(price_bucket(19.99, PriceGranularity::Dense), "19.50"); + } + + #[test] + fn dense_above_20_caps_at_20() { + assert_eq!(price_bucket(20.00, PriceGranularity::Dense), "20.00"); + assert_eq!(price_bucket(50.00, PriceGranularity::Dense), "20.00"); + } + + #[test] + fn low_increments_by_0_50_capped_at_5() { + assert_eq!(price_bucket(0.49, PriceGranularity::Low), "0.00"); + assert_eq!(price_bucket(0.50, PriceGranularity::Low), "0.50"); + assert_eq!(price_bucket(5.01, PriceGranularity::Low), "5.00"); + } + + #[test] + fn medium_increments_by_0_10_capped_at_20() { + assert_eq!(price_bucket(1.05, PriceGranularity::Medium), "1.00"); + assert_eq!(price_bucket(1.10, PriceGranularity::Medium), "1.10"); + assert_eq!(price_bucket(20.5, PriceGranularity::Medium), "20.00"); + } + + #[test] + fn high_increments_by_0_01_capped_at_20() { + assert_eq!(price_bucket(1.234, PriceGranularity::High), "1.23"); + assert_eq!(price_bucket(20.5, PriceGranularity::High), "20.00"); + } + + #[test] + fn auto_routes_through_dense() { + assert_eq!( + price_bucket(2.53, PriceGranularity::Auto), + price_bucket(2.53, PriceGranularity::Dense) + ); + } + } + ``` + + Run: `cargo test -p trusted-server-core price_bucket` + Expected: compile error (module not yet exported from lib.rs) + +- [ ] **Step 2: Implement price_bucket.rs** + + ```rust + //! Prebid price granularity bucketing. + //! + //! Converts a raw CPM to the `hb_pb` price bucket string sent to GAM as targeting. + //! Mirrors Prebid.js built-in granularity tables exactly. + + use serde::{Deserialize, Serialize}; + + /// Prebid price granularity setting. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] + #[serde(rename_all = "lowercase")] + pub enum PriceGranularity { + Low, + Medium, + #[default] + Dense, + High, + Auto, + } + + impl PriceGranularity { + /// Returns the `Dense` variant — used as a `#[serde(default = ...)]` fn pointer. + pub fn dense() -> Self { + Self::Dense + } + } + + /// Convert a raw CPM (`f64`) to the `hb_pb` price bucket string. + pub fn price_bucket(cpm: f64, granularity: PriceGranularity) -> String { + if cpm <= 0.0 { + return "0.00".to_string(); + } + match granularity { + PriceGranularity::Low => { + let capped = cpm.min(5.0); + format!("{:.2}", (capped / 0.50).floor() * 0.50) + } + PriceGranularity::Medium => { + let capped = cpm.min(20.0); + format!("{:.2}", (capped / 0.10).floor() * 0.10) + } + PriceGranularity::High => { + let capped = cpm.min(20.0); + format!("{:.2}", (capped / 0.01).floor() * 0.01) + } + PriceGranularity::Dense | PriceGranularity::Auto => dense_bucket(cpm), + } + } + + fn dense_bucket(cpm: f64) -> String { + if cpm >= 20.0 { + return "20.00".to_string(); + } + if cpm >= 8.0 { + return format!("{:.2}", (cpm / 0.50).floor() * 0.50); + } + if cpm >= 3.0 { + return format!("{:.2}", (cpm / 0.05).floor() * 0.05); + } + format!("{:.2}", (cpm / 0.01).floor() * 0.01) + } + ``` + +- [ ] **Step 3: Export from lib.rs** + + ```rust + pub mod price_bucket; + ``` + +- [ ] **Step 4: Run tests** + + Run: `cargo test -p trusted-server-core price_bucket` + Expected: all tests pass + +- [ ] **Step 5: Commit** + + ```bash + git add crates/trusted-server-core/src/price_bucket.rs \ + crates/trusted-server-core/src/lib.rs + git commit -m "Add Prebid price granularity bucketing (dense default, auto = dense)" + ``` + +--- + +## Task 3: Extend `auction::types` — `MediaType::banner()` and `Bid::ad_id` + +**Files:** + +- Modify: `crates/trusted-server-core/src/auction/types.rs` + +`CreativeOpportunityFormat` uses `#[serde(default = "MediaType::banner")]` which requires a free function. Add `ad_id: Option` to `Bid` for `hb_adid` targeting. + +- [ ] **Step 1: Write failing tests** + + In `auction/types.rs` test module: + + ```rust + #[test] + fn media_type_banner_fn_returns_banner() { + assert_eq!(MediaType::banner(), MediaType::Banner); + } + + #[test] + fn bid_has_ad_id_field() { + let bid = Bid { + slot_id: "s".to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "kargo".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: Some("prebid-ad-id-abc".to_string()), + metadata: Default::default(), + }; + assert_eq!(bid.ad_id.as_deref(), Some("prebid-ad-id-abc")); + } + ``` + + Run: `cargo test -p trusted-server-core auction::types::tests` + Expected: compile error (`MediaType::banner` not found, `ad_id` field missing) + +- [ ] **Step 2: Add `MediaType::banner()` and `Bid::ad_id`** + + In `auction/types.rs`, add to `MediaType`: + + ```rust + impl MediaType { + /// Returns `Banner` — used as a `#[serde(default = ...)]` fn pointer. + pub fn banner() -> Self { + Self::Banner + } + } + ``` + + Add `ad_id: Option` field to `Bid`. Update the `make_bid` test helper to include `ad_id: None`. + +- [ ] **Step 3: Run tests** + + Run: `cargo test -p trusted-server-core auction` + Expected: all tests pass + +- [ ] **Step 4: Update prebid.rs to populate `ad_id`** + + In `crates/trusted-server-core/src/integrations/prebid.rs`, in the `Bid` construction, find where `nurl` and `burl` are set and add: + + ```rust + ad_id: bid_obj.get("adid") + .or_else(|| bid_obj.get("id")) + .and_then(|v| v.as_str()) + .map(String::from), + ``` + + (Prebid Server uses lowercase `adid` in bid objects, not `adId`. Fall back to `id` if absent.) + +- [ ] **Step 5: Commit** + + ```bash + git add crates/trusted-server-core/src/auction/types.rs \ + crates/trusted-server-core/src/integrations/prebid.rs + git commit -m "Add MediaType::banner() constructor and Bid::ad_id for hb_adid targeting" + ``` + +--- + +## Task 4: `creative_opportunities.rs` — Config types and URL matching + +**Files:** + +- Create: `crates/trusted-server-core/src/creative_opportunities.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` + +- [ ] **Step 1: Write failing tests** + + Create `crates/trusted-server-core/src/creative_opportunities.rs` with only tests: + + ```rust + #[cfg(test)] + mod tests { + use super::*; + + fn make_slot(id: &str, patterns: Vec<&str>) -> CreativeOpportunitySlot { + CreativeOpportunitySlot { + id: id.to_string(), + gam_unit_path: None, + div_id: None, + page_patterns: patterns.into_iter().map(String::from).collect(), + 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(), + } + } + + #[test] + fn glob_matches_article_path() { + let slot = make_slot("atf", vec!["/20**"]); + assert!(slot.matches_path("/2024/01/my-article/"), "should match article path"); + assert!(!slot.matches_path("/"), "should not match root"); + } + + #[test] + fn exact_match_homepage() { + let slot = make_slot("home", vec!["/"]); + assert!(slot.matches_path("/"), "should match root"); + assert!(!slot.matches_path("/about"), "should not match /about"); + } + + #[test] + fn slot_id_validates_alphanumeric() { + assert!(validate_slot_id("atf_sidebar_ad").is_ok()); + assert!(validate_slot_id("below-content-0").is_ok()); + assert!(validate_slot_id("").is_err(), "empty id should fail"); + assert!(validate_slot_id("xss"# + .to_string() + ), + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"T", true) + .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"); + assert!(html.contains("window.__ts_request_id"), "should inject request_id at head-open"); + } + + #[test] + fn does_not_hold_end_of_head() { + // Verify: no bid data appears before — that hold was rejected by spec §4.3 + 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: None, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"T", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!(!html.contains("__ts_bids"), "must not inject bids into head"); + } + ``` + + Run: `cargo test -p trusted-server-core html_processor` + Expected: compile error (no `ad_slots_script` field, no `empty_for_tests()`) + +- [ ] **Step 2: Add `empty_for_tests()` to `IntegrationRegistry`** + + In `registry.rs`, add: + + ```rust + #[cfg(test)] + impl IntegrationRegistry { + pub fn empty_for_tests() -> Self { + Self { + inner: Arc::new(RegistryInner { + proxies: Default::default(), + attribute_rewriters: Default::default(), + script_rewriters: Vec::new(), + html_post_processors: Vec::new(), + head_injectors: Vec::new(), + metadata: Default::default(), + }) + } + } + } + ``` + + (Adjust field names to match the actual `RegistryInner` struct.) + +- [ ] **Step 3: Add single field to `HtmlProcessorConfig`** + + Replace any existing `ad_slots_script`/`ad_bids_script` fields with: + + ```rust + pub struct HtmlProcessorConfig { + pub origin_host: String, + pub request_host: String, + pub request_scheme: String, + pub integrations: IntegrationRegistry, + /// Pre-computed ``. + /// Injected at `` open, before integration head inserts. `None` when no slots matched. + pub ad_slots_script: Option, + } + ``` + + Update `from_settings` (or wherever `HtmlProcessorConfig` is constructed) to initialize `ad_slots_script: None`. + +- [ ] **Step 4: Inject `ad_slots_script` at head-open** + + In `create_html_processor`, within the EXISTING `element!("head", ...)` handler, build the full snippet string with `ad_slots_script` first (so it appears first in output — lol_html `prepend` inserts before children, with **last-prepend-wins** ordering, so we call `prepend` exactly once with the full combined string): + + ```rust + let ad_slots_script = config.ad_slots_script.clone(); + // ... existing captures ... + + element!("head", |el| { + let mut snippet = String::new(); + + // ad_slots_script first so __ts_ad_slots + __ts_request_id appear before + // integration inserts. DO NOT call prepend multiple times — lol_html stacks + // prepend calls in reverse order, so a single prepend with the full string + // guarantees correct ordering. + if let Some(ref slots_script) = ad_slots_script { + snippet.push_str(slots_script); + } + + // ... existing: for insert in integrations.head_inserts(&ctx) { snippet.push_str(...) } + + if !snippet.is_empty() { + el.prepend(&snippet, ContentType::Html); + } + // DO NOT register on_end_tag — flushes immediately per spec §4.3 + Ok(()) + }) + ``` + +- [ ] **Step 5: Run tests** + + Run: `cargo test -p trusted-server-core html_processor` + Expected: all tests pass (including the new ones; no bids injection test must also pass) + +- [ ] **Step 6: Run full suite** + + Run: `cargo test --workspace` + Expected: clean + +- [ ] **Step 7: Commit** + + ```bash + git add crates/trusted-server-core/src/html_processor.rs \ + crates/trusted-server-core/src/integrations/registry.rs + git commit -m "Add ad_slots_script injection to HtmlProcessorConfig at head-open; no hold" + ``` + +--- + +## Task 8: `bid_cache.rs` — In-process auction result cache + +**Files:** + +- Create: `crates/trusted-server-core/src/bid_cache.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` + +The `BidCache` stores auction results keyed by `request_id` with a 30-second TTL. It is shared across concurrent Fastly request handlers via `std::sync::Mutex`. The `/ts-bids` endpoint (Task 10) uses `wait_for()` to block-poll until results arrive or the deadline fires. + +> **WASM note:** `std::time::Instant` and `std::thread::sleep` are both supported in Viceroy and Fastly Compute. The Mutex is uncontested in practice — requests are handled cooperatively with brief lock windows. + +- [ ] **Step 1: Write failing tests** + + Create `crates/trusted-server-core/src/bid_cache.rs` with only the tests: + + ```rust + #[cfg(test)] + mod tests { + use super::*; + use std::time::{Duration, Instant}; + + fn make_bids() -> BidMap { + let mut m = std::collections::HashMap::new(); + m.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + m + } + + #[test] + fn returns_not_found_for_unknown_rid() { + let cache = BidCache::new(Duration::from_secs(30), 100); + let result = cache.try_get("unknown-rid"); + assert!(matches!(result, CacheResult::NotFound), "should return NotFound"); + } + + #[test] + fn returns_pending_before_put() { + let cache = BidCache::new(Duration::from_secs(30), 100); + let deadline = Instant::now() + Duration::from_secs(5); + cache.mark_pending("rid-1", deadline); + let result = cache.try_get("rid-1"); + assert!(matches!(result, CacheResult::Pending), "should be Pending"); + } + + #[test] + fn returns_bids_after_put() { + let cache = BidCache::new(Duration::from_secs(30), 100); + let deadline = Instant::now() + Duration::from_secs(5); + cache.mark_pending("rid-2", deadline); + cache.put("rid-2", make_bids()); + match cache.try_get("rid-2") { + CacheResult::Complete(bids) => { + assert!(bids.contains_key("atf"), "should contain atf bid"); + } + other => panic!("expected Complete, got {:?}", other), + } + } + + #[test] + fn returns_not_found_for_expired_entry() { + let cache = BidCache::new(Duration::from_millis(1), 100); + let deadline = Instant::now() + Duration::from_secs(5); + cache.mark_pending("rid-3", deadline); + cache.put("rid-3", make_bids()); + std::thread::sleep(Duration::from_millis(5)); + let result = cache.try_get("rid-3"); + assert!(matches!(result, CacheResult::NotFound), "should expire after TTL"); + } + + #[test] + fn wait_for_returns_bids_immediately_when_complete() { + let cache = BidCache::new(Duration::from_secs(30), 100); + let deadline = Instant::now() + Duration::from_secs(5); + cache.mark_pending("rid-4", deadline); + cache.put("rid-4", make_bids()); + let result = cache.wait_for("rid-4", deadline); + assert!(matches!(result, WaitResult::Bids(_)), "should return bids immediately"); + } + + #[test] + fn wait_for_returns_not_found_for_unknown_rid() { + let cache = BidCache::new(Duration::from_secs(30), 100); + let deadline = Instant::now() + Duration::from_millis(50); + let result = cache.wait_for("never-registered", deadline); + assert!(matches!(result, WaitResult::NotFound), "should return NotFound"); + } + } + ``` + + Run: `cargo test -p trusted-server-core bid_cache` + Expected: compile error (module not exported yet) + +- [ ] **Step 2: Implement bid_cache.rs** + + ```rust + //! In-process auction result cache keyed by request ID. + //! + //! Shared across concurrent Fastly request handlers via a global `Mutex`. + //! Entries expire after a configurable TTL (30 seconds by default). + + use std::collections::HashMap; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + pub type BidMap = HashMap; + + #[derive(Debug)] + enum EntryState { + Pending { auction_deadline: Instant }, + Complete { bids: BidMap }, + } + + struct CacheEntry { + state: EntryState, + inserted_at: Instant, + } + + struct BidCacheInner { + entries: HashMap, + insertion_order: std::collections::VecDeque, + capacity: usize, + ttl: Duration, + } + + impl BidCacheInner { + fn evict_expired(&mut self) { + let now = Instant::now(); + self.insertion_order.retain(|rid| { + self.entries.get(rid) + .map(|e| now.duration_since(e.inserted_at) < self.ttl) + .unwrap_or(false) + }); + self.entries.retain(|_, e| now.duration_since(e.inserted_at) < self.ttl); + } + + fn evict_oldest_if_full(&mut self) { + while self.entries.len() >= self.capacity { + if let Some(oldest) = self.insertion_order.pop_front() { + self.entries.remove(&oldest); + } else { + break; + } + } + } + } + + /// Outcome of a non-blocking cache lookup. + #[derive(Debug)] + pub enum CacheResult { + /// Auction complete; bids are ready. + Complete(BidMap), + /// Auction registered but not yet complete. + Pending, + /// Request ID never registered, or TTL expired. + NotFound, + } + + /// Outcome of a blocking `wait_for` call. + #[derive(Debug)] + pub enum WaitResult { + /// Auction completed within the deadline. + Bids(BidMap), + /// Deadline passed; bids not available. + Empty, + /// Request ID never registered (caller should return 404). + NotFound, + } + + /// In-process cache for auction results, shared across request handlers. + pub struct BidCache { + inner: Mutex, + } + + impl BidCache { + /// Create a new `BidCache`. + /// + /// # Arguments + /// - `ttl`: how long to keep entries before expiry + /// - `capacity`: max number of concurrent entries (oldest evicted when full) + pub fn new(ttl: Duration, capacity: usize) -> Self { + Self { + inner: Mutex::new(BidCacheInner { + entries: HashMap::new(), + insertion_order: std::collections::VecDeque::new(), + capacity, + ttl, + }), + } + } + + /// Register a request as in-flight. Call at auction start, before `run_auction`. + pub fn mark_pending(&self, request_id: &str, auction_deadline: Instant) { + let mut inner = self.inner.lock().expect("should lock bid_cache"); + inner.evict_expired(); + inner.evict_oldest_if_full(); + inner.entries.insert(request_id.to_string(), CacheEntry { + state: EntryState::Pending { auction_deadline }, + inserted_at: Instant::now(), + }); + inner.insertion_order.push_back(request_id.to_string()); + } + + /// Store completed auction results. Transitions entry from Pending → Complete. + pub fn put(&self, request_id: &str, bids: BidMap) { + let mut inner = self.inner.lock().expect("should lock bid_cache"); + if let Some(entry) = inner.entries.get_mut(request_id) { + entry.state = EntryState::Complete { bids }; + } + } + + /// Non-blocking lookup. Returns current state without sleeping. + pub fn try_get(&self, request_id: &str) -> CacheResult { + let inner = self.inner.lock().expect("should lock bid_cache"); + let now = Instant::now(); + match inner.entries.get(request_id) { + None => CacheResult::NotFound, + Some(entry) if now.duration_since(entry.inserted_at) >= inner.ttl => { + CacheResult::NotFound + } + Some(entry) => match &entry.state { + EntryState::Pending { .. } => CacheResult::Pending, + EntryState::Complete { bids } => CacheResult::Complete(bids.clone()), + }, + } + } + + /// Return the stored auction deadline for a pending entry (the `T₀ + auction_timeout_ms` + /// value minted when the page request arrived). Used by `/ts-bids` to enforce the correct + /// deadline rather than minting a fresh `Instant::now() + timeout`. + /// + /// Returns `None` if the entry is unknown, expired, or already complete. + pub fn get_auction_deadline(&self, request_id: &str) -> Option { + let inner = self.inner.lock().expect("should lock bid_cache"); + let now = Instant::now(); + inner.entries.get(request_id).and_then(|entry| { + if now.duration_since(entry.inserted_at) >= inner.ttl { + return None; + } + match entry.state { + EntryState::Pending { auction_deadline } => Some(auction_deadline), + EntryState::Complete { .. } => None, + } + }) + } + + /// Block until bids are available for `request_id` or `deadline` passes. + /// + /// Polls every 50ms. Returns `NotFound` immediately if `request_id` was never registered. + /// Returns `Empty` if deadline fires before auction completes. + pub fn wait_for(&self, request_id: &str, deadline: Instant) -> WaitResult { + loop { + match self.try_get(request_id) { + CacheResult::Complete(bids) => return WaitResult::Bids(bids), + CacheResult::NotFound => return WaitResult::NotFound, + CacheResult::Pending => { + if Instant::now() >= deadline { + return WaitResult::Empty; + } + std::thread::sleep(Duration::from_millis(50)); + } + } + } + } + } + ``` + +- [ ] **Step 3: Export from lib.rs** + + ```rust + pub mod bid_cache; + ``` + +- [ ] **Step 4: Run tests** + + Run: `cargo test -p trusted-server-core bid_cache` + Expected: all tests pass + +- [ ] **Step 5: Commit** + + ```bash + git add crates/trusted-server-core/src/bid_cache.rs \ + crates/trusted-server-core/src/lib.rs + git commit -m "Add BidCache with 30s TTL, pending/complete states, and blocking wait_for" + ``` + +--- + +## Task 9: `handle_publisher_request` async restructuring + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` + +> **Key constraint from spec §4.3:** Page rendering is never held for the auction. The auction and origin fetch run concurrently via Fastly's `send_async()` model — origin is dispatched first (non-blocking), then the auction runs its own `send_async` calls, so both overlap on the network. Bid results go to `bid_cache` only — they are NOT injected into the HTML. `Cache-Control: private, no-store` is set whenever slots matched (not just when bids arrived). + +- [ ] **Step 1: Update function signature** + + Change `handle_publisher_request` in `publisher.rs`: + + ```rust + pub async fn handle_publisher_request( + settings: &Settings, + integration_registry: &IntegrationRegistry, + services: &RuntimeServices, + orchestrator: &crate::auction::orchestrator::AuctionOrchestrator, + slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, + bid_cache: &crate::bid_cache::BidCache, + mut req: Request, + ) -> Result> + ``` + + Add imports: + + ```rust + use crate::auction::orchestrator::AuctionOrchestrator; + use crate::auction::types::{AuctionContext, AuctionRequest, PublisherInfo, UserInfo, SiteInfo}; + use crate::bid_cache::{BidCache, BidMap}; + use crate::creative_opportunities::{CreativeOpportunitiesFile, match_slots}; + use crate::price_bucket::price_bucket; + ``` + +- [ ] **Step 2: Mint `request_id`, match URL, check consent** + + At the top of the function body, before the origin fetch: + + ```rust + // Mint per-request UUID — included in head injection and /ts-bids lookup key. + let request_id = uuid::Uuid::new_v4().to_string(); + + let request_path = req.get_path().to_string(); + let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() { + match_slots(&slots_file.slots, &request_path) + .into_iter() + .cloned() + .collect() + } else { + Vec::new() + }; + + let consent_allows_auction = consent_context + .tcf + .as_ref() + .map_or(false, |tcf| tcf.has_purpose_consent(1)); + let should_run_auction = !matched_slots.is_empty() && consent_allows_auction; + + let auction_timeout_ms = settings + .creative_opportunities + .as_ref() + .and_then(|co| co.auction_timeout_ms) + .unwrap_or(settings.auction.timeout_ms); + ``` + +- [ ] **Step 3: Register pending in bid_cache, fire origin + auction concurrently** + + ```rust + // Mint T₀ auction deadline. Stored in bid_cache so /ts-bids uses the same deadline, + // not a freshly-minted one when the browser's fetch arrives. + let auction_deadline = std::time::Instant::now() + + std::time::Duration::from_millis(u64::from(auction_timeout_ms)); + + // Register request as in-flight so /ts-bids can long-poll for it. + if should_run_auction { + bid_cache.mark_pending(&request_id, auction_deadline); + } + + restrict_accept_encoding(&mut req); + req.set_header("host", &origin_host); + + // Fire origin request immediately — Fastly's send_async dispatches the HTTP request + // to the network without blocking. The origin fetch is in-flight from this point. + // The auction below also uses send_async internally, so both origin SSP requests + // overlap on the network. This is Fastly's concurrency model — no join! needed. + let pending_origin = req + .send_async(&backend_name) + .change_context(TrustedServerError::Proxy { + message: "Failed to dispatch async origin request".to_string(), + })?; + + // Run auction (internal send_async calls overlap with origin fetch on the network). + let auction_result = if should_run_auction { + let co_config = settings.creative_opportunities.as_ref() + .expect("should be present when should_run_auction is true"); + let auction_request = build_auction_request( + &matched_slots, + &ec_id, + &consent_context, + &request_info, + co_config, + ); + let placeholder_req = fastly::Request::new(); + let auction_context = AuctionContext { + settings, + request: &placeholder_req, + client_info: &services.client_info, + timeout_ms: auction_timeout_ms, + provider_responses: None, + }; + match orchestrator.run_auction(&auction_request, &auction_context, services).await { + Ok(result) => Some(result), + Err(e) => { + log::warn!("server-side auction failed, proceeding without bids: {e:?}"); + None + } + } + } else { + None + }; + + // Write auction results to bid_cache — /ts-bids will serve them. + if should_run_auction { + let co_config = settings.creative_opportunities.as_ref() + .expect("should be present"); + // Bind empty map to a local to avoid &Default::default() referencing a temporary. + let empty_bids = std::collections::HashMap::new(); + let winning_bids = auction_result.as_ref() + .map(|r| &r.winning_bids) + .unwrap_or(&empty_bids); + let bid_map = build_bid_map(winning_bids, co_config.price_granularity); + bid_cache.put(&request_id, bid_map); + } + + // Await origin response (may already be buffered since we started it before the auction). + let mut response = pending_origin + .wait() + .change_context(TrustedServerError::Proxy { + message: "Failed to await origin response".to_string(), + })?; + ``` + +- [ ] **Step 4: Build head injection script, set cache headers, force chunked encoding** + + After acquiring `response`: + + ```rust + // Build head injection script: __ts_ad_slots + __ts_request_id (never bids). + let ad_slots_script = if let Some(co_config) = &settings.creative_opportunities { + if !matched_slots.is_empty() { + Some(build_head_globals_script(&matched_slots, &request_id, co_config)) + } else { + None + } + } else { + None + }; + + // When slots matched: prevent browser/CDN caching of the per-user assembled HTML. + // Spec §4.4: set regardless of whether bids arrived — the request_id is now in the page. + if ad_slots_script.is_some() { + response.set_header(header::CACHE_CONTROL, "private, no-store"); + response.remove_header("surrogate-control"); + response.remove_header("fastly-surrogate-control"); + } + + // Spec §4.3/§4.7: Force chunked encoding on every origin response so that + // reaches the browser immediately as chunks arrive — regardless of whether origin + // sent a buffered response (WordPress, Drupal) or a streaming one (NextJS 16). + // Removing Content-Length is required; sending both headers is invalid HTTP/1.1. + response.remove_header(header::CONTENT_LENGTH); + response.set_header("transfer-encoding", "chunked"); + ``` + +- [ ] **Step 5: Add `pub(crate)` helper functions** + + ```rust + /// Build the `"# + ) + } + + /// Build the `BidMap` stored in `bid_cache` and returned by `/ts-bids`. + /// + /// Keyed by slot ID. Values contain `hb_pb`, `hb_bidder`, `hb_adid`, `burl`. + pub(crate) fn build_bid_map( + winning_bids: &std::collections::HashMap, + price_granularity: crate::price_bucket::PriceGranularity, + ) -> crate::bid_cache::BidMap { + winning_bids + .iter() + .filter_map(|(slot_id, bid)| { + let cpm = bid.price?; + let entry: std::collections::HashMap = [ + ("hb_pb".to_string(), serde_json::Value::String(price_bucket(cpm, price_granularity))), + ("hb_bidder".to_string(), serde_json::Value::String(bid.bidder.clone())), + ("hb_adid".to_string(), serde_json::Value::String( + bid.ad_id.as_deref().unwrap_or("").to_string() + )), + ("burl".to_string(), bid.burl.as_deref() + .map(serde_json::Value::from) + .unwrap_or(serde_json::Value::Null)), + ].into_iter().collect(); + Some((slot_id.clone(), entry.into_iter() + .map(|(k, v)| (k, v)) + .collect::>() + .into())) + }) + .collect() + } + + /// HTML-escape a JSON string for safe inline `" + .to_string(), + // __tsAdInit: fetches /ts-bids for bid targeting, then drives GPT. + // window.__ts_ad_slots and window.__ts_request_id are injected at head-open by TS. + // bidsPromise resolves concurrently with page rendering — never blocks FCP. + concat!( + "" + ).to_string(), + ] + } + } + ``` + +- [ ] **Step 3: Run tests** + + Run: `cargo test -p trusted-server-core integrations::gpt` + Expected: all pass including new test + +- [ ] **Step 4: Commit** + + ```bash + git add crates/trusted-server-core/src/integrations/gpt.rs + git commit -m "Emit __tsAdInit with /ts-bids fetch pattern from GPT head injector" + ``` + +--- + +## Task 12: `gpt/index.ts` — TypeScript `__tsAdInit` with `/ts-bids` fetch + +**Files:** + +- Modify: `crates/js/lib/src/integrations/gpt/index.ts` + +The TypeScript version mirrors the Rust inline string from Task 11. It uses the `bidsPromise` pattern — fetching `/ts-bids` concurrently with GPT slot definition. + +- [ ] **Step 1: Write failing tests** + + In `crates/js/lib/src/integrations/gpt/index.test.ts`: + + ```typescript + import { describe, it, expect, vi, beforeEach } from 'vitest' + + describe('installTsAdInit', () => { + beforeEach(() => { + delete (window as any).__ts_ad_slots + delete (window as any).__ts_request_id + delete (window as any).__tsAdInit + }) + + it('fetches /ts-bids with request_id and applies bid targeting before refresh', async () => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('atf'), + getTargeting: vi.fn().mockReturnValue([]), + } + const mockPubads = { + enableSingleRequest: vi.fn(), + addEventListener: vi.fn(), + refresh: vi.fn(), + } + ;(window as any).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + } + ;(window as any).__ts_ad_slots = [ + { + id: 'atf', + gam_unit_path: '/123/atf', + div_id: 'atf', + formats: [[300, 250]], + targeting: { pos: 'atf' }, + }, + ] + ;(window as any).__ts_request_id = 'test-rid-123' + + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + burl: 'https://ssp/bill', + }, + }), + } as Response) + + const { installTsAdInit } = await import('./index') + installTsAdInit() + await (window as any).__tsAdInit() + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/ts-bids?rid=test-rid-123'), + expect.objectContaining({ credentials: 'omit' }) + ) + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00') + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo') + expect(mockPubads.refresh).toHaveBeenCalled() + + fetchSpy.mockRestore() + }) + + it('calls refresh with empty bids when fetch fails', async () => { + const mockPubads = { + enableSingleRequest: vi.fn(), + addEventListener: vi.fn(), + refresh: vi.fn(), + } + ;(window as any).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue({ + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + }), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + } + ;(window as any).__ts_ad_slots = [] + ;(window as any).__ts_request_id = 'rid-fail' + + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error')) + + const { installTsAdInit } = await import('./index') + installTsAdInit() + await (window as any).__tsAdInit() + + expect(mockPubads.refresh).toHaveBeenCalled() + }) + + it('fires burl via sendBeacon on slotRenderEnded when our bid won', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true) + let capturedListener: ((e: any) => void) | undefined + + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + getSlotElementId: vi.fn().mockReturnValue('atf'), + getTargeting: vi.fn().mockReturnValue(['abc']), + } + const mockPubads = { + enableSingleRequest: vi.fn(), + refresh: vi.fn(), + addEventListener: vi.fn((event: string, fn: (e: any) => void) => { + if (event === 'slotRenderEnded') capturedListener = fn + }), + } + ;(window as any).googletag = { + cmd: { push: vi.fn((fn: () => void) => fn()) }, + defineSlot: vi.fn().mockReturnValue(mockSlot), + pubads: vi.fn().mockReturnValue(mockPubads), + enableServices: vi.fn(), + } + ;(window as any).__ts_ad_slots = [ + { + id: 'atf', + gam_unit_path: '/123/atf', + div_id: 'atf', + formats: [[300, 250]], + targeting: {}, + }, + ] + ;(window as any).__ts_request_id = 'rid-burl-test' + + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + burl: 'https://ssp/bill', + }, + }), + } as Response) + + const { installTsAdInit } = await import('./index') + installTsAdInit() + await (window as any).__tsAdInit() + + // Trigger slotRenderEnded — slot has our winning hb_adid + expect(capturedListener).toBeDefined() + capturedListener!({ + isEmpty: false, + slot: mockSlot, + }) + + expect(beaconSpy).toHaveBeenCalledWith('https://ssp/bill') + beaconSpy.mockRestore() + }) + }) + ``` + + Run: `cd crates/js/lib && npx vitest run` + Expected: FAIL — `installTsAdInit` not exported or fetches wrong endpoint + +- [ ] **Step 2: Add `installTsAdInit` to `index.ts`** + + Add to `crates/js/lib/src/integrations/gpt/index.ts`: + + ```typescript + interface TsAdSlot { + id: string + gam_unit_path: string + div_id: string + formats: Array + targeting: Record + } + + interface TsBidData { + hb_pb?: string + hb_bidder?: string + hb_adid?: string + burl?: string + } + + type TsWindow = Window & { + __ts_ad_slots?: TsAdSlot[] + __ts_request_id?: string + __tsAdInit?: () => void + } + + /** + * Install `window.__tsAdInit`. + * + * Reads `window.__ts_ad_slots` and `window.__ts_request_id` (both injected by + * the edge at `` open). Fetches bid results from `/ts-bids?rid=` + * concurrently with GPT slot definition. Applies targeting and calls `refresh()` + * after the fetch resolves. Registers `slotRenderEnded` to fire `burl` via + * `sendBeacon` when our specific Prebid bid wins the GAM line item match. + */ + export function installTsAdInit(): void { + const w = window as TsWindow + w.__tsAdInit = function () { + const slots = w.__ts_ad_slots ?? [] + const rid = w.__ts_request_id + + const bidsPromise: Promise> = rid + ? fetch(`/ts-bids?rid=${encodeURIComponent(rid)}`, { + credentials: 'omit', + }) + .then((r) => (r.ok ? r.json() : {})) + .catch(() => ({})) + : Promise.resolve({}) + + const g = (window as GptWindow).googletag + if (!g) return + + g.cmd.push(() => { + const gptSlots = slots + .map((slot) => { + const gptSlot = g.defineSlot?.( + slot.gam_unit_path, + slot.formats, + slot.div_id + ) + if (!gptSlot) return null + gptSlot.addService(g.pubads()) + Object.entries(slot.targeting ?? {}).forEach(([k, v]) => + gptSlot.setTargeting(k, v) + ) + return { id: slot.id, gptSlot } + }) + .filter(Boolean) as Array<{ + id: string + gptSlot: NonNullable> + }> + + g.pubads().enableSingleRequest() + g.enableServices() + + bidsPromise.then((bids) => { + gptSlots.forEach(({ id, gptSlot }) => { + const bid = bids[id] ?? {} + ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, bid[key]!) + }) + }) + + g.pubads().addEventListener?.('slotRenderEnded', (event: any) => { + const slotId: string = event.slot?.getSlotElementId?.() ?? '' + const bid = bids[slotId] ?? {} + if ( + !event.isEmpty && + bid.burl && + event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid + ) { + navigator.sendBeacon(bid.burl) + } + }) + + g.pubads().refresh() + }) + }) + } + } + ``` + + Call `installTsAdInit()` from the integration's initialization path. + +- [ ] **Step 3: Run JS tests** + + Run: `cd crates/js/lib && npx vitest run` + Expected: new tests pass + +- [ ] **Step 4: Build JS bundle** + + Run: `cd crates/js/lib && node build-all.mjs` + Expected: clean build + +- [ ] **Step 5: Commit** + + ```bash + git add crates/js/lib/src/integrations/gpt/ + git commit -m "Add installTsAdInit with /ts-bids fetch pattern and slotRenderEnded burl firing" + ``` + +--- + +## Task 13: `nurl` fire-and-forget + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/prebid.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Write failing test** + + ```rust + #[test] + fn prebid_config_fire_nurl_defaults_to_true() { + let config = PrebidConfig::default(); + assert!(config.fire_nurl_at_edge, "should fire nurl at edge by default"); + } + ``` + + Run: `cargo test -p trusted-server-core integrations::prebid` + Expected: FAIL + +- [ ] **Step 2: Add `fire_nurl_at_edge` to `PrebidConfig`** + + ```rust + #[serde(default = "default_fire_nurl_at_edge")] + pub fire_nurl_at_edge: bool, + ``` + + ```rust + fn default_fire_nurl_at_edge() -> bool { true } + ``` + +- [ ] **Step 3: Fire nurls in publisher.rs after bid_cache.put()** + + After the `bid_cache.put(...)` call (Task 9 Step 3), add: + + ```rust + if let Some(ref result) = auction_result { + fire_winning_nurls(result, settings); + } + ``` + + Add helper: + + ```rust + fn fire_winning_nurls( + result: &crate::auction::orchestrator::OrchestrationResult, + settings: &Settings, + ) { + use crate::backend::BackendConfig; + + let fire_nurl = settings + .integrations + .get_typed::("prebid") + .map(|c| c.fire_nurl_at_edge) + .unwrap_or(true); + + if !fire_nurl { + return; + } + + for bid in result.winning_bids.values() { + let Some(ref nurl) = bid.nurl else { continue }; + let backend_name = match BackendConfig::from_url(nurl, false) { + Ok(name) => name, + Err(e) => { + log::warn!("nurl: cannot create backend for {nurl}: {e:?}"); + continue; + } + }; + match fastly::Request::get(nurl).send_async(&backend_name) { + Ok(_) => log::debug!("nurl: fired for slot {}", bid.slot_id), + Err(e) => log::warn!("nurl: failed for slot {}: {e}", bid.slot_id), + } + } + } + ``` + +- [ ] **Step 4: Run tests** + + Run: `cargo test --workspace` + Expected: all pass + +- [ ] **Step 5: Commit** + + ```bash + git add crates/trusted-server-core/src/integrations/prebid.rs \ + crates/trusted-server-core/src/publisher.rs + git commit -m "Fire winning bid nurl fire-and-forget from edge; add fire_nurl_at_edge config" + ``` + +--- + +## Task 14: End-to-end integration tests + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` (test module) + +Tests use `pub(crate)` helpers from Task 9 directly. + +- [ ] **Step 1: Write tests** + + In `publisher.rs` test module: + + ```rust + #[cfg(test)] + mod creative_opportunities_tests { + use super::{build_head_globals_script, build_bid_map, html_escape_for_script}; + use crate::creative_opportunities::{ + CreativeOpportunitiesConfig, CreativeOpportunitySlot, CreativeOpportunityFormat, + CreativeOpportunitiesFile, match_slots, + }; + use crate::auction::types::{Bid, MediaType}; + use crate::price_bucket::PriceGranularity; + use std::collections::HashMap; + + fn make_config() -> CreativeOpportunitiesConfig { + CreativeOpportunitiesConfig { + gam_network_id: "21765378893".to_string(), + auction_timeout_ms: Some(500), + price_granularity: PriceGranularity::Dense, + } + } + + fn make_slot() -> CreativeOpportunitySlot { + CreativeOpportunitySlot { + id: "atf_sidebar_ad".to_string(), + gam_unit_path: Some("/21765378893/publisher/atf-sidebar".to_string()), + div_id: Some("div-atf-sidebar".to_string()), + page_patterns: vec!["/20**".to_string()], + formats: vec![CreativeOpportunityFormat { + width: 300, height: 250, media_type: MediaType::Banner, + }], + floor_price: Some(0.50), + targeting: [("pos".to_string(), "atf".to_string())].into_iter().collect(), + providers: Default::default(), + } + } + + #[test] + fn head_globals_script_contains_ad_slots_and_request_id() { + let slots = vec![make_slot()]; + let config = make_config(); + let rid = "550e8400-e29b-41d4-a716-446655440000"; + let script = build_head_globals_script(&slots, rid, &config); + assert!(script.contains("window.__ts_ad_slots=JSON.parse"), "should use JSON.parse for slots"); + assert!(script.contains("atf_sidebar_ad"), "should include slot id"); + assert!(script.contains(&format!("window.__ts_request_id=\"{rid}\"")), "should include request_id"); + assert!(!script.contains("__ts_bids"), "must NOT contain bids — bids come from /ts-bids"); + } + + #[test] + fn head_globals_script_is_xss_safe() { + let slots = vec![make_slot()]; + let config = make_config(); + let script = build_head_globals_script(&slots, "safe-rid", &config); + // Strip outer "); + assert!(!inner.contains('<'), "no unescaped < in script content"); + assert!(!inner.contains('>'), "no unescaped > in script content"); + } + + #[test] + fn bid_map_uses_price_bucket_and_ad_id() { + let mut winning_bids = HashMap::new(); + winning_bids.insert("atf_sidebar_ad".to_string(), Bid { + slot_id: "atf_sidebar_ad".to_string(), + price: Some(2.53), + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "kargo".to_string(), + width: 300, height: 250, + nurl: None, + burl: Some("https://ssp.example/billing?id=abc123".to_string()), + ad_id: Some("prebid-uuid-abc123".to_string()), + metadata: HashMap::new(), + }); + let bid_map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let slot_bids = bid_map.get("atf_sidebar_ad").expect("should have slot bids"); + assert_eq!( + slot_bids.get("hb_pb").and_then(|v| v.as_str()), + Some("2.53"), + "should bucket 2.53 as 2.53 (dense)" + ); + assert_eq!( + slot_bids.get("hb_bidder").and_then(|v| v.as_str()), + Some("kargo"), + "should include bidder" + ); + assert_eq!( + slot_bids.get("hb_adid").and_then(|v| v.as_str()), + Some("prebid-uuid-abc123"), + "should use ad_id not creative markup" + ); + } + + #[test] + fn html_escape_neutralizes_xss_in_json() { + let malicious = r#"{"zone":""), "should escape "); + assert!(escaped.contains("\\u003c"), "should unicode-escape <"); + assert!(escaped.contains("\\u003e"), "should unicode-escape >"); + } + + #[test] + fn url_matching_end_to_end() { + let file = CreativeOpportunitiesFile { slots: vec![make_slot()] }; + assert_eq!(match_slots(&file.slots, "/2024/01/my-article").len(), 1, "should match article"); + assert_eq!(match_slots(&file.slots, "/about").len(), 0, "should not match /about"); + assert_eq!(match_slots(&file.slots, "/").len(), 0, "should not match root"); + } + } + ``` + +- [ ] **Step 2: Run tests** + + Run: `cargo test -p trusted-server-core creative_opportunities_tests` + Expected: all pass + +- [ ] **Step 3: Run full suite + CI gates** + + ```bash + cargo test --workspace + cargo clippy --workspace --all-targets --all-features -- -D warnings + cargo fmt --all -- --check + cd crates/js/lib && npx vitest run + cd crates/js/lib && npm run format + cd docs && npm run format + ``` + + Expected: all clean + +- [ ] **Step 4: Commit** + + ```bash + git add crates/trusted-server-core/src/publisher.rs + git commit -m "Add integration tests for creative opportunities pipeline (head globals, bid map, XSS)" + ``` + +--- + +## Manual Verification Checklist + +Run `fastly compute serve` and verify: + +- [ ] **No match:** Request `/about` — no `__ts_ad_slots`, no `__ts_request_id` in response HTML; no `Cache-Control: private, no-store` +- [ ] **Match:** Request `/2024/01/article` — `window.__ts_ad_slots` and `window.__ts_request_id` in ``; `Cache-Control: private, no-store`; **no `__ts_bids` in HTML** +- [ ] **`/ts-bids` cache hit:** Request `/2024/01/article`, then `GET /ts-bids?rid=` — returns JSON within 30ms; `Content-Type: application/json`; `Cache-Control: private, no-store` +- [ ] **`/ts-bids` unknown rid:** `GET /ts-bids?rid=not-a-real-id` — returns 404 +- [ ] **`/ts-bids` missing rid:** `GET /ts-bids` — returns 400 +- [ ] **Empty file kill-switch:** Empty `creative-opportunities.toml` → no globals injected on any URL; no cache headers +- [ ] **Auction timeout:** Set `auction_timeout_ms = 1` → `/ts-bids` returns `{}` promptly +- [ ] **XSS check:** Add `targeting = { zone = " +``` + +> **Security:** All string values are JSON-serialized via `serde_json` and HTML-escaped +> before insertion into the `