From 2b326e778c4c76404b75602793f38173527ee1b9 Mon Sep 17 00:00:00 2001 From: jevansnyc Date: Wed, 15 Apr 2026 20:45:20 +0200 Subject: [PATCH 1/6] Add server-side ad templates design spec Co-Authored-By: Claude Sonnet 4.6 --- ...6-04-15-server-side-ad-templates-design.md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md diff --git a/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md b/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md new file mode 100644 index 000000000..454f37641 --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-server-side-ad-templates-design.md @@ -0,0 +1,363 @@ +# Server-Side Ad Templates Design + +*April 2026* + +--- + +## 1. Problem Statement + +Today's display ad pipeline on most publisher sites is structurally sequential +and browser-bound: + +1. Page HTML arrives at browser +2. Prebid.js (~300KB) downloads and parses +3. Smart Slots SDK scans the DOM to discover ad placements +4. `addAdUnits()` registers slot definitions +5. Prebid auction fires from the browser (~80–150ms RTT to SSPs) +6. Bids return (~1,000–1,500ms window) +7. GPT `setTargeting()` + `refresh()` fires +8. GAM creative renders + +**Total time to ad visible: ~3,100ms.** + +The browser is the slowest possible place to run an auction. It must first download and parse +multiple SDKs, scan the DOM to discover what ad slots exist, and then fire SSP requests over +a consumer internet connection with high and variable latency. + +Trusted Server sits at the Fastly edge — milliseconds from the user, with data-center-to-data-center +RTT to Prebid Server (~20–30ms vs ~80–150ms from a browser). The server knows, from the request +URL alone, exactly which ad slots are available on any given page. There is no reason to wait for +the browser. + +--- + +## 2. Goal + +Enable Trusted Server to: + +1. Match an incoming page request URL against a set of pre-configured slot templates +2. Immediately fire the full server-side auction (all providers: PBS, APS, future wrappers) in + parallel with the origin HTML fetch — before the browser receives a single byte +3. Inject GPT slot definitions into `` so the client can define slots without any SDK +4. Return pre-collected winning bids to the browser's lightweight `/auction` POST before the + browser would have even finished parsing Prebid.js +5. Eliminate Prebid.js from the client entirely + +**Target time to ad visible: ~1,200ms. Net saving: ~2,000ms.** + +--- + +## 3. Non-Goals + +- Eliminating client-side GPT / Google Ad Manager — GAM remains in the rendering pipeline + for Phase 1. The GAM call (`securepubads.g.doubleclick.net`) moves server-side in a future phase. +- Dynamic slot discovery (reading the DOM) — this design commits to pre-defined, URL-matched + slot templates. Smart Slots' dynamic injection behavior is replaced by server knowledge. +- Changing the `AuctionOrchestrator` internally — the orchestrator already handles parallel + provider fan-out. This design adds a new trigger point, not new auction logic. + +--- + +## 4. Architecture + +### 4.1 New File: `creative-opportunities.toml` + +A new config file at the repo root, alongside `trusted-server.toml`. It holds all slot templates: +page pattern matching rules, ad formats, floor prices, and GAM targeting key-values. Bidder-level +params (placement IDs, account IDs) live in Prebid Server stored requests, keyed by slot ID — not +in this file. + +Loaded at build time via `include_str!()`, parsed into `Vec` at startup. +Ad ops can edit this file independently of server configuration. + +`floor_price` is the publisher-owned hard floor per slot — the source of truth for the minimum +acceptable bid price, enforced at the edge before bids reach the ad server. Any bid below the +floor is discarded at the orchestrator level before it enters `__ts_bids`. SSPs may apply their +own dynamic floors independently within their platforms; this floor is the publisher's baseline +that supersedes all other floor logic by virtue of being enforced earliest in the pipeline. + +**Schema:** + +```toml +[[slot]] +id = "atf_sidebar_ad" +page_patterns = ["/20*/"] +formats = [{ width = 300, height = 250 }] +floor_price = 0.50 + +[slot.targeting] +pos = "atf" +zone = "atfSidebar" + +[[slot]] +id = "below-content-ad" +page_patterns = ["/20*/"] +formats = [{ width = 300, height = 250 }, { width = 728, height = 90 }] +floor_price = 0.25 + +[slot.targeting] +pos = "btf" +zone = "belowContent" + +[[slot]] +id = "ad-homepage-0" +page_patterns = ["/", "/index.html"] +formats = [{ width = 970, height = 250 }, { width = 728, height = 90 }] +floor_price = 1.00 + +[slot.targeting] +pos = "atf" +zone = "homepage" +slot_index = "0" +``` + +**Rust type:** + +```rust +#[derive(Debug, Clone, serde::Deserialize)] +pub struct CreativeOpportunitySlot { + pub id: String, + pub page_patterns: Vec, + pub formats: Vec, + pub floor_price: Option, + pub targeting: HashMap, +} +``` + +### 4.2 URL Pattern Matching + +At request time, TS matches the request path against each slot's `page_patterns`. Patterns are +glob-style strings: + +- `/20*/` — matches all date-prefixed article paths (e.g., `/2024/01/my-article/`) +- `/` — matches the homepage exactly +- `/index.html` — exact match + +Multiple slots can match a single URL. All matching slots are collected and fed into a single +auction as separate impressions. Pattern matching is purely in-memory against the pre-parsed +config — sub-millisecond. + +### 4.3 Auction Trigger + +When slots are matched, TS immediately calls `AuctionOrchestrator::run_auction()` with the +matched slots converted to `AdSlot` objects. This happens at request receipt time — in parallel +with the origin fetch. + +The orchestrator's existing behaviour is unchanged: +- All providers (PBS, APS, any configured wrappers) are dispatched simultaneously +- Per-provider timeout budgets are enforced from the remaining auction deadline +- Floor price filtering, bid unification, and winning bid selection are applied as today +- PBS resolves bidder params from its stored requests by slot ID — no bidder params travel + through TS or the browser + +**On NextJS 14 (buffered mode):** TS must buffer the full origin response before forwarding. +This gives the auction the entire origin response time (~150–400ms typical) to run before +any HTML is forwarded. In practice, bids are often collected before origin even responds. + +**On NextJS 16 (streaming mode):** TS streams HTML chunks to the browser immediately. The +auction runs in parallel. Bid injection into `` must complete before the `` tag +is forwarded. If the auction has not returned by the time `` is encountered, TS waits +up to the remaining auction budget, then flushes with whatever bids have arrived (partial +results) or no targeting if timed out. Content after `` is never held. + +### 4.4 Head Injection + +TS injects two separate ``, not +> raw string interpolation. -Prebid.js is eliminated. The client-side ad bootstrap is replaced by a small inline script -(~20 lines) that reads `__ts_ad_slots` and `__ts_bids` and drives GPT directly: +> **Cache contract:** Any response with `__ts_bids` injected is per-user data and must +> not be cached. TS sets `Cache-Control: private, no-store` on the response before +> forwarding, overriding any conflicting cache headers from the publisher origin. +> `Surrogate-Control` and `Fastly-Surrogate-Control` are also stripped. + +### 4.5 Win Notifications + +Win notification responsibilities are split by where the truth lives: + +**`nurl` (SSP win event) — fired server-side.** When the orchestrator selects a winning +bid, TS fires a fire-and-forget background HTTP request to `nurl` from the edge +(edge→SSP RTT ~20–30ms, no auction-path latency cost). A per-integration switch +(`[integrations.prebid].fire_nurl_at_edge`, default `true`) handles cases where the PBS +deployment already fires win events internally to avoid double-firing. APS win +notification follows its own spec. + +**`burl` (billing event) — fired client-side.** `burl` is embedded per slot in +`__ts_bids` (see §4.4). The `__tsAdInit` script registers a GPT `slotRenderEnded` +listener after defining slots. On render: if `!event.isEmpty` and +`event.slot.getTargeting('hb_adid')[0] === bidData.hb_adid`, the client fires `burl` +via `navigator.sendBeacon`. This confirms both that the ad rendered and that our specific +Prebid bid (not a direct deal or backfill) won the GAM line item match. + +### 4.6 Client Residual + +Prebid.js is eliminated. The client-side ad bootstrap is replaced by a small inline +script (~30 lines) that reads `__ts_ad_slots` and `__ts_bids`, drives GPT directly, and +handles billing notifications: ```javascript -window.__tsAdInit = function() { - var slots = window.__ts_ad_slots || []; - var bids = window.__ts_bids || {}; - googletag.cmd.push(function() { - slots.forEach(function(slot) { - var gptSlot = googletag.defineSlot(slot.id, slot.formats, slot.id) - .addService(googletag.pubads()); +window.__tsAdInit = function () { + var slots = window.__ts_ad_slots || [] + var bids = window.__ts_bids || {} + googletag.cmd.push(function () { + slots.forEach(function (slot) { + var gptSlot = googletag + .defineSlot(slot.gam_unit_path, slot.formats, slot.div_id) + .addService(googletag.pubads()) // Apply static targeting from config - Object.entries(slot.targeting).forEach(function([k, v]) { - gptSlot.setTargeting(k, v); - }); + Object.entries(slot.targeting).forEach(function ([k, v]) { + gptSlot.setTargeting(k, v) + }) // Apply pre-won bid targeting if available - var bidTargeting = bids[slot.id] || {}; - Object.entries(bidTargeting).forEach(function([k, v]) { - gptSlot.setTargeting(k, v); - }); - }); - googletag.pubads().enableSingleRequest(); - googletag.enableServices(); - googletag.pubads().refresh(); - }); -}; + var bidData = bids[slot.id] || {} + ;['hb_pb', 'hb_bidder', 'hb_adid'].forEach(function (key) { + if (bidData[key]) gptSlot.setTargeting(key, bidData[key]) + }) + }) + googletag.pubads().enableSingleRequest() + googletag.enableServices() + // Fire burl on confirmed render + googletag.pubads().addEventListener('slotRenderEnded', function (event) { + var slotId = event.slot.getSlotElementId() + var bidData = bids[slotId] || {} + if ( + !event.isEmpty && + bidData.burl && + event.slot.getTargeting('hb_adid')[0] === bidData.hb_adid + ) { + navigator.sendBeacon(bidData.burl) + } + }) + googletag.pubads().refresh() + }) +} ``` -This script is part of the `tsjs-gpt` integration bundle, injected by TS into every matching -page response alongside the existing GPT integration. +This script is part of the existing `gpt` integration bundle +(`crates/js/lib/src/integrations/gpt/index.ts`), extending the existing GPT shim. +Injected via the `gpt` head injector alongside `window.__ts_ad_slots`. --- @@ -238,21 +440,26 @@ t=0ms GET ts.publisher.com/article arrives at Fastly edge t=1ms URL matched against creative-opportunities.toml Slots matched: [atf_sidebar_ad, below-content-ad, section_ad] + Consent check: TCF consent present → auction proceeds t=2ms AuctionOrchestrator.run_auction() called - PBS + APS dispatched in parallel + PBS + APS dispatched in parallel via send_async() Edge→PBS RTT: ~20–30ms -t=2ms Origin fetch dispatched in parallel +t=2ms Origin fetch dispatched via send_async() in parallel + +t=2ms window.__ts_ad_slots script assembled from config (no auction needed) t=150ms Origin HTML arrives at edge (NextJS 14: buffered) + Auction still running; origin response held at edge -t=502ms Auction timeout fires (500ms budget) - Winning bids collected +t=502ms Auction deadline fires (500ms budget) + Winning bids collected; nurl fired as background requests -t=502ms injection assembled: - - window.__ts_ad_slots (from config, available at t=1ms) - - window.__ts_bids (from auction results) +t=502ms HtmlProcessorConfig constructed with bid results captured + injection assembled: + - window.__ts_ad_slots (from config, ready at t=2ms) + - window.__ts_bids (from auction results; Cache-Control: private, no-store set) t=502ms HTML forwarded to browser with injected @@ -270,7 +477,7 @@ t=822ms GET /gampad/ads t=922ms Creative fetch -t=1222ms Creative sub-resources + paint +t=1222ms Creative sub-resources + paint; burl fired via slotRenderEnded AD VISIBLE ~1200ms ``` @@ -279,18 +486,23 @@ t=1222ms Creative sub-resources + paint ## 6. Performance Summary -| Stage | Client-side today | With TS templates | Saving | -|---|---|---|---| -| Script load chain | ~700ms | ~40ms (tsjs only) | -660ms | -| Script parse/JIT | ~280ms | ~10ms | -270ms | -| Sequential SDK hops | ~200ms | 0 | -200ms | -| Auction window | ~1,500ms | ~500ms | -1,000ms | -| GAM + creative | ~570ms | ~570ms | — | -| **Total** | **~3,250ms** | **~1,200ms** | **~2,000ms** | +| Stage | Client-side today | With TS templates | Saving | +| ------------------- | ----------------- | ----------------- | ------------ | +| Script load chain | ~700ms | ~40ms (tsjs only) | -660ms | +| Script parse/JIT | ~280ms | ~10ms | -270ms | +| Sequential SDK hops | ~200ms | 0 | -200ms | +| Auction window | ~1,500ms | ~500ms | -1,000ms | +| GAM + creative | ~570ms | ~570ms | — | +| TTFB penalty¹ | 0 | up to +350ms | - | +| **Total** | **~3,250ms** | **~1,200ms** | **~2,000ms** | + +¹ Buffered mode only: the origin response is held until the auction resolves. For fast +origins (<150ms) and a 500ms auction deadline, TTFB may increase by up to 350ms. This +tradeoff is net-positive on revenue. The streaming mode (NextJS 16) has no TTFB penalty. -Auction RTT improvement: browser fires SSP requests at 80–150ms RTT; edge fires at 20–30ms. -Auction timeout can drop from 1,000–1,500ms to 500ms while still collecting more complete -results, because edge→PBS latency is ~5–7x lower. +Auction RTT improvement: browser fires SSP requests at 80–150ms RTT; edge fires at +20–30ms. Auction timeout can drop from 1,000–1,500ms to 500ms while still collecting +more complete results, because edge→PBS latency is ~5–7x lower. --- @@ -299,24 +511,42 @@ results, because edge→PBS latency is ~5–7x lower. ### New - `creative-opportunities.toml` — slot template config file -- `crates/trusted-server-core/src/creative_opportunities.rs` — config types, TOML parsing, - URL pattern matching, slot-to-`AdSlot` conversion -- `build.rs` update — `include_str!()` for `creative-opportunities.toml` -- Request handler modification — match slots at request receipt, trigger orchestrator immediately, - hold result for head injection -- `tsjs-gpt` integration update — `__tsAdInit` bootstrap replaces Prebid.js ad unit setup +- `crates/trusted-server-core/src/creative_opportunities.rs` — config types, TOML + parsing, URL glob matching, slot-to-`AdSlot` conversion, price bucketing +- `crates/trusted-server-core/build.rs` — `include_str!()` for + `creative-opportunities.toml`; startup slot-ID validation +- `crates/trusted-server-core/src/price_bucket.rs` — Prebid price granularity tables + (dense default; publisher-configurable); converts raw CPM `f64` to `hb_pb` string ### Modified -- `crates/trusted-server-core/src/integrations/prebid.rs` head injector — emit - `window.__ts_ad_slots` from matched slots -- `crates/trusted-server-core/src/html_processor.rs` — inject `window.__ts_bids` once auction - results are available, before `` -- `trusted-server.toml` — add `creative_opportunities_path` config key pointing to the new file +- **`crates/trusted-server-core/src/publisher.rs`** — primary structural change: + - Convert `handle_publisher_request` from `fn` to `async fn` + - Switch origin fetch from `.send()` to `.send_async()` (returns + `PlatformPendingRequest`) + - Add `orchestrator: &AuctionOrchestrator` parameter + - Match slots, check consent, fire auction and origin fetch concurrently + - Await both and construct `HtmlProcessorConfig` with resolved bid results +- **`crates/trusted-server-adapter-fastly/src/main.rs`** — update `route_request` call + site to `.await` the now-async publisher handler; pass orchestrator reference +- **`crates/trusted-server-core/src/html_processor.rs`** — inject `window.__ts_bids` + before `` via `el.on_end_tag()` on the `` element; set + `Cache-Control: private, no-store` header on injection; HTML-escape bid JSON +- **`crates/trusted-server-core/src/integrations/gpt.rs`** — extend head injector to + emit `window.__ts_ad_slots` from matched slots (not `prebid.rs`); emit `__tsAdInit` + bootstrap script +- **`crates/js/lib/src/integrations/gpt/index.ts`** — add `__tsAdInit` function and + `slotRenderEnded` burl-firing logic to the existing GPT shim +- **`crates/trusted-server-core/src/integrations/prebid.rs`** — add + `fire_nurl_at_edge` config key; add nurl fire-and-forget call in orchestrator result + handling +- **`trusted-server.toml`** — add `[creative_opportunities]` section +- **`crates/trusted-server-core/src/settings.rs`** — add `CreativeOpportunitiesConfig` + to `Settings` ### Unchanged -- `AuctionOrchestrator` — no internal changes; new call site only +- `AuctionOrchestrator` internals — no changes; new call site only - PBS stored request configuration — bidder params remain in PBS, keyed by slot ID - GAM line item configuration — targeting key-values pass through unchanged @@ -324,40 +554,66 @@ results, because edge→PBS latency is ~5–7x lower. ## 8. Edge Cases -**No slots match the URL** — auction is not fired. Head injection emits neither global. GPT -bootstrap detects empty `__ts_ad_slots` and skips initialization. Page loads normally with no -ad stack. +**No slots match the URL** — auction is not fired. Neither global is emitted. The page +loads with no TS ad stack; existing client-side Prebid/GPT flow runs unmodified (for +publishers in dual-mode rollout). + +**Consent absent or denied** — auction is not fired. Neither global is emitted. +`Cache-Control: private, no-store` is still set (to prevent caching the consent-negative +response if personalised ads were previously served). Page loads normally; GAM runs its +own auction without Prebid targeting. + +**Auction times out with partial results** — `__ts_bids` is populated with whatever bids +arrived before the deadline. Slots with no bid are omitted. GPT fires without pre-set +targeting for those slots; GAM falls back to its own auction for them. + +**Auction times out with zero results** — `__ts_bids` is an empty object `{}`. All slots +fire GAM without bid targeting. No revenue impact beyond the timeout scenario itself. -**Auction times out with partial results** — `__ts_bids` is populated with whatever bids arrived -before the deadline. Slots with no bid omitted. GPT fires without pre-set targeting for those slots; -GAM falls back to its own auction. +**Origin is slow (NextJS 14, buffered)** — auction has more time; results more likely to +be complete. TTFB impact is bounded by the origin latency, not additive to it. -**Auction times out with zero results** — `__ts_bids` is an empty object `{}`. All slots fire -GAM without bid targeting. No revenue impact beyond the timeout scenario itself (same as today's -fallback). +**NextJS 16 streaming** — `el.on_end_tag()` on `` gates injection. TS waits up to +the remaining `auction_timeout_ms` budget, then flushes. Content after `` is never +held. If the auction resolves before `` is encountered (common case), injection is +zero-latency. -**Origin is slow (NextJS 14, buffered)** — auction has more time; results more likely to be -complete. No change to streaming behavior. +**`creative-opportunities.toml` missing or malformed** — startup fails with a clear +error. No silent degradation. -**NextJS 16 streaming** — TS must flush `` before `` tag passes through. If auction -not yet complete, TS waits up to `auction_timeout_ms` from the config, then flushes. Content -streaming resumes immediately after `` regardless of bid state. +**Config empty (zero slots)** — treated as "no match" for all URLs; auction never fires. +No error. Useful as a kill-switch: deploying an empty `creative-opportunities.toml` +disables the feature without a code change. -**`creative-opportunities.toml` missing or malformed** — startup fails with a clear error. -No silent degradation. +**Slot ID not found in PBS stored requests** — PBS returns a no-bid for that slot. Slot +is omitted from `__ts_bids`. The remaining slots proceed normally. --- ## 9. Open Questions -1. **URL pattern coverage** — does `/20*/` cover all article paths, or are there +1. **URL pattern coverage** — does `/20**` cover all article paths, or are there non-date-prefixed article URLs? Publisher to confirm. 2. **PBS stored request setup** — slot IDs in `creative-opportunities.toml` must have - corresponding stored requests configured in the publisher's PBS instance before this goes live. -3. **Homepage slot count** — the example shows slots 0 and 1. Are there slots 2–5 following - the same pattern? Slot IDs and count to be confirmed with ad ops. -4. **Auction timeout for server-side trigger** — current `[integrations.prebid].timeout_ms` - is 1,000ms. Recommend reducing to 500ms for server-side triggered auctions given the - lower edge→PBS RTT. Separate config key or override on the new trigger path? -5. **`tsjs-gpt` bootstrap delivery** — the `__tsAdInit` script needs to fire after GPT.js - loads. Confirm injection order with the existing GPT integration head injection. + corresponding stored requests configured in the publisher's PBS instance before this + goes live. +3. **Homepage slot count** — the example shows slots 0 and 1. Are there additional slots + following the same pattern? Slot IDs and count to be confirmed with ad ops. +4. **Auction timeout** — ✅ Resolved: new dedicated key + `[creative_opportunities].auction_timeout_ms` with fallback to `[auction].timeout_ms`. + Per-provider ceilings (`[integrations.prebid].timeout_ms`, + `[integrations.aps].timeout_ms`) remain unchanged; the orchestrator's existing + `min(remaining_budget, provider_timeout)` logic applies. +5. **KV-backed config migration path** — Phase 1 ships with `include_str!()` for + simplicity and cost. When ad ops require live slot edits between deploys, the migration + path is: load from `services.kv_store()` at request time with a compiled-in fallback. + Design tracked as a follow-up before Phase 2. +6. **Phase 2 server-side GAM** — The real latency ceiling is the GAM call + (`securepubads.g.doubleclick.net`). Phase 2 routes the GAM ad request through the edge + (securepubads proxy + creative bundling), eliminating the last browser→Google hop. The + Phase 1 architecture is designed to be shape-compatible with this: `__ts_ad_slots` + gives the edge the full slot inventory it needs to build a server-side GAM request. +7. **`tsjs-gpt` bootstrap delivery** — ✅ Resolved: `__tsAdInit` is part of the existing + `gpt` integration bundle, not a new integration. Injection order: `window.__ts_ad_slots` + → existing GPT shim → `__tsAdInit` — all emitted by the `gpt` head injector in a single + `".to_string() + ), + ad_bids_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("window.__ts_ad_slots"), "should inject ad slots"); + } + + #[test] + fn injects_bids_before_end_of_head() { + let bids_script = ""; + 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, + ad_bids_script: Some(bids_script.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_bids"), "should inject bids"); + let bids_pos = html.find("window.__ts_bids").expect("should find bids"); + let end_head_pos = html.find("").expect("should find "); + assert!(bids_pos < end_head_pos, "bids script should appear before "); + } + ``` + + Run: `cargo test -p trusted-server-core html_processor` + Expected: compile error (no `ad_slots_script`/`ad_bids_script` fields, 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 { + // Minimal registry with no integrations for unit testing html_processor + 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 fields to `HtmlProcessorConfig`** + + ```rust + pub struct HtmlProcessorConfig { + pub origin_host: String, + pub request_host: String, + pub request_scheme: String, + pub integrations: IntegrationRegistry, + /// Pre-computed `` for matched slots. + /// Injected at open, before integration head inserts. `None` when no slots matched. + pub ad_slots_script: Option, + /// Pre-computed `` for winning bids. + /// Injected immediately before via on_end_tag(). `None` when auction not run. + pub ad_bids_script: Option, + } + ``` + + Update `from_settings` to initialize `ad_slots_script: None, ad_bids_script: None`. + +- [ ] **Step 4: Inject `__ts_ad_slots` at head-open AND register `on_end_tag` for `__ts_bids`** + + In `create_html_processor`, within the EXISTING single `element!("head", ...)` handler, make two changes: + 1. Prepend the ad slots script BEFORE the existing integration inserts: + + ```rust + // NEW: inject __ts_ad_slots first + if let Some(ref slots_script) = ad_slots_script { + snippet.push_str(slots_script); + } + // ... existing: for insert in integrations.head_inserts(&ctx) { ... } + ``` + + 2. After `el.prepend(...)`, register the end-tag handler for `__ts_bids`: + ```rust + // Register on_end_tag handler for __ts_bids injection before + if let Some(bids_script) = ad_bids_script.clone() { + el.on_end_tag(move |end_tag| { + end_tag.before(&bids_script, ContentType::Html); + Ok(()) + })?; + } + ``` + + Both changes live inside the same `element!("head", ...)` closure — no second handler needed. + + Capture `ad_slots_script` and `ad_bids_script` into the closure the same way as `injected_tsjs`: + + ```rust + let ad_slots_script = config.ad_slots_script.clone(); + let ad_bids_script = config.ad_bids_script.clone(); + ``` + + > **lol_html `on_end_tag` API note:** `Element::on_end_tag(handler)` is available in lol_html ≥2.0. The handler receives `&mut EndTag` and must return `Result<(), Box>`. Use `ContentType::Html` so the injected `", escaped) + } + + pub(crate) fn build_ad_bids_script( + winning_bids: &std::collections::HashMap, + price_granularity: crate::price_bucket::PriceGranularity, + ) -> String { + let bids_map: serde_json::Map = winning_bids + .iter() + .filter_map(|(slot_id, bid)| { + let cpm = bid.price?; + let entry = serde_json::json!({ + "hb_pb": price_bucket(cpm, price_granularity), + "hb_bidder": bid.bidder, + "hb_adid": bid.ad_id.as_deref().unwrap_or(""), + "burl": bid.burl, + }); + Some((slot_id.clone(), entry)) + }) + .collect(); + let json = serde_json::to_string(&serde_json::Value::Object(bids_map)) + .expect("should serialize bids"); + let escaped = html_escape_for_script(&json); + format!("", escaped) + } + + /// HTML-escape a JSON string for safe inline `" + .to_string(), + // __tsAdInit definition — reads window.__ts_ad_slots / __ts_bids at call time. + 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 function definition from GPT head injector" + ``` + +--- + +## Task 10: `gpt/index.ts` — TypeScript `__tsAdInit` + +**Files:** + +- Modify: `crates/js/lib/src/integrations/gpt/index.ts` + +The TypeScript version is the authoritative implementation; it must mirror the Rust inline string from Task 9 exactly. + +- [ ] **Step 1: Write a failing test** + + 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_bids + delete (window as any).__tsAdInit + }) + + it('defines googletag slots from __ts_ad_slots and calls refresh', () => { + const mockSlot = { + addService: vi.fn().mockReturnThis(), + setTargeting: vi.fn().mockReturnThis(), + } + const mockPubads = { + enableSingleRequest: vi.fn(), + addEventListener: vi.fn(), + refresh: vi.fn(), + getTargeting: vi.fn().mockReturnValue([]), + } + ;(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_bids = { + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + burl: 'https://ssp/bill', + }, + } + + // Must import installTsAdInit from the module + const { installTsAdInit } = require('./index') + installTsAdInit() + ;(window as any).__tsAdInit() + + expect((window as any).googletag.defineSlot).toHaveBeenCalledWith( + '/123/atf', + [[300, 250]], + 'atf' + ) + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00') + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo') + expect(mockPubads.refresh).toHaveBeenCalled() + }) + + it('fires burl via sendBeacon on slotRenderEnded when our bid won', () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true) + // ... setup and trigger slotRenderEnded event + // Verify: navigator.sendBeacon called with burl + beaconSpy.mockRestore() + }) + }) + ``` + + Run: `cd crates/js/lib && npx vitest run` + Expected: FAIL — `installTsAdInit` not exported + +- [ ] **Step 2: Add `installTsAdInit` to `index.ts`** + + Add to `crates/js/lib/src/integrations/gpt/index.ts` (bottom of file): + + ```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_bids?: Record + __tsAdInit?: () => void + } + + /** + * Install `window.__tsAdInit` — reads `window.__ts_ad_slots` and `window.__ts_bids` + * (injected by the edge into ), defines GPT slots, applies pre-won bid targeting, + * registers a `slotRenderEnded` listener to fire `burl` via `sendBeacon`, then calls + * `refresh()`. + */ + export function installTsAdInit(): void { + const w = window as TsWindow + w.__tsAdInit = function () { + const slots = w.__ts_ad_slots ?? [] + const bids = w.__ts_bids ?? {} + const g = (window as GptWindow).googletag + if (!g) return + g.cmd.push(() => { + slots.forEach((slot) => { + 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) + ) + const bid = bids[slot.id] ?? {} + ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, bid[key]!) + }) + }) + g.pubads().enableSingleRequest() + g.enableServices() + 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 so it's set up when the bundle loads. + +- [ ] **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 __tsAdInit and slotRenderEnded burl firing to GPT integration" + ``` + +--- + +## Task 11: `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 auction** + + After `auction_result` is obtained, add: + + ```rust + if let Some(ref result) = auction_result { + fire_winning_nurls(result, settings); + } + ``` + + Add helper (no `.await` — fire-and-forget): + + ```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 12: End-to-end integration tests + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` (test module) + +Tests use `pub(crate)` helpers from Task 8 directly. + +- [ ] **Step 1: Write tests** + + In `publisher.rs` test module: + + ```rust + #[cfg(test)] + mod creative_opportunities_tests { + use super::{build_ad_slots_script, build_ad_bids_script, 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 ad_slots_script_is_safe_and_parseable() { + let slots = vec![make_slot()]; + 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"); + assert!(script.contains("atf_sidebar_ad"), "should include slot id"); + // Verify no raw < or > that could break HTML parser + let inner = script.trim_start_matches(""); + assert!(!inner.contains('<'), "no unescaped < in script content"); + assert!(!inner.contains('>'), "no unescaped > in script content"); + } + + #[test] + fn ad_bids_script_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 script = build_ad_bids_script(&winning_bids, PriceGranularity::Dense); + assert!(script.contains("\"hb_pb\":\"2.53\""), "should bucket 2.53 as 2.53 (dense)"); + assert!(script.contains("\"hb_bidder\":\"kargo\""), "should include bidder"); + assert!(script.contains("\"hb_adid\":\"prebid-uuid-abc123\""), "should use ad_id not creative markup"); + assert!(script.contains("burl"), "should include burl for billing"); + } + + #[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 (slots, bids, XSS)" + ``` + +--- + +## Manual Verification Checklist + +Run `fastly compute serve` and verify: + +- [ ] **No match:** Request `/about` — no `__ts_ad_slots` or `__ts_bids` in response HTML, no `Cache-Control: private, no-store` +- [ ] **Match:** Request `/2024/01/article` — both globals present in ``, `Cache-Control: private, no-store` set +- [ ] **Empty file kill-switch:** Empty `creative-opportunities.toml` → no globals injected on any URL +- [ ] **Auction timeout:** Set `auction_timeout_ms = 1` → `__ts_bids` injects as `{}`, no slot entries +- [ ] **XSS check:** Add `targeting = { zone = " +``` + +> **Security:** All string values are JSON-serialized via `serde_json` and HTML-escaped +> before insertion into the ``, not -> raw string interpolation. +- If the auction has already completed for ``, response returns immediately + with cached results (cache hit). Typical case for non-trivial origin times. +- If the auction is still in flight, the request blocks until completion or `A_deadline`, + whichever fires first. Long-poll semantics, capped by the auction timeout. +- If `` is unknown (cache miss, expired TTL, or never created), returns + `404`. Client falls back to firing GPT without pre-set targeting. +- If no slot received a bid above floor, returns `{}`. Client fires GPT without targeting. +- Response carries `Cache-Control: private, no-store`. -> **Cache contract:** Any response with `__ts_bids` injected is per-user data and must -> not be cached. TS sets `Cache-Control: private, no-store` on the response before -> forwarding, overriding any conflicting cache headers from the publisher origin. -> `Surrogate-Control` and `Fastly-Surrogate-Control` are also stripped. +**Storage:** auction results cached in-process (per-edge-instance) keyed by request ID +with a 30-second TTL. Sized small (a few KB per entry) and short-lived; no Fastly KV +write on the hot path. + +**Security:** request IDs are 128-bit unguessable UUIDs. Even if a request ID leaks, the +worst-case impact is reading bid metadata that's already destined for that session's +GPT slots — no cross-user data exposure. ### 4.5 Win Notifications @@ -386,119 +455,357 @@ Prebid bid (not a direct deal or backfill) won the GAM line item match. ### 4.6 Client Residual Prebid.js is eliminated. The client-side ad bootstrap is replaced by a small inline -script (~30 lines) that reads `__ts_ad_slots` and `__ts_bids`, drives GPT directly, and -handles billing notifications: +script that reads `__ts_ad_slots`, fetches bids from `/ts-bids`, drives GPT directly, +and handles billing notifications. Slot definition happens immediately; bid targeting +and `refresh()` happen after `/ts-bids` resolves: ```javascript window.__tsAdInit = function () { var slots = window.__ts_ad_slots || [] - var bids = window.__ts_bids || {} + var rid = window.__ts_request_id + + // Kick off bid fetch as early as possible. Fires in parallel with GPT setup. + var bidsPromise = rid + ? fetch('/ts-bids?rid=' + encodeURIComponent(rid), { credentials: 'omit' }) + .then(function (r) { + return r.ok ? r.json() : {} + }) + .catch(function () { + return {} + }) + : Promise.resolve({}) + googletag.cmd.push(function () { - slots.forEach(function (slot) { + // Define slots immediately — no auction wait + var gptSlots = slots.map(function (slot) { var gptSlot = googletag .defineSlot(slot.gam_unit_path, slot.formats, slot.div_id) .addService(googletag.pubads()) - // Apply static targeting from config Object.entries(slot.targeting).forEach(function ([k, v]) { gptSlot.setTargeting(k, v) }) - // Apply pre-won bid targeting if available - var bidData = bids[slot.id] || {} - ;['hb_pb', 'hb_bidder', 'hb_adid'].forEach(function (key) { - if (bidData[key]) gptSlot.setTargeting(key, bidData[key]) - }) + return { id: slot.id, gptSlot: gptSlot } }) + googletag.pubads().enableSingleRequest() googletag.enableServices() - // Fire burl on confirmed render - googletag.pubads().addEventListener('slotRenderEnded', function (event) { - var slotId = event.slot.getSlotElementId() - var bidData = bids[slotId] || {} - if ( - !event.isEmpty && - bidData.burl && - event.slot.getTargeting('hb_adid')[0] === bidData.hb_adid - ) { - navigator.sendBeacon(bidData.burl) - } + + // Apply bid targeting and refresh once /ts-bids resolves. + bidsPromise.then(function (bids) { + gptSlots.forEach(function ({ id, gptSlot }) { + var bidData = bids[id] || {} + ;['hb_pb', 'hb_bidder', 'hb_adid'].forEach(function (key) { + if (bidData[key]) gptSlot.setTargeting(key, bidData[key]) + }) + }) + + // Fire burl on confirmed render + googletag.pubads().addEventListener('slotRenderEnded', function (event) { + var slotId = event.slot.getSlotElementId() + var bidData = bids[slotId] || {} + if ( + !event.isEmpty && + bidData.burl && + event.slot.getTargeting('hb_adid')[0] === bidData.hb_adid + ) { + navigator.sendBeacon(bidData.burl) + } + }) + + googletag.pubads().refresh() }) - googletag.pubads().refresh() }) } ``` +**Why slot definition happens before bid fetch resolves:** GPT slot definition is +synchronous and cheap. Defining slots early lets GPT prepare iframes and start any +internal work that doesn't require ad server response. `refresh()` is the call that +actually triggers the GAM ad request — that's the one we delay until bids arrive. + +**Failure modes:** + +- `/ts-bids` returns 404 (unknown rid, TTL expired) → `bidsPromise` resolves to `{}`, + `refresh()` fires without bid targeting, GAM falls back to its own auction. Same + graceful degradation as no-bid case. +- `/ts-bids` network failure → caught, resolves to `{}`, same fallback. +- Auction times out server-side → `/ts-bids` returns `{}`, same fallback. + This script is part of the existing `gpt` integration bundle (`crates/js/lib/src/integrations/gpt/index.ts`), extending the existing GPT shim. Injected via the `gpt` head injector alongside `window.__ts_ad_slots`. +### 4.7 Caching Behavior + +Page assets and bid results have very different cacheability properties. The +architecture is designed so that everything that can be cached, is. + +**What gets cached where:** + +| Asset | Cached at | Cacheability | +| ------------------------ | -------------------------------- | --------------------------------------------------------- | +| Origin HTML | Fastly edge HTTP cache | Yes, if origin sends `Cache-Control: public, max-age=...` | +| Origin CSS / fonts / JS | Fastly edge + browser | Yes (typically hashed URLs, immutable) | +| `tsjs` bundle | Fastly edge + browser | Yes (already content-hashed via `bundle.rs`, immutable) | +| `__ts_ad_slots` payload | Could be precomputed per pattern | In-memory match is sub-millisecond — not worth caching | +| `__ts_request_id` | **Never** | Per-request UUID, minted at request receipt | +| Bid results (`/ts-bids`) | In-process `bid_cache`, 30s TTL | Per-request, never shared across users | + +**Architecture:** + +1. Fastly's built-in HTTP cache stores the **origin response** keyed by URL. TS + does not implement its own HTML caching layer — it leverages the existing + Fastly cache. +2. On request: TS reads from cache (cache hit, ~5ms) or fetches from origin + (cache miss, ~150ms typical). +3. TS injects `__ts_ad_slots` + `__ts_request_id` at the `` open via the + existing `el.prepend()` head handler. This injection is per-request — origin + HTML in cache is unmodified. +4. TS forces `Transfer-Encoding: chunked` and streams the assembled response + to the browser. +5. The auction runs in parallel regardless of HTML cache state — bids land in + `bid_cache` keyed by `request_id`, served via `/ts-bids` when the client + fetches. + +The `bid_cache` (per-request bid results) and Fastly's HTML cache are +**independent systems**. HTML cache hit/miss does not affect auction firing; +auction firing does not affect HTML caching. + +**`Cache-Control` handling:** + +TS preserves the origin's `Cache-Control` header on the response sent to the +browser, with one override: when `__ts_request_id` is injected (any matched +page), TS sets `Cache-Control: private, no-store` on the **browser-facing** +response to prevent intermediate caches or the browser from caching the +per-user assembled HTML. The Fastly edge cache for the **origin** response is +unaffected — TS reads the cached origin HTML and assembles a fresh per-request +response on every hit. + +`Surrogate-Control` and `Fastly-Surrogate-Control` headers from origin are +preserved (they control Fastly's cache, not the browser's). + +**When caching doesn't apply:** + +- **Logged-in users** — origin typically returns `Cache-Control: private`. Falls + back to cache-miss timing (full origin fetch). +- **Personalized SSR** (per-user content, A/B test variants) — same. +- **Dynamic NextJS routes without ISR** — origin sends `Cache-Control: no-store` + or short max-age. Falls back to cache-miss timing. +- **First request after deploy or cache purge** — cold cache, full origin fetch. +- **Long-tail URLs** — low cache hit rate, treat as cache-miss case. + +For typical news / content publisher sites with anonymous visitors on stable +content pages, expect 70–90%+ edge cache hit rate. The cache-hit timing in §5 +is the realistic common case, not the optimistic best case. + --- ## 5. Request-Time Sequence +Sequence applies to all origins (WordPress, Drupal, Rails, NextJS 14/16, static sites). +TS forces chunked encoding on every response, so origin format is invisible from the +browser's perspective. + +### 5.1 Visual Sequence (full content + creative flow) + +```mermaid +sequenceDiagram + autonumber + participant B as Browser + participant E as TS Edge
(Fastly) + participant C as Fastly HTTP Cache + participant O as Publisher Origin
(WP / NextJS / etc) + participant A as Auction
(PBS + APS) + participant S as SSPs
(Kargo / Index / etc) + participant G as GAM
(securepubads) + + Note over B,G: t=0ms — Navigation start + + B->>E: GET ts.publisher.com/article + + Note over E: t=1ms — URL → slots match
Mint request_id (UUID)
Check consent + + par Auction kicks off server-side + E->>A: POST bid requests
(PBS + APS in parallel) + A->>S: Fan out to all SSPs + S-->>A: Bids return + A-->>E: Aggregated bid responses
(t=502ms) + Note over E: Cache bids in bid_cache
(keyed by request_id, 30s TTL) + E->>S: Fire nurl (fire-and-forget)
for winning bids + and Origin HTML lookup + E->>C: Lookup origin HTML by URL + alt Cache HIT (typical for content pages) + C-->>E: Cached HTML (~5ms) + else Cache MISS (cold / dynamic / logged-in) + C->>O: GET origin HTML + O-->>C: HTML response (~150ms) + C-->>E: HTML response + end + end + + Note over E: Force Transfer-Encoding: chunked
Inject __ts_ad_slots + __ts_request_id
at open
Set Cache-Control: private, no-store + + E-->>B: Stream HTML chunks (no auction wait) + + Note over B: TTFB: ~10ms (hit) / ~155ms (miss)
Browser parses
CSS, fonts, tsjs download
(also from Fastly + browser cache) + + Note over B: flushes immediately
Body parsing begins
🎨 FCP: ~80ms (hit) / ~250ms (miss) + + Note over B: tsjs bundle executes
t=130ms (hit) / t=300ms (miss)
__tsAdInit() defines GPT slots
(no GAM call yet) + + B->>E: GET /ts-bids?rid= + + alt Auction already complete (typical on cache-hit pages) + Note over E: bid_cache hit — return immediately + E-->>B: Bid targeting JSON
(hb_pb, hb_bidder, hb_adid, burl) + else Auction still running + Note over E: Long-poll — block until
auction completes or A_deadline + A-->>E: Bids arrive + E-->>B: Bid targeting JSON
(or {} on timeout) + end + + Note over B: Bids received (~30ms RTT)
setTargeting(hb_*) per slot
Register slotRenderEnded listener
googletag.pubads().refresh() fires + + B->>G: GET /gampad/ads
with hb_* key-values + + Note over G: GAM matches hb_pb against
Prebid line items, selects winner + + G-->>B: Ad markup
(iframe HTML or creative URL) + + Note over B: Creative iframe loads in slot
Fetches sub-resources
(images, scripts, viewability pixels) + + Note over B: 🎯 Creative paints
slotRenderEnded event fires
__tsAdInit checks hb_adid match + + alt Our Prebid bid won the GAM line item match + B->>S: Fire burl (navigator.sendBeacon)
SSP confirms billable impression + else Direct deal / backfill won (hb_adid mismatch or empty) + Note over B: No burl fired — our bid lost
(correct behavior — different creative rendered) + end + + Note over B: window.load fires
(page fully loaded) + + Note over B,G: ✅ AD VISIBLE
Cache hit: ~900ms total
Cache miss: ~1,050ms total
FCP: ~80ms (hit) / ~250ms (miss)

vs client-side today: ~3,250ms ad-visible / FCP ~500ms+ +``` + +### 5.2 Cache-Hit Sequence (typical for content publisher pages) + +This is the common case for anonymous visitors on cacheable content pages. + ``` t=0ms GET ts.publisher.com/article arrives at Fastly edge t=1ms URL matched against creative-opportunities.toml Slots matched: [atf_sidebar_ad, below-content-ad, section_ad] Consent check: TCF consent present → auction proceeds + Request ID minted: 550e8400-e29b-41d4-a716-446655440000 -t=2ms AuctionOrchestrator.run_auction() called +t=2ms AuctionOrchestrator.run_auction() dispatched (parallel) PBS + APS dispatched in parallel via send_async() Edge→PBS RTT: ~20–30ms + Fastly cache lookup dispatched in parallel + __ts_ad_slots + __ts_request_id ".to_string() + r#""# + .to_string() ), - ad_bids_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("window.__ts_ad_slots"), "should inject ad slots"); + 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 injects_bids_before_end_of_head() { - let bids_script = ""; + 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, - ad_bids_script: Some(bids_script.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_bids"), "should inject bids"); - let bids_pos = html.find("window.__ts_bids").expect("should find bids"); - let end_head_pos = html.find("").expect("should find "); - assert!(bids_pos < end_head_pos, "bids script should appear before "); + 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`/`ad_bids_script` fields, no `empty_for_tests()`) + Expected: compile error (no `ad_slots_script` field, no `empty_for_tests()`) - [ ] **Step 2: Add `empty_for_tests()` to `IntegrationRegistry`** @@ -888,7 +886,6 @@ Adding the two new fields to `HtmlProcessorConfig` and the injection logic is in #[cfg(test)] impl IntegrationRegistry { pub fn empty_for_tests() -> Self { - // Minimal registry with no integrations for unit testing html_processor Self { inner: Arc::new(RegistryInner { proxies: Default::default(), @@ -905,7 +902,9 @@ Adding the two new fields to `HtmlProcessorConfig` and the injection logic is in (Adjust field names to match the actual `RegistryInner` struct.) -- [ ] **Step 3: Add fields to `HtmlProcessorConfig`** +- [ ] **Step 3: Add single field to `HtmlProcessorConfig`** + + Replace any existing `ad_slots_script`/`ad_bids_script` fields with: ```rust pub struct HtmlProcessorConfig { @@ -913,56 +912,47 @@ Adding the two new fields to `HtmlProcessorConfig` and the injection logic is in pub request_host: String, pub request_scheme: String, pub integrations: IntegrationRegistry, - /// Pre-computed `` for matched slots. - /// Injected at open, before integration head inserts. `None` when no slots matched. + /// Pre-computed ``. + /// Injected at `` open, before integration head inserts. `None` when no slots matched. pub ad_slots_script: Option, - /// Pre-computed `` for winning bids. - /// Injected immediately before via on_end_tag(). `None` when auction not run. - pub ad_bids_script: Option, } ``` - Update `from_settings` to initialize `ad_slots_script: None, ad_bids_script: None`. + Update `from_settings` (or wherever `HtmlProcessorConfig` is constructed) to initialize `ad_slots_script: None`. -- [ ] **Step 4: Inject `__ts_ad_slots` at head-open AND register `on_end_tag` for `__ts_bids`** +- [ ] **Step 4: Inject `ad_slots_script` at head-open** - In `create_html_processor`, within the EXISTING single `element!("head", ...)` handler, make two changes: - 1. Prepend the ad slots script BEFORE the existing integration inserts: + 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 - // NEW: inject __ts_ad_slots first - if let Some(ref slots_script) = ad_slots_script { - snippet.push_str(slots_script); - } - // ... existing: for insert in integrations.head_inserts(&ctx) { ... } - ``` + ```rust + let ad_slots_script = config.ad_slots_script.clone(); + // ... existing captures ... - 2. After `el.prepend(...)`, register the end-tag handler for `__ts_bids`: - ```rust - // Register on_end_tag handler for __ts_bids injection before - if let Some(bids_script) = ad_bids_script.clone() { - el.on_end_tag(move |end_tag| { - end_tag.before(&bids_script, ContentType::Html); - Ok(()) - })?; - } - ``` + element!("head", |el| { + let mut snippet = String::new(); - Both changes live inside the same `element!("head", ...)` closure — no second handler needed. + // 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); + } - Capture `ad_slots_script` and `ad_bids_script` into the closure the same way as `injected_tsjs`: + // ... existing: for insert in integrations.head_inserts(&ctx) { snippet.push_str(...) } - ```rust - let ad_slots_script = config.ad_slots_script.clone(); - let ad_bids_script = config.ad_bids_script.clone(); + if !snippet.is_empty() { + el.prepend(&snippet, ContentType::Html); + } + // DO NOT register on_end_tag — flushes immediately per spec §4.3 + Ok(()) + }) ``` - > **lol_html `on_end_tag` API note:** `Element::on_end_tag(handler)` is available in lol_html ≥2.0. The handler receives `&mut EndTag` and must return `Result<(), Box>`. Use `ContentType::Html` so the injected `", escaped) + let slots_json_str = serde_json::to_string(&slots_json) + .expect("should serialize ad slots"); + let escaped_slots = html_escape_for_script(&slots_json_str); + // request_id is a UUID (hex + hyphens only) — safe to embed without escaping. + format!( + r#""# + ) } - pub(crate) fn build_ad_bids_script( + /// 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, - ) -> String { - let bids_map: serde_json::Map = winning_bids + ) -> crate::bid_cache::BidMap { + winning_bids .iter() .filter_map(|(slot_id, bid)| { let cpm = bid.price?; - let entry = serde_json::json!({ - "hb_pb": price_bucket(cpm, price_granularity), - "hb_bidder": bid.bidder, - "hb_adid": bid.ad_id.as_deref().unwrap_or(""), - "burl": bid.burl, - }); - Some((slot_id.clone(), entry)) + 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(); - let json = serde_json::to_string(&serde_json::Value::Object(bids_map)) - .expect("should serialize bids"); - let escaped = html_escape_for_script(&json); - format!("", escaped) + .collect() } /// HTML-escape a JSON string for safe inline `" .to_string(), - // __tsAdInit definition — reads window.__ts_ad_slots / __ts_bids at call time. + // __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!( "" @@ -1394,20 +1825,20 @@ The `HtmlProcessorConfig` fields now exist (Task 7). This task wires the auction ```bash git add crates/trusted-server-core/src/integrations/gpt.rs - git commit -m "Emit __tsAdInit function definition from GPT head injector" + git commit -m "Emit __tsAdInit with /ts-bids fetch pattern from GPT head injector" ``` --- -## Task 10: `gpt/index.ts` — TypeScript `__tsAdInit` +## Task 12: `gpt/index.ts` — TypeScript `__tsAdInit` with `/ts-bids` fetch **Files:** - Modify: `crates/js/lib/src/integrations/gpt/index.ts` -The TypeScript version is the authoritative implementation; it must mirror the Rust inline string from Task 9 exactly. +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 a failing test** +- [ ] **Step 1: Write failing tests** In `crates/js/lib/src/integrations/gpt/index.test.ts`: @@ -1417,20 +1848,21 @@ The TypeScript version is the authoritative implementation; it must mirror the R describe('installTsAdInit', () => { beforeEach(() => { delete (window as any).__ts_ad_slots - delete (window as any).__ts_bids + delete (window as any).__ts_request_id delete (window as any).__tsAdInit }) - it('defines googletag slots from __ts_ad_slots and calls refresh', () => { + 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(), - getTargeting: vi.fn().mockReturnValue([]), } ;(window as any).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, @@ -1447,45 +1879,131 @@ The TypeScript version is the authoritative implementation; it must mirror the R targeting: { pos: 'atf' }, }, ] - ;(window as any).__ts_bids = { - atf: { - hb_pb: '1.00', - hb_bidder: 'kargo', - hb_adid: 'abc', - burl: 'https://ssp/bill', - }, - } + ;(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) - // Must import installTsAdInit from the module - const { installTsAdInit } = require('./index') + const { installTsAdInit } = await import('./index') installTsAdInit() - ;(window as any).__tsAdInit() + await (window as any).__tsAdInit() - expect((window as any).googletag.defineSlot).toHaveBeenCalledWith( - '/123/atf', - [[300, 250]], - 'atf' + 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', () => { + it('fires burl via sendBeacon on slotRenderEnded when our bid won', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true) - // ... setup and trigger slotRenderEnded event - // Verify: navigator.sendBeacon called with burl + 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 + 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` (bottom of file): + Add to `crates/js/lib/src/integrations/gpt/index.ts`: ```typescript interface TsAdSlot { @@ -1505,60 +2023,87 @@ The TypeScript version is the authoritative implementation; it must mirror the R type TsWindow = Window & { __ts_ad_slots?: TsAdSlot[] - __ts_bids?: Record + __ts_request_id?: string __tsAdInit?: () => void } /** - * Install `window.__tsAdInit` — reads `window.__ts_ad_slots` and `window.__ts_bids` - * (injected by the edge into ), defines GPT slots, applies pre-won bid targeting, - * registers a `slotRenderEnded` listener to fire `burl` via `sendBeacon`, then calls - * `refresh()`. + * 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 bids = w.__ts_bids ?? {} + 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(() => { - slots.forEach((slot) => { - 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) - ) - const bid = bids[slot.id] ?? {} - ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { - if (bid[key]) gptSlot.setTargeting(key, bid[key]!) + 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() - 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) - } + + 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() }) - g.pubads().refresh() }) } } ``` - Call `installTsAdInit()` from the integration's initialization path so it's set up when the bundle loads. + Call `installTsAdInit()` from the integration's initialization path. - [ ] **Step 3: Run JS tests** @@ -1574,12 +2119,12 @@ The TypeScript version is the authoritative implementation; it must mirror the R ```bash git add crates/js/lib/src/integrations/gpt/ - git commit -m "Add __tsAdInit and slotRenderEnded burl firing to GPT integration" + git commit -m "Add installTsAdInit with /ts-bids fetch pattern and slotRenderEnded burl firing" ``` --- -## Task 11: `nurl` fire-and-forget +## Task 13: `nurl` fire-and-forget **Files:** @@ -1610,9 +2155,9 @@ The TypeScript version is the authoritative implementation; it must mirror the R fn default_fire_nurl_at_edge() -> bool { true } ``` -- [ ] **Step 3: Fire nurls in publisher.rs after auction** +- [ ] **Step 3: Fire nurls in publisher.rs after bid_cache.put()** - After `auction_result` is obtained, add: + After the `bid_cache.put(...)` call (Task 9 Step 3), add: ```rust if let Some(ref result) = auction_result { @@ -1620,7 +2165,7 @@ The TypeScript version is the authoritative implementation; it must mirror the R } ``` - Add helper (no `.await` — fire-and-forget): + Add helper: ```rust fn fire_winning_nurls( @@ -1671,13 +2216,13 @@ The TypeScript version is the authoritative implementation; it must mirror the R --- -## Task 12: End-to-end integration tests +## 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 8 directly. +Tests use `pub(crate)` helpers from Task 9 directly. - [ ] **Step 1: Write tests** @@ -1686,7 +2231,7 @@ Tests use `pub(crate)` helpers from Task 8 directly. ```rust #[cfg(test)] mod creative_opportunities_tests { - use super::{build_ad_slots_script, build_ad_bids_script, html_escape_for_script}; + use super::{build_head_globals_script, build_bid_map, html_escape_for_script}; use crate::creative_opportunities::{ CreativeOpportunitiesConfig, CreativeOpportunitySlot, CreativeOpportunityFormat, CreativeOpportunitiesFile, match_slots, @@ -1719,20 +2264,32 @@ Tests use `pub(crate)` helpers from Task 8 directly. } #[test] - fn ad_slots_script_is_safe_and_parseable() { + fn head_globals_script_contains_ad_slots_and_request_id() { let slots = vec![make_slot()]; 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"); + 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"); - // Verify no raw < or > that could break HTML parser - let inner = script.trim_start_matches(""); + 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 ad_bids_script_uses_price_bucket_and_ad_id() { + 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(), @@ -1747,11 +2304,23 @@ Tests use `pub(crate)` helpers from Task 8 directly. ad_id: Some("prebid-uuid-abc123".to_string()), metadata: HashMap::new(), }); - let script = build_ad_bids_script(&winning_bids, PriceGranularity::Dense); - assert!(script.contains("\"hb_pb\":\"2.53\""), "should bucket 2.53 as 2.53 (dense)"); - assert!(script.contains("\"hb_bidder\":\"kargo\""), "should include bidder"); - assert!(script.contains("\"hb_adid\":\"prebid-uuid-abc123\""), "should use ad_id not creative markup"); - assert!(script.contains("burl"), "should include burl for billing"); + 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] @@ -1795,7 +2364,7 @@ Tests use `pub(crate)` helpers from Task 8 directly. ```bash git add crates/trusted-server-core/src/publisher.rs - git commit -m "Add integration tests for creative opportunities pipeline (slots, bids, XSS)" + git commit -m "Add integration tests for creative opportunities pipeline (head globals, bid map, XSS)" ``` --- @@ -1804,19 +2373,27 @@ Tests use `pub(crate)` helpers from Task 8 directly. Run `fastly compute serve` and verify: -- [ ] **No match:** Request `/about` — no `__ts_ad_slots` or `__ts_bids` in response HTML, no `Cache-Control: private, no-store` -- [ ] **Match:** Request `/2024/01/article` — both globals present in ``, `Cache-Control: private, no-store` set -- [ ] **Empty file kill-switch:** Empty `creative-opportunities.toml` → no globals injected on any URL -- [ ] **Auction timeout:** Set `auction_timeout_ms = 1` → `__ts_bids` injects as `{}`, no slot entries -- [ ] **XSS check:** Add `targeting = { zone = "