From 2b326e778c4c76404b75602793f38173527ee1b9 Mon Sep 17 00:00:00 2001 From: jevansnyc Date: Wed, 15 Apr 2026 20:45:20 +0200 Subject: [PATCH 01/34] 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 = " -``` - -> **Security:** All string values are JSON-serialized via `serde_json` and HTML-escaped -> before insertion into the `, ContentType::Html)`. + +> **Security:** All string values are JSON-serialized via `serde_json` and HTML-escaped +> before insertion into the `"# - .to_string() + r#""#.to_string() ), + ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)), }; let mut processor = create_html_processor(config); let output = processor - .process_chunk(b"T", true) + .process_chunk(b"Tcontent", 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"); + assert!(!html.contains("__ts_request_id"), "must NOT inject request_id — body-injection arch has no request_id"); } #[test] - fn does_not_hold_end_of_head() { - // Verify: no bid data appears before — that hold was rejected by spec §4.3 + fn injects_ts_bids_before_body_close() { + let bids_script = r#""#; + let state = std::sync::Arc::new(std::sync::RwLock::new( + Some(bids_script.to_string()) + )); let config = HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + ad_bids_state: state, }; let mut processor = create_html_processor(config); let output = processor - .process_chunk(b"T", true) + .process_chunk(b"content", 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"); + assert!(html.contains("window.__ts_bids"), "should inject bids before "); + let bids_pos = html.find("window.__ts_bids").expect("bids should be in output"); + let body_close_pos = html.find("").expect(" should be in output"); + assert!(bids_pos < body_close_pos, "bids must appear before "); + } + + #[test] + fn injects_empty_ts_bids_when_state_is_none() { + let state = std::sync::Arc::new(std::sync::RwLock::new(None)); + 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_state: state, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!(html.contains("__ts_bids=JSON.parse(\"{}\""), "should inject empty bids on None state"); } ``` Run: `cargo test -p trusted-server-core html_processor` - Expected: compile error (no `ad_slots_script` field, no `empty_for_tests()`) + Expected: compile error (no `ad_bids_state` field yet) - [ ] **Step 2: Add `empty_for_tests()` to `IntegrationRegistry`** @@ -902,9 +930,7 @@ The `hb_pb` value in bid responses is a discretized bucket string from Prebid's (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: +- [ ] **Step 3: Update `HtmlProcessorConfig`** ```rust pub struct HtmlProcessorConfig { @@ -912,362 +938,104 @@ The `hb_pb` value in bid responses is a discretized bucket string from Prebid's 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. + /// Pre-computed ``. + /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, + /// Shared auction result script — written by the auction task before HTML processing + /// begins. Handler reads this in `el.on_end_tag()` on the body element. + /// `None` means no auction ran (consent denied, bot UA, no slot match, etc.); + /// inject empty `__ts_bids = {}` as graceful fallback. + pub ad_bids_state: std::sync::Arc>>, } ``` - Update `from_settings` (or wherever `HtmlProcessorConfig` is constructed) to initialize `ad_slots_script: None`. + Update `from_settings` (or wherever `HtmlProcessorConfig` is constructed) to initialize `ad_bids_state: Arc::new(RwLock::new(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): + In `create_html_processor`, within the existing `element!("head", ...)` handler: ```rust let ad_slots_script = config.ad_slots_script.clone(); - // ... existing captures ... + // 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(...) } - + // existing integration head inserts... if !snippet.is_empty() { el.prepend(&snippet, ContentType::Html); } - // DO NOT register on_end_tag — flushes immediately per spec §4.3 + // DO NOT register on_end_tag — flushes immediately 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** +- [ ] **Step 5: Inject `__ts_bids` before `` via `el.on_end_tag()`** - Create `crates/trusted-server-core/src/bid_cache.rs` with only the tests: + Add a new handler in `create_html_processor`. The shared state is already populated by the time lol_html reaches `` (Task 9 awaits the auction before starting HTML processing): ```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"); + let ad_bids_state = config.ad_bids_state.clone(); + + element!("body", |el| { + let state = ad_bids_state.clone(); + el.on_end_tag(move |end_tag| { + let script = state.read().expect("should read bid state"); + let bids_script = match &*script { + Some(s) => s.clone(), + None => { + r#""#.to_string() } - 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)); - } - } - } - } - } + }; + end_tag.before(&bids_script, ContentType::Html); + Ok(()) + })?; + Ok(()) + }) ``` -- [ ] **Step 3: Export from lib.rs** +- [ ] **Step 6: Run tests** - ```rust - pub mod bid_cache; - ``` + Run: `cargo test -p trusted-server-core html_processor` + Expected: all tests pass -- [ ] **Step 4: Run tests** +- [ ] **Step 7: Run full suite** - Run: `cargo test -p trusted-server-core bid_cache` - Expected: all tests pass + Run: `cargo test --workspace` + Expected: clean -- [ ] **Step 5: Commit** +- [ ] **Step 8: 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" + git add crates/trusted-server-core/src/html_processor.rs \ + crates/trusted-server-core/src/integrations/registry.rs + git commit -m "Inject __ts_ad_slots at head-open and __ts_bids before via shared auction state" ``` --- -## Task 9: `handle_publisher_request` async restructuring +## Task 8: `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). +> **Key constraint from spec §4.3 and §3:** No `bid_cache`. No `/ts-bids`. No `request_id`. Bids travel inline with the HTML response via body injection. The `Arc>>` is the coordination mechanism within a single request's lifetime — it is written before HTML processing and read by the lol_html `` handler. + +> **Eligibility gating (spec §4.3):** Auctions fire only for real GET requests from non-bot, non-prefetch clients with TCF Purpose 1 consent and at least one matching slot. All other requests proceed with no auction and no `__ts_bids` injection. + +> **Cache-Control (spec §4.7):** Set `Cache-Control: private, max-age=0` (not `no-store`) to preserve BFCache eligibility. Strip `Surrogate-Control` and `Fastly-Surrogate-Control`. - [ ] **Step 1: Update function signature** Change `handle_publisher_request` in `publisher.rs`: + > **Existing context:** The existing `publisher.rs` function body already computes `consent_context`, `ec_id`, `request_info`, `origin_host`, and `backend_name` before the origin fetch. Steps below insert new logic between those existing computations and the origin fetch — they do not replace them. + ```rust pub async fn handle_publisher_request( settings: &Settings, @@ -1275,31 +1043,48 @@ The `BidCache` stores auction results keyed by `request_id` with a 30-second TTL 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: + Add imports at top of file: ```rust + use std::sync::{Arc, RwLock}; + use fastly::http::header; 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** + > **`send_async` return type:** `req.send_async()` returns `fastly::handle::PendingRequestHandle` (re-exported as `fastly::PendingRequest` in recent versions). Confirm the exact type from the `fastly` crate version in `Cargo.toml`; `.wait()` is the blocking resolve method on whichever type is returned. - At the top of the function body, before the origin fetch: +- [ ] **Step 2: Apply auction-eligibility gates** - ```rust - // Mint per-request UUID — included in head injection and /ts-bids lookup key. - let request_id = uuid::Uuid::new_v4().to_string(); + At the top of the function body, before origin fetch: + ```rust let request_path = req.get_path().to_string(); - let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() { + let request_method = req.get_method().clone(); + + // Gate 1: Only GET triggers auctions. HEAD skips everything. + let is_get = request_method == fastly::http::Method::GET; + + // Gate 2: Skip prefetch hints (Sec-Purpose: prefetch or Purpose: prefetch). + let is_prefetch = req.get_header_str("sec-purpose") + .map_or(false, |v| v.contains("prefetch")) + || req.get_header_str("purpose") + .map_or(false, |v| v.contains("prefetch")); + + // Gate 3: Skip well-known crawler UAs (protects SSP QPS budget). + let user_agent = req.get_header_str("user-agent").unwrap_or(""); + let is_bot = ["Googlebot", "Bingbot", "AhrefsBot", "SemrushBot", "DotBot"] + .iter() + .any(|bot| user_agent.contains(bot)); + + // Gate 4: Slot match. + let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() && is_get { match_slots(&slots_file.slots, &request_path) .into_iter() .cloned() @@ -1308,11 +1093,17 @@ The `BidCache` stores auction results keyed by `request_id` with a 30-second TTL Vec::new() }; + // Gate 5: TCF Purpose 1 consent. 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 should_run_auction = is_get + && !is_prefetch + && !is_bot + && !matched_slots.is_empty() + && consent_allows_auction; let auction_timeout_ms = settings .creative_opportunities @@ -1321,33 +1112,24 @@ The `BidCache` stores auction results keyed by `request_id` with a 30-second TTL .unwrap_or(settings.auction.timeout_ms); ``` -- [ ] **Step 3: Register pending in bid_cache, fire origin + auction concurrently** +- [ ] **Step 3: Create shared bid state, 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); - } + // Shared state: auction task writes the ready-to-inject script; lol_html + // handler reads it. Both within the same request — no cross-request sharing. + let ad_bids_state: Arc>> = Arc::new(RwLock::new(None)); 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. + // Fire origin immediately — both origin and auction SSP calls overlap on the network. 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). + // Run auction. Internal SSP calls use send_async and overlap with origin fetch. 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"); @@ -1377,17 +1159,20 @@ The `BidCache` stores auction results keyed by `request_id` with a 30-second TTL None }; - // Write auction results to bid_cache — /ts-bids will serve them. + // Write auction result to shared state before HTML processing begins. + // The lol_html handler reads this synchronously — it is always populated here. + // `build_bid_map` returns `serde_json::Map`. 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 empty_bids: std::collections::HashMap = + 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); + let bids_script = build_bids_script(&bid_map); + *ad_bids_state.write().expect("should write bid state") = Some(bids_script); } // Await origin response (may already be buffered since we started it before the auction). @@ -1403,10 +1188,9 @@ The `BidCache` stores auction results keyed by `request_id` with a 30-second TTL 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)) + Some(build_ad_slots_script(&matched_slots, co_config)) } else { None } @@ -1414,33 +1198,91 @@ The `BidCache` stores auction results keyed by `request_id` with a 30-second TTL 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. + // Set cache headers when slots matched. private, max-age=0 (not no-store) preserves + // BFCache eligibility — browser back/forward cache restores the already-rendered ad + // without firing a new GAM call, which is the desired behavior. if ad_slots_script.is_some() { - response.set_header(header::CACHE_CONTROL, "private, no-store"); + response.set_header(header::CACHE_CONTROL, "private, max-age=0"); 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. + // Force chunked encoding so reaches the browser immediately as chunks arrive. + // Sending both Content-Length and Transfer-Encoding is invalid HTTP/1.1. response.remove_header(header::CONTENT_LENGTH); response.set_header("transfer-encoding", "chunked"); ``` -- [ ] **Step 5: Add `pub(crate)` helper functions** +- [ ] **Step 5: Thread shared state into `OwnedProcessResponseParams`** + + Update `OwnedProcessResponseParams`: + + ```rust + pub struct OwnedProcessResponseParams { + // existing fields... + pub(crate) ad_slots_script: Option, + pub(crate) ad_bids_state: Arc>>, + } + ``` + + Pass both through to `create_html_stream_processor` and into `HtmlProcessorConfig`. + +- [ ] **Step 6: Add `pub(crate)` helper functions** + + > **`BidMap` type:** Use `serde_json::Map` directly — no separate module needed. + + Add helpers in this order (each function is used by the one below it, so define leaf functions first): ```rust + /// HTML-escape a JSON string for safe inline `"#) + } + /// 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 `"#) } fn build_auction_request( @@ -1535,39 +1332,25 @@ The `BidCache` stores auction results keyed by `request_id` with a 30-second TTL } ``` -- [ ] **Step 6: Thread `ad_slots_script` into `OwnedProcessResponseParams`** - - Update `OwnedProcessResponseParams`: - - ```rust - pub struct OwnedProcessResponseParams { - // existing fields... - pub(crate) ad_slots_script: Option, - } - ``` - - Pass `ad_slots_script` through to `create_html_stream_processor` and into `HtmlProcessorConfig`. + > **Type note:** All helper signatures use `serde_json::Map` directly. Do not create a `BidMap` type alias or `bid_types.rs` module. - [ ] **Step 7: Update `main.rs` call site** In `crates/trusted-server-adapter-fastly/src/main.rs`: ```rust - // At startup — load creative-opportunities.toml and initialize bid_cache. + // At startup (top of main() / request handler setup, before the request dispatch loop). + // include_str! embeds the file at compile time — no runtime file I/O. const CREATIVE_OPPORTUNITIES_TOML: &str = include_str!("../../../creative-opportunities.toml"); - let slots_file: creative_opportunities::CreativeOpportunitiesFile = + let slots_file: trusted_server_core::creative_opportunities::CreativeOpportunitiesFile = toml::from_str(CREATIVE_OPPORTUNITIES_TOML) .expect("should parse creative-opportunities.toml"); - - // BidCache: 30s TTL, capacity 1000 entries (each entry is a few KB). - let bid_cache = crate::bid_cache::BidCache::new( - std::time::Duration::from_secs(30), - 1000, - ); ``` + `slots_file` is a local in the startup/handler scope and passed by reference into `handle_publisher_request` on each request — no `Arc` needed since it's immutable and the handler borrows it. + Update the call to `handle_publisher_request`: ```rust @@ -1575,15 +1358,16 @@ The `BidCache` stores auction results keyed by `request_id` with a 30-second TTL settings, integration_registry, &publisher_services, - orchestrator, // existing - &slots_file, // new - &bid_cache, // new + orchestrator, // existing + &slots_file, // new req, ).await { // existing match arms unchanged } ``` + There is **no `/ts-bids` route** to add. The body injection is complete within `handle_publisher_request`. + - [ ] **Step 8: Compile check** Run: `cargo check --workspace` @@ -1599,164 +1383,43 @@ The `BidCache` stores auction results keyed by `request_id` with a 30-second TTL ```bash git add crates/trusted-server-core/src/publisher.rs \ crates/trusted-server-adapter-fastly/src/main.rs - git commit -m "Convert handle_publisher_request to async; auction writes to bid_cache; inject head globals only" + git commit -m "Convert handle_publisher_request to async; body-inject __ts_bids; eligibility gates; max-age=0" ``` --- -## Task 10: `/ts-bids` endpoint - -**Files:** - -- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` - -The `/ts-bids` endpoint is the client's fetch target for bid results. It long-polls until the auction completes or the deadline fires, then returns JSON. Bid results were already stored in `bid_cache` by Task 9. - -- [ ] **Step 1: Write failing test (integration-style)** - - In `main.rs` test module (or a new `tests/ts_bids.rs`): - - ```rust - #[test] - fn ts_bids_response_structure() { - use crate::bid_cache::{BidCache, WaitResult}; - use std::time::{Duration, Instant}; - - let cache = BidCache::new(Duration::from_secs(30), 100); - let rid = "test-rid-abc"; - let deadline = Instant::now() + Duration::from_secs(5); - cache.mark_pending(rid, deadline); - let mut bids = std::collections::HashMap::new(); - bids.insert("atf".to_string(), serde_json::json!({ - "hb_pb": "1.00", "hb_bidder": "kargo", "hb_adid": "abc", "burl": null, - })); - cache.put(rid, bids); - - match cache.wait_for(rid, deadline) { - WaitResult::Bids(b) => { - assert!(b.contains_key("atf"), "should contain atf slot bids"); - } - other => panic!("expected Bids, got {:?}", other), - } - } - ``` - - Run: `cargo test -p trusted-server-adapter-fastly ts_bids` - Expected: compile error (no handler yet, or pass since it's testing bid_cache directly) - -- [ ] **Step 2: Add `/ts-bids` route handler in `main.rs`** - - In the request routing section, before the publisher fallback, add: - - ```rust - if req.get_path() == "/ts-bids" && req.get_method() == fastly::http::Method::GET { - return handle_ts_bids_request(req, &bid_cache, settings); - } - ``` - - Add the handler function: - - ```rust - fn handle_ts_bids_request( - req: fastly::Request, - bid_cache: &crate::bid_cache::BidCache, - settings: &Settings, - ) -> fastly::Response { - // Parse `rid` query param. - let rid = req.get_query_parameter("rid").map(String::from); - let rid = match rid { - Some(r) if !r.is_empty() => r, - _ => { - return fastly::Response::from_status(fastly::http::StatusCode::BAD_REQUEST) - .with_body_text_plain("missing rid parameter"); - } - }; - - // Use the stored T₀ auction deadline from bid_cache — not a freshly-minted - // Instant::now() + timeout, which would extend the window past the original A_deadline. - // Spec §4.4: "/ts-bids blocks until auction completion or A_deadline" where A_deadline - // = T₀ + auction_timeout_ms (minted at page request receipt, stored in bid_cache entry). - let deadline = bid_cache.get_auction_deadline(&rid) - .unwrap_or_else(|| { - // Fallback: rid is unknown or already complete. wait_for returns immediately. - std::time::Instant::now() - }); - - let result = bid_cache.wait_for(&rid, deadline); - - match result { - crate::bid_cache::WaitResult::Bids(bids) => { - let body = serde_json::to_string(&bids) - .unwrap_or_else(|_| "{}".to_string()); - fastly::Response::from_status(fastly::http::StatusCode::OK) - .with_header(fastly::http::header::CONTENT_TYPE, "application/json") - .with_header(fastly::http::header::CACHE_CONTROL, "private, no-store") - .with_body(body) - } - crate::bid_cache::WaitResult::Empty => { - fastly::Response::from_status(fastly::http::StatusCode::OK) - .with_header(fastly::http::header::CONTENT_TYPE, "application/json") - .with_header(fastly::http::header::CACHE_CONTROL, "private, no-store") - .with_body("{}") - } - crate::bid_cache::WaitResult::NotFound => { - fastly::Response::from_status(fastly::http::StatusCode::NOT_FOUND) - .with_header(fastly::http::header::CACHE_CONTROL, "private, no-store") - .with_body_text_plain("unknown request id") - } - } - } - ``` - -- [ ] **Step 3: Compile check** - - Run: `cargo check --workspace` - Expected: clean - -- [ ] **Step 4: Run tests** - - Run: `cargo test --workspace` - Expected: all pass - -- [ ] **Step 5: Commit** - - ```bash - git add crates/trusted-server-adapter-fastly/src/main.rs - git commit -m "Add /ts-bids endpoint with long-poll semantics; serves bid_cache results by request_id" - ``` - ---- - -## Task 11: GPT head injector — emit `__tsAdInit` with `/ts-bids` fetch +## Task 9: GPT head injector — emit `__tsAdInit` with synchronous bid read **Files:** - Modify: `crates/trusted-server-core/src/integrations/gpt.rs` -> **Critical:** The `__tsAdInit` function MUST fetch `/ts-bids?rid=` — it must NOT read from `window.__ts_bids` (which is never set). The `window.__ts_request_id` global (injected at head-open by Task 9) supplies the RID. +> **Critical:** `__tsAdInit` reads `window.__ts_bids` **synchronously** — no fetch, no Promise. `window.__ts_bids` is already on the page (injected before ``) when `__tsAdInit` runs (it executes post-DCL, after `` is received). Both `nurl` and `burl` fire client-side from `slotRenderEnded`; neither is fired server-side. - [ ] **Step 1: Write failing test** ```rust #[test] - fn head_inserts_includes_ts_ad_init_with_ts_bids_fetch() { + fn head_inserts_includes_ts_ad_init_with_synchronous_bids_read() { let config = test_config(); let integration = GptIntegration::new(config); let ctx = make_test_context(); let inserts = integration.head_inserts(&ctx); let combined = inserts.join(""); assert!(combined.contains("__tsAdInit"), "should define __tsAdInit"); - assert!(combined.contains("/ts-bids"), "should fetch from /ts-bids endpoint"); - assert!(combined.contains("__ts_request_id"), "should use __ts_request_id for rid"); - assert!(combined.contains("bidsPromise"), "should use bidsPromise pattern"); + assert!(combined.contains("window.__ts_bids"), "should read window.__ts_bids synchronously"); + assert!(combined.contains("ts_initial"), "should set ts_initial sentinel"); assert!(combined.contains("slotRenderEnded"), "should register slotRenderEnded"); - assert!(combined.contains("sendBeacon"), "should fire burl via sendBeacon"); - assert!(!combined.contains("__ts_bids"), "must NOT read window.__ts_bids — bids come from /ts-bids fetch"); + assert!(combined.contains("sendBeacon"), "should fire nurl and burl via sendBeacon"); + assert!(combined.contains("nurl"), "should fire nurl on confirmed render"); + assert!(!combined.contains("/ts-bids"), "must NOT fetch /ts-bids — bids are inline on the page"); + assert!(!combined.contains("bidsPromise"), "must NOT use bidsPromise — bids are synchronous"); + assert!(!combined.contains("__ts_request_id"), "must NOT reference request_id — no longer used"); } ``` Run: `cargo test -p trusted-server-core integrations::gpt` - Expected: FAIL — `__tsAdInit` not defined / assertion on `/ts-bids` string fails if old version present + Expected: FAIL - [ ] **Step 2: Replace `head_inserts()` in gpt.rs** @@ -1771,42 +1434,39 @@ The `/ts-bids` endpoint is the client's fetch target for bid results. It long-po "" .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. + // __tsAdInit: reads window.__ts_bids synchronously (injected before ). + // No fetch, no Promise. Executes post-DCL when has already arrived. + // Both nurl and burl fire client-side from slotRenderEnded — never server-side. + // Note: window.__tsjs_installGptShim above is an EXISTING function in the + // tsjs-core bundle that stubs googletag.cmd before the real GPT loads. concat!( "" @@ -1825,18 +1485,18 @@ The `/ts-bids` endpoint is the client's fetch target for bid results. It long-po ```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" + git commit -m "Emit __tsAdInit with synchronous window.__ts_bids read; nurl+burl from slotRenderEnded" ``` --- -## Task 12: `gpt/index.ts` — TypeScript `__tsAdInit` with `/ts-bids` fetch +## Task 10: `gpt/index.ts` — TypeScript `__tsAdInit` with slim-Prebid lazy loader **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. +The TypeScript version mirrors the Rust inline string from Task 9 and adds the lazy slim-Prebid loader. Slim-Prebid loads post-`window.load` and handles two things: refresh auctions (via existing GPT refresh triggers) and userID module warm-up to enrich the EC graph for the next request. - [ ] **Step 1: Write failing tests** @@ -1848,16 +1508,16 @@ The TypeScript version mirrors the Rust inline string from Task 11. It uses the describe('installTsAdInit', () => { beforeEach(() => { delete (window as any).__ts_ad_slots - delete (window as any).__ts_request_id + delete (window as any).__ts_bids delete (window as any).__tsAdInit }) - it('fetches /ts-bids with request_id and applies bid targeting before refresh', async () => { + it('reads window.__ts_bids synchronously and applies bid targeting before refresh', async () => { const mockSlot = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), getSlotElementId: vi.fn().mockReturnValue('atf'), - getTargeting: vi.fn().mockReturnValue([]), + getTargeting: vi.fn().mockReturnValue(['abc']), } const mockPubads = { enableSingleRequest: vi.fn(), @@ -1879,71 +1539,94 @@ The TypeScript version mirrors the Rust inline string from Task 11. It uses the 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) + ;(window as any).__ts_bids = { + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, + } + + const fetchSpy = vi.spyOn(global, 'fetch') const { installTsAdInit } = await import('./index') installTsAdInit() - await (window as any).__tsAdInit() + ;(window as any).__tsAdInit() - expect(fetchSpy).toHaveBeenCalledWith( - expect.stringContaining('/ts-bids?rid=test-rid-123'), - expect.objectContaining({ credentials: 'omit' }) - ) + expect(fetchSpy).not.toHaveBeenCalled() expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00') expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo') + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1') expect(mockPubads.refresh).toHaveBeenCalled() fetchSpy.mockRestore() }) - it('calls refresh with empty bids when fetch fails', async () => { + it('fires both nurl and burl via sendBeacon on slotRenderEnded when our bid won', async () => { + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true) + let capturedListener: ((e: 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(), - addEventListener: 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({ - addService: vi.fn().mockReturnThis(), - setTargeting: vi.fn().mockReturnThis(), - }), + defineSlot: vi.fn().mockReturnValue(mockSlot), 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')) + ;(window as any).__ts_ad_slots = [ + { + id: 'atf', + gam_unit_path: '/123/atf', + div_id: 'atf', + formats: [[300, 250]], + targeting: {}, + }, + ] + ;(window as any).__ts_bids = { + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, + } const { installTsAdInit } = await import('./index') installTsAdInit() - await (window as any).__tsAdInit() + ;(window as any).__tsAdInit() - expect(mockPubads.refresh).toHaveBeenCalled() + expect(capturedListener).toBeDefined() + capturedListener!({ isEmpty: false, slot: mockSlot }) + + expect(beaconSpy).toHaveBeenCalledWith('https://ssp/win') + expect(beaconSpy).toHaveBeenCalledWith('https://ssp/bill') + beaconSpy.mockRestore() }) - it('fires burl via sendBeacon on slotRenderEnded when our bid won', async () => { + it('does not fire nurl/burl when bid did not win GAM line item', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true) let capturedListener: ((e: any) => void) | undefined - const mockSlot = { + const mockSlotNoMatch = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), getSlotElementId: vi.fn().mockReturnValue('atf'), - getTargeting: vi.fn().mockReturnValue(['abc']), + getTargeting: vi.fn().mockReturnValue(['OTHER_BID_ID']), } const mockPubads = { enableSingleRequest: vi.fn(), @@ -1954,7 +1637,7 @@ The TypeScript version mirrors the Rust inline string from Task 11. It uses the } ;(window as any).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, - defineSlot: vi.fn().mockReturnValue(mockSlot), + defineSlot: vi.fn().mockReturnValue(mockSlotNoMatch), pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), } @@ -1967,43 +1650,58 @@ The TypeScript version mirrors the Rust inline string from Task 11. It uses the 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) + ;(window as any).__ts_bids = { + atf: { + hb_pb: '1.00', + hb_bidder: 'kargo', + hb_adid: 'abc', + nurl: 'https://ssp/win', + burl: 'https://ssp/bill', + }, + } const { installTsAdInit } = await import('./index') installTsAdInit() - await (window as any).__tsAdInit() + ;(window as any).__tsAdInit() + capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }) - // Trigger slotRenderEnded — slot has our winning hb_adid - expect(capturedListener).toBeDefined() - capturedListener!({ - isEmpty: false, - slot: mockSlot, - }) - - expect(beaconSpy).toHaveBeenCalledWith('https://ssp/bill') + expect(beaconSpy).not.toHaveBeenCalled() beaconSpy.mockRestore() }) + + it('calls refresh even when __ts_bids is empty (graceful fallback)', () => { + 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_bids = {} + + const { installTsAdInit } = require('./index') + installTsAdInit() + ;(window as any).__tsAdInit() + + expect(mockPubads.refresh).toHaveBeenCalled() + }) }) ``` Run: `cd crates/js/lib && npx vitest run` - Expected: FAIL — `installTsAdInit` not exported or fetches wrong endpoint + Expected: FAIL — `installTsAdInit` not defined or assertions fail -- [ ] **Step 2: Add `installTsAdInit` to `index.ts`** +- [ ] **Step 2: Implement `installTsAdInit` in `index.ts`** - Add to `crates/js/lib/src/integrations/gpt/index.ts`: + Replace the old `/ts-bids` fetch implementation with: ```typescript interface TsAdSlot { @@ -2018,38 +1716,30 @@ The TypeScript version mirrors the Rust inline string from Task 11. It uses the hb_pb?: string hb_bidder?: string hb_adid?: string + nurl?: string burl?: string } type TsWindow = Window & { __ts_ad_slots?: TsAdSlot[] - __ts_request_id?: string + __ts_bids?: Record __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. + * Reads `window.__ts_ad_slots` (injected at head-open) and `window.__ts_bids` + * (injected before ) synchronously — no fetch, no Promise. Applies bid + * targeting to GPT slots, sets the `ts_initial` sentinel, registers + * `slotRenderEnded` to fire both nurl and burl via sendBeacon when our + * specific Prebid bid wins the GAM line item match, then calls refresh(). */ 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 bids = w.__ts_bids ?? {} const g = (window as GptWindow).googletag if (!g) return @@ -2066,6 +1756,11 @@ The TypeScript version mirrors the Rust inline string from Task 11. It uses the Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v) ) + const bid = bids[slot.id] ?? {} + ;(['hb_pb', 'hb_bidder', 'hb_adid'] as const).forEach((key) => { + if (bid[key]) gptSlot.setTargeting(key, bid[key]!) + }) + gptSlot.setTargeting('ts_initial', '1') return { id: slot.id, gptSlot } }) .filter(Boolean) as Array<{ @@ -2076,153 +1771,86 @@ The TypeScript version mirrors the Rust inline string from Task 11. It uses the 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() + g.pubads().addEventListener?.('slotRenderEnded', (event: any) => { + const slotId: string = event.slot?.getSlotElementId?.() ?? '' + const bid = bids[slotId] ?? {} + const ourBidWon = + !event.isEmpty && + bid.hb_adid && + event.slot?.getTargeting?.('hb_adid')?.[0] === bid.hb_adid + if (ourBidWon) { + if (bid.nurl) navigator.sendBeacon(bid.nurl) + if (bid.burl) navigator.sendBeacon(bid.burl) + } }) + + g.pubads().refresh() }) } } ``` - Call `installTsAdInit()` from the integration's initialization path. +- [ ] **Step 3: Add lazy slim-Prebid loader (post-`window.load`)** -- [ ] **Step 3: Run JS tests** + After `installTsAdInit`, add: - 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"); + ```typescript + /** + * Register the slim-Prebid lazy loader. Fires after window.load — off the + * critical path. slim-Prebid handles refresh auctions and userID module + * warm-up (ID5, sharedID, LiveRamp ATS, Lockr). It skips initial-render slots + * (ts_initial=1) and registers as the GPT refresh handler for scroll/sticky auctions. + * + * Phase 1: no-op unless window.__tsjs_slim_prebid_url is set (it won't be until + * the slim-Prebid bundle build target ships in a later phase). + */ + export function installSlimPrebidLoader(): void { + const url = (window as any).__tsjs_slim_prebid_url as string | undefined + if (!url) return + window.addEventListener('load', () => { + const script = document.createElement('script') + script.src = url + script.defer = true + document.head.appendChild(script) + }) } ``` - Run: `cargo test -p trusted-server-core integrations::prebid` - Expected: FAIL - -- [ ] **Step 2: Add `fire_nurl_at_edge` to `PrebidConfig`** + Call `installTsAdInit()` from the integration's existing initialization path — wherever the module's init function runs at page load (look for the existing `init()` or module-level call that sets up the GPT integration). Add: - ```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); - } + ```typescript + // In the integration's init / module entry point: + installTsAdInit() ``` - 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); + `window.__tsAdInit()` itself is called by `__tsAdInit` being invoked from the `"); @@ -2289,7 +1914,7 @@ Tests use `pub(crate)` helpers from Task 9 directly. } #[test] - fn bid_map_uses_price_bucket_and_ad_id() { + fn bid_map_includes_nurl_and_burl() { let mut winning_bids = HashMap::new(); winning_bids.insert("atf_sidebar_ad".to_string(), Bid { slot_id: "atf_sidebar_ad".to_string(), @@ -2298,46 +1923,60 @@ Tests use `pub(crate)` helpers from Task 9 directly. creative: None, adomain: None, bidder: "kargo".to_string(), - width: 300, height: 250, + width: 300, + height: 250, + nurl: Some("https://ssp/win".to_string()), + burl: Some("https://ssp/bill".to_string()), + ad_id: Some("abc123".to_string()), + metadata: Default::default(), + }); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); + assert_eq!(entry.get("hb_pb").and_then(|v| v.as_str()), Some("2.50")); + assert_eq!(entry.get("hb_bidder").and_then(|v| v.as_str()), Some("kargo")); + assert_eq!(entry.get("hb_adid").and_then(|v| v.as_str()), Some("abc123")); + assert_eq!(entry.get("nurl").and_then(|v| v.as_str()), Some("https://ssp/win")); + assert_eq!(entry.get("burl").and_then(|v| v.as_str()), Some("https://ssp/bill")); + } + + #[test] + fn bid_map_excludes_slot_when_price_is_none() { + let mut winning_bids = HashMap::new(); + winning_bids.insert("no-price-slot".to_string(), Bid { + slot_id: "no-price-slot".to_string(), + price: None, + 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(), + burl: None, + ad_id: None, + metadata: Default::default(), }); - 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" - ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + assert!(map.is_empty(), "slot with no price should be excluded from bid map"); } #[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 >"); + fn bids_script_is_xss_safe() { + let mut map = serde_json::Map::new(); + map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + let script = build_bids_script(&map); + let inner = script + .trim_start_matches(""); + assert!(!inner.contains('<'), "no unescaped < in bids script"); + assert!(!inner.contains('>'), "no unescaped > in bids script"); } #[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"); + fn html_escape_encodes_special_chars() { + assert_eq!(html_escape_for_script("`. + /// Injected at `` open. `None` when no slots matched. + pub ad_slots_script: Option, + /// Shared auction result — written by auction task before HTML processing begins. + /// Handler reads this in `el.on_end_tag()` on the body element. + /// `None` means no auction ran; inject empty `__ts_bids = {}` as fallback. + pub ad_bids_state: std::sync::Arc>>, } impl HtmlProcessorConfig { @@ -151,6 +158,8 @@ impl HtmlProcessorConfig { request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), integrations: integrations.clone(), + ad_slots_script: None, + ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)), } } } @@ -230,6 +239,8 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let injected_tsjs = Rc::new(Cell::new(false)); let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); + let ad_slots_script = config.ad_slots_script.clone(); + let ad_bids_state = config.ad_bids_state.clone(); let mut element_content_handlers = vec![ // Inject unified tsjs bundle once at the start of @@ -238,9 +249,14 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let integrations = integration_registry.clone(); let patterns = patterns.clone(); let document_state = document_state.clone(); + let ad_slots_script = ad_slots_script.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); + // Inject ad slots script first so it appears before tsjs bundle. + if let Some(ref slots_script) = ad_slots_script { + snippet.push_str(slots_script); + } let ctx = IntegrationHtmlContext { request_host: &patterns.request_host, request_scheme: &patterns.request_scheme, @@ -265,6 +281,30 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso Ok(()) } }), + // Inject __ts_bids before via end_tag_handlers. + element!("body", { + let state = ad_bids_state.clone(); + move |el| { + let state = state.clone(); + if let Some(handlers) = el.end_tag_handlers() { + let handler: EndTagHandler<'static> = + Box::new(move |end_tag: &mut EndTag<'_>| { + let script_guard = state.read().expect("should read bid state"); + let bids_script = match &*script_guard { + Some(s) => s.clone(), + None => { + r#""# + .to_string() + } + }; + end_tag.before(&bids_script, ContentType::Html); + Ok(()) + }); + handlers.push(handler); + } + Ok(()) + } + }), // Replace URLs in href attributes element!("[href]", { let patterns = patterns.clone(); @@ -540,6 +580,8 @@ mod tests { request_host: "test.example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), + ad_slots_script: None, + ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)), } } @@ -1185,4 +1227,85 @@ mod tests { "should contain post-processor mutation" ); } + + #[test] + fn injects_ad_slots_at_head_open() { + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: Some( + r#""#.to_string(), + ), + ad_bids_state: std::sync::Arc::new(std::sync::RwLock::new(None)), + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk( + b"Tcontent", + 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("__ts_request_id"), + "must NOT inject request_id" + ); + } + + #[test] + fn injects_ts_bids_before_body_close() { + let bids_script = + r#""#; + let state = std::sync::Arc::new(std::sync::RwLock::new(Some(bids_script.to_string()))); + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: None, + ad_bids_state: state, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!( + html.contains("window.__ts_bids"), + "should inject bids before " + ); + let bids_pos = html + .find("window.__ts_bids") + .expect("bids should be in output"); + let body_close_pos = html.find("").expect(" should be in output"); + assert!(bids_pos < body_close_pos, "bids must appear before "); + } + + #[test] + fn injects_empty_ts_bids_when_state_is_none() { + let state = std::sync::Arc::new(std::sync::RwLock::new(None)); + 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_state: state, + }; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert!( + html.contains("__ts_bids=JSON.parse(\"{}\")"), + "should inject empty bids on None state" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 8b55493be..ffad78921 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -853,6 +853,14 @@ impl IntegrationRegistry { .collect() } + #[cfg(test)] + #[must_use] + pub fn empty_for_tests() -> Self { + Self { + inner: Arc::new(IntegrationRegistryInner::default()), + } + } + #[cfg(test)] #[must_use] pub fn from_rewriters( From 8b9500cf7c5d83d4d7bf97910ddb414651ec704d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 5 May 2026 19:47:16 +0530 Subject: [PATCH 16/34] Convert handle_publisher_request to async; body-inject __ts_bids; eligibility gates; max-age=0 - Make handle_publisher_request async; add orchestrator and slots_file params - Dispatch origin request with send_async before running auction in parallel - Gate auction on GET, no prefetch, no bot, matched slots, TCF purpose-1 consent - Run server-side auction and write bucketed bids to ad_bids_state Arc - Compute ad_slots_script after response headers; set Cache-Control: private, max-age=0 - Fix Stream arm to thread actual ad_slots_script and ad_bids_state through - Add build_auction_request, build_bid_map, build_bids_script, build_ad_slots_script helpers - Update route_tests.rs to pass empty slots_file to route_request --- .../src/route_tests.rs | 6 + crates/trusted-server-core/src/publisher.rs | 249 +++++++++++++++++- 2 files changed, 243 insertions(+), 12 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 0fd0113f8..06336a9b1 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -184,6 +184,8 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); + let slots_file = + trusted_server_core::creative_opportunities::CreativeOpportunitiesFile::default(); let discovery_req = Request::get("https://test.com/.well-known/trusted-server.json"); let discovery_services = test_runtime_services(&discovery_req); @@ -192,6 +194,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &discovery_services, + &slots_file, discovery_req, )) .expect("should route discovery request"); @@ -208,6 +211,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &admin_services, + &slots_file, admin_req, )) .expect("should route admin request"); @@ -224,6 +228,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &auction_services, + &slots_file, auction_req, )) .expect("should return an error response for auction requests"); @@ -240,6 +245,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { &orchestrator, &integration_registry, &publisher_services, + &slots_file, publisher_req, )) .expect("should return an error response for publisher fallback"); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 5bcef6941..4037bdf8a 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -12,11 +12,14 @@ //! content-rewriting concern. use std::io::Write; +use std::sync::{Arc, RwLock}; use error_stack::{Report, ResultExt}; use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; +use crate::auction::orchestrator::AuctionOrchestrator; +use crate::auction::types::{AuctionContext, AuctionRequest, Bid, PublisherInfo, SiteInfo, UserInfo}; use crate::backend::BackendConfig; use crate::consent::{allows_ec_creation, build_consent_context, ConsentPipelineInput}; use crate::constants::{COOKIE_TS_EC, HEADER_X_COMPRESS_HINT, HEADER_X_TS_EC}; @@ -26,6 +29,7 @@ use crate::error::TrustedServerError; use crate::http_util::{serve_static_with_etag, RequestInfo}; use crate::integrations::IntegrationRegistry; use crate::platform::RuntimeServices; +use crate::price_bucket::price_bucket; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; @@ -182,6 +186,8 @@ struct ProcessResponseParams<'a> { settings: &'a Settings, content_type: &'a str, integration_registry: &'a IntegrationRegistry, + ad_slots_script: Option<&'a str>, + ad_bids_state: &'a Arc>>, } /// Process response body through the streaming pipeline. @@ -224,6 +230,8 @@ fn process_response_streaming( params.request_scheme, params.settings, params.integration_registry, + params.ad_slots_script.map(str::to_string), + params.ad_bids_state.clone(), )?; StreamingPipeline::new(config, processor).process(body, output)?; } else if is_rsc_flight { @@ -252,18 +260,21 @@ fn create_html_stream_processor( origin_host: &str, request_host: &str, request_scheme: &str, - settings: &Settings, + _settings: &Settings, integration_registry: &IntegrationRegistry, + ad_slots_script: Option, + ad_bids_state: Arc>>, ) -> Result> { use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; - let config = HtmlProcessorConfig::from_settings( - settings, - integration_registry, - origin_host, - request_host, - request_scheme, - ); + let config = HtmlProcessorConfig { + origin_host: origin_host.to_string(), + request_host: request_host.to_string(), + request_scheme: request_scheme.to_string(), + integrations: integration_registry.clone(), + ad_slots_script, + ad_bids_state, + }; Ok(create_html_processor(config)) } @@ -392,6 +403,8 @@ pub struct OwnedProcessResponseParams { pub(crate) request_host: String, pub(crate) request_scheme: String, pub(crate) content_type: String, + pub(crate) ad_slots_script: Option, + pub(crate) ad_bids_state: Arc>>, } /// Stream the publisher response body through the processing pipeline. @@ -420,6 +433,8 @@ pub fn stream_publisher_body( settings, content_type: ¶ms.content_type, integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), + ad_bids_state: ¶ms.ad_bids_state, }; process_response_streaming(body, output, &borrowed) } @@ -441,10 +456,12 @@ pub fn stream_publisher_body( /// /// Returns a [`TrustedServerError`] if the proxy request fails or the /// origin backend is unreachable. -pub fn handle_publisher_request( +pub async fn handle_publisher_request( settings: &Settings, integration_registry: &IntegrationRegistry, services: &RuntimeServices, + orchestrator: &AuctionOrchestrator, + slots_file: &crate::creative_opportunities::CreativeOpportunitiesFile, mut req: Request, ) -> Result> { log::debug!("Proxying request to publisher_origin"); @@ -520,14 +537,105 @@ pub fn handle_publisher_request( backend_name, settings.publisher.origin_url ); + + let request_path = req.get_path().to_string(); + let is_get = req.get_method() == fastly::http::Method::GET; + + let is_prefetch = req.get_header_str("sec-purpose") + .map_or(false, |v| v.contains("prefetch")) + || req.get_header_str("purpose") + .map_or(false, |v| v.contains("prefetch")); + + let user_agent = req.get_header_str("user-agent").unwrap_or(""); + let is_bot = ["Googlebot", "Bingbot", "AhrefsBot", "SemrushBot", "DotBot"] + .iter() + .any(|bot| user_agent.contains(bot)); + + let matched_slots: Vec<_> = if settings.creative_opportunities.is_some() && is_get { + crate::creative_opportunities::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 = is_get + && !is_prefetch + && !is_bot + && !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); + + let ad_bids_state: Arc>> = Arc::new(RwLock::new(None)); + // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); req.set_header("host", &origin_host); - let mut response = req - .send(&backend_name) + let pending_origin = req + .send_async(&backend_name) .change_context(TrustedServerError::Proxy { - message: "Failed to proxy request to origin".to_string(), + message: "Failed to dispatch async origin request".to_string(), + })?; + + 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::get("https://placeholder.invalid/"); + let auction_context = AuctionContext { + settings, + request: &placeholder_req, + client_info: services.client_info(), + timeout_ms: auction_timeout_ms, + provider_responses: None, + services, + }; + 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 + }; + + if should_run_auction { + let co_config = settings.creative_opportunities.as_ref() + .expect("should be present"); + let empty: std::collections::HashMap = + std::collections::HashMap::new(); + let winning_bids = auction_result.as_ref() + .map(|r| &r.winning_bids) + .unwrap_or(&empty); + let bid_map = build_bid_map(winning_bids, co_config.price_granularity); + let bids_script = build_bids_script(&bid_map); + *ad_bids_state.write().expect("should write bid state") = Some(bids_script); + } + + let mut response = pending_origin + .wait() + .change_context(TrustedServerError::Proxy { + message: "Failed to await origin response".to_string(), })?; log::debug!("Response headers:"); @@ -535,6 +643,22 @@ pub fn handle_publisher_request( log::debug!(" {}: {:?}", name, value); } + let ad_slots_script = if let Some(co_config) = &settings.creative_opportunities { + if !matched_slots.is_empty() { + Some(build_ad_slots_script(&matched_slots, co_config)) + } else { + None + } + } else { + None + }; + + if ad_slots_script.is_some() { + response.set_header(header::CACHE_CONTROL, "private, max-age=0"); + response.remove_header("surrogate-control"); + response.remove_header("fastly-surrogate-control"); + } + // Set EC ID / cookie headers BEFORE body processing. // These are body-independent (computed from request cookies + consent). apply_ec_headers( @@ -623,6 +747,8 @@ pub fn handle_publisher_request( request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), content_type, + ad_slots_script: ad_slots_script.clone(), + ad_bids_state: ad_bids_state.clone(), }, }) } @@ -642,6 +768,8 @@ pub fn handle_publisher_request( settings, content_type: &content_type, integration_registry, + ad_slots_script: ad_slots_script.as_deref(), + ad_bids_state: &ad_bids_state, }; let mut output = Vec::new(); process_response_streaming(body, &mut output, ¶ms)?; @@ -654,6 +782,93 @@ pub fn handle_publisher_request( } } +/// Build an [`AuctionRequest`] from matched creative opportunity slots. +pub(crate) fn build_auction_request( + matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + ec_id: &str, + consent_context: &crate::consent::ConsentContext, + request_info: &crate::http_util::RequestInfo, + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, +) -> AuctionRequest { + let slots = matched_slots + .iter() + .map(|s| s.to_ad_slot(&co_config.gam_network_id)) + .collect(); + AuctionRequest { + id: format!("ts-{}", ec_id), + slots, + publisher: PublisherInfo { + domain: request_info.host.clone(), + page_url: None, + }, + user: UserInfo { + id: ec_id.to_string(), + fresh_id: ec_id.to_string(), + consent: Some(consent_context.clone()), + }, + device: None, + site: Some(SiteInfo { + domain: request_info.host.clone(), + page: String::new(), + }), + context: std::collections::HashMap::new(), + } +} + +/// Build a price-bucketed bid map from winning bids. +/// +/// Returns a map of slot ID → bucketed CPM string. +pub(crate) fn build_bid_map( + winning_bids: &std::collections::HashMap, + granularity: crate::price_bucket::PriceGranularity, +) -> std::collections::HashMap { + winning_bids + .iter() + .filter_map(|(slot_id, bid)| { + bid.price.map(|cpm| { + let bucket = price_bucket(cpm, granularity); + (slot_id.clone(), bucket) + }) + }) + .collect() +} + +/// Build the `__ts_bids` inline script content from a bucketed bid map. +pub(crate) fn build_bids_script(bid_map: &std::collections::HashMap) -> String { + let entries: Vec = bid_map + .iter() + .map(|(slot_id, bucket)| format!("\"{}\":\"{}\"", slot_id, bucket)) + .collect(); + format!("window.__ts_bids={{{}}};", entries.join(",")) +} + +/// Build the `__ts_ad_slots` inline script content from matched slots. +pub(crate) fn build_ad_slots_script( + matched_slots: &[crate::creative_opportunities::CreativeOpportunitySlot], + co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, +) -> String { + let entries: Vec = matched_slots + .iter() + .map(|slot| { + let gam_path = slot.resolved_gam_unit_path(&co_config.gam_network_id); + let div_id = slot.resolved_div_id(); + let formats: Vec = slot + .formats + .iter() + .map(|f| format!("[{},{}]", f.width, f.height)) + .collect(); + format!( + "{{\"id\":\"{}\",\"div\":\"{}\",\"path\":\"{}\",\"sizes\":[{}]}}", + slot.id, + div_id, + gam_path, + formats.join(",") + ) + }) + .collect(); + format!("window.__ts_ad_slots=[{}];", entries.join(",")) +} + /// Whether the content type requires processing (URL rewriting, HTML injection). /// /// Text-based and JavaScript/JSON responses are processable; binary types @@ -1366,6 +1581,8 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(RwLock::new(None)), }; let mut output = Vec::new(); @@ -1407,6 +1624,8 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(RwLock::new(None)), }; let mut output = Vec::new(); @@ -1439,6 +1658,8 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(RwLock::new(None)), }; let bogus_body = Body::from(b"not gzip".to_vec()); @@ -1538,6 +1759,8 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(RwLock::new(None)), }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -1588,6 +1811,8 @@ mod tests { request_host: "proxy.example.com".to_string(), request_scheme: "https".to_string(), content_type: "text/html".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(RwLock::new(None)), }; let mut output = Vec::new(); From 9cdbb36fd7bb62212258a433c2764f07eb8f7e54 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 5 May 2026 20:05:59 +0530 Subject: [PATCH 17/34] Emit __tsAdInit with synchronous window.__ts_bids read; nurl+burl from slotRenderEnded --- .../src/integrations/gpt.rs | 89 +++++++++++++++++-- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 40bcf7f2c..796d633e1 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -438,13 +438,42 @@ impl IntegrationHeadInjector for GptIntegration { } fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - // Set the enable flag and best-effort call the activation function - // registered by the GPT shim module. The bundle also auto-installs - // when it sees the pre-set flag, so this works regardless of whether - // the inline bootstrap runs before or after the TSJS bundle. vec![ - "" + "" .to_string(), + concat!( + "" + ).to_string(), ] } } @@ -1020,7 +1049,7 @@ mod tests { let inserts = integration.head_inserts(&ctx); - assert_eq!(inserts.len(), 1, "should emit exactly one head insert"); + assert_eq!(inserts.len(), 2, "should emit exactly two head inserts"); assert_eq!( inserts[0], "", @@ -1028,6 +1057,54 @@ mod tests { ); } + #[test] + fn head_inserts_includes_ts_ad_init_with_synchronous_bids_read() { + let config = test_config(); + let integration = GptIntegration::new(config); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let inserts = integration.head_inserts(&ctx); + let combined = inserts.join(""); + assert!(combined.contains("__tsAdInit"), "should define __tsAdInit"); + assert!( + combined.contains("window.__ts_bids"), + "should read window.__ts_bids synchronously" + ); + assert!( + combined.contains("ts_initial"), + "should set ts_initial sentinel" + ); + assert!( + combined.contains("slotRenderEnded"), + "should register slotRenderEnded" + ); + assert!( + combined.contains("sendBeacon"), + "should fire nurl and burl via sendBeacon" + ); + assert!( + combined.contains("nurl"), + "should fire nurl on confirmed render" + ); + assert!( + !combined.contains("/ts-bids"), + "must NOT fetch /ts-bids — bids are inline on the page" + ); + assert!( + !combined.contains("bidsPromise"), + "must NOT use bidsPromise — bids are synchronous" + ); + assert!( + !combined.contains("__ts_request_id"), + "must NOT reference request_id — no longer used" + ); + } + #[test] fn head_injector_integration_id() { let integration = GptIntegration::new(test_config()); From 6b624e3c9262cdc06b163eec4b7e18d024acdb3e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 09:32:32 +0530 Subject: [PATCH 18/34] Fix bid map shape and ad slots property names; resolve clippy errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_bid_map now returns serde_json::Map with full bid objects (hb_pb, hb_bidder, hb_adid, nurl, burl) instead of a plain CPM string map - build_bids_script / build_ad_slots_script now emit full "# - .to_string() - } + None => r#""# + .to_string(), }; end_tag.before(&bids_script, ContentType::Html); Ok(()) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4037bdf8a..c614e5ed4 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -19,7 +19,9 @@ use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; use crate::auction::orchestrator::AuctionOrchestrator; -use crate::auction::types::{AuctionContext, AuctionRequest, Bid, PublisherInfo, SiteInfo, UserInfo}; +use crate::auction::types::{ + AuctionContext, AuctionRequest, Bid, PublisherInfo, SiteInfo, UserInfo, +}; use crate::backend::BackendConfig; use crate::consent::{allows_ec_creation, build_consent_context, ConsentPipelineInput}; use crate::constants::{COOKIE_TS_EC, HEADER_X_COMPRESS_HINT, HEADER_X_TS_EC}; @@ -456,6 +458,12 @@ pub fn stream_publisher_body( /// /// Returns a [`TrustedServerError`] if the proxy request fails or the /// origin backend is unreachable. +/// +/// # Panics +/// +/// Panics if `should_run_auction` is `true` but `settings.creative_opportunities` is `None`. +/// This is a logic invariant: `should_run_auction` is only set when creative opportunities +/// are configured, so this state is unreachable in practice. pub async fn handle_publisher_request( settings: &Settings, integration_registry: &IntegrationRegistry, @@ -541,10 +549,12 @@ pub async fn handle_publisher_request( let request_path = req.get_path().to_string(); let is_get = req.get_method() == fastly::http::Method::GET; - let is_prefetch = req.get_header_str("sec-purpose") - .map_or(false, |v| v.contains("prefetch")) - || req.get_header_str("purpose") - .map_or(false, |v| v.contains("prefetch")); + let is_prefetch = req + .get_header_str("sec-purpose") + .is_some_and(|v| v.contains("prefetch")) + || req + .get_header_str("purpose") + .is_some_and(|v| v.contains("prefetch")); let user_agent = req.get_header_str("user-agent").unwrap_or(""); let is_bot = ["Googlebot", "Bingbot", "AhrefsBot", "SemrushBot", "DotBot"] @@ -563,13 +573,10 @@ pub async fn handle_publisher_request( let consent_allows_auction = consent_context .tcf .as_ref() - .map_or(false, |tcf| tcf.has_purpose_consent(1)); + .is_some_and(|tcf| tcf.has_purpose_consent(1)); - let should_run_auction = is_get - && !is_prefetch - && !is_bot - && !matched_slots.is_empty() - && consent_allows_auction; + let should_run_auction = + is_get && !is_prefetch && !is_bot && !matched_slots.is_empty() && consent_allows_auction; let auction_timeout_ms = settings .creative_opportunities @@ -583,14 +590,16 @@ pub async fn handle_publisher_request( restrict_accept_encoding(&mut req); req.set_header("host", &origin_host); - let pending_origin = req - .send_async(&backend_name) - .change_context(TrustedServerError::Proxy { - message: "Failed to dispatch async origin request".to_string(), - })?; + let pending_origin = + req.send_async(&backend_name) + .change_context(TrustedServerError::Proxy { + message: "Failed to dispatch async origin request".to_string(), + })?; let auction_result = if should_run_auction { - let co_config = settings.creative_opportunities.as_ref() + 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, @@ -608,7 +617,10 @@ pub async fn handle_publisher_request( provider_responses: None, services, }; - match orchestrator.run_auction(&auction_request, &auction_context, services).await { + 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:?}"); @@ -620,11 +632,13 @@ pub async fn handle_publisher_request( }; if should_run_auction { - let co_config = settings.creative_opportunities.as_ref() + let co_config = settings + .creative_opportunities + .as_ref() .expect("should be present"); - let empty: std::collections::HashMap = - std::collections::HashMap::new(); - let winning_bids = auction_result.as_ref() + let empty: std::collections::HashMap = std::collections::HashMap::new(); + let winning_bids = auction_result + .as_ref() .map(|r| &r.winning_bids) .unwrap_or(&empty); let bid_map = build_bid_map(winning_bids, co_config.price_granularity); @@ -815,58 +829,103 @@ pub(crate) fn build_auction_request( } } +/// Escape a JSON string so it is safe to embed inside a JS double-quoted string literal. +/// +/// Backslashes are doubled first (so they survive the next pass), then +/// double-quotes are escaped so they do not terminate the JS string. +/// The result is always valid to write as `JSON.parse("…")`. +fn html_escape_for_script(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + /// Build a price-bucketed bid map from winning bids. /// -/// Returns a map of slot ID → bucketed CPM string. +/// Returns a JSON object map of slot ID → bid metadata including the bucketed +/// CPM (`hb_pb`), bidder (`hb_bidder`), and optional ad ID, nurl, and burl. pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, -) -> std::collections::HashMap { +) -> serde_json::Map { winning_bids .iter() .filter_map(|(slot_id, bid)| { bid.price.map(|cpm| { let bucket = price_bucket(cpm, granularity); - (slot_id.clone(), bucket) + let mut obj = serde_json::Map::new(); + obj.insert("hb_pb".to_string(), serde_json::Value::String(bucket)); + obj.insert( + "hb_bidder".to_string(), + serde_json::Value::String(bid.bidder.clone()), + ); + if let Some(ref ad_id) = bid.ad_id { + obj.insert( + "hb_adid".to_string(), + serde_json::Value::String(ad_id.clone()), + ); + } + if let Some(ref nurl) = bid.nurl { + obj.insert("nurl".to_string(), serde_json::Value::String(nurl.clone())); + } + if let Some(ref burl) = bid.burl { + obj.insert("burl".to_string(), serde_json::Value::String(burl.clone())); + } + (slot_id.clone(), serde_json::Value::Object(obj)) }) }) .collect() } -/// Build the `__ts_bids` inline script content from a bucketed bid map. -pub(crate) fn build_bids_script(bid_map: &std::collections::HashMap) -> String { - let entries: Vec = bid_map - .iter() - .map(|(slot_id, bucket)| format!("\"{}\":\"{}\"", slot_id, bucket)) - .collect(); - format!("window.__ts_bids={{{}}};", entries.join(",")) +/// Build the `__ts_bids` `` sequences inside the string. +pub(crate) fn build_bids_script(bid_map: &serde_json::Map) -> String { + let json = serde_json::to_string(bid_map).unwrap_or_else(|_| "{}".to_string()); + let escaped = html_escape_for_script(&json); + format!( + "", + escaped + ) } -/// Build the `__ts_ad_slots` inline script content from matched slots. +/// Build the `__ts_ad_slots` `", + escaped + ) } /// Whether the content type requires processing (URL rewriting, HTML injection). From c212ec544138791419b8faed992627101a7a60dc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 11:47:51 +0530 Subject: [PATCH 19/34] Wire slots_file and orchestrator into adapter; parse creative-opportunities.toml at startup --- Cargo.lock | 8 +++++ .../trusted-server-adapter-fastly/Cargo.toml | 1 + .../trusted-server-adapter-fastly/src/main.rs | 14 ++++++++- crates/trusted-server-core/build.rs | 11 +++---- .../src/creative_opportunities.rs | 30 +++++++++++++------ crates/trusted-server-core/src/lib.rs | 2 +- crates/trusted-server-core/src/settings.rs | 4 ++- 7 files changed, 53 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e06ac75e7..65d1d777c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1151,6 +1151,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "group" version = "0.13.0" @@ -2707,6 +2713,7 @@ dependencies = [ "log-fastly", "serde", "serde_json", + "toml 1.0.7+spec-1.1.0", "trusted-server-core", "urlencoding", ] @@ -2731,6 +2738,7 @@ dependencies = [ "fastly", "flate2", "futures", + "glob", "hex", "hmac", "http", diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index e483ea621..a730efcd6 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -20,6 +20,7 @@ log = { workspace = true } log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } trusted-server-core = { workspace = true } urlencoding = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 52c869d7f..74414220b 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -39,6 +39,8 @@ use crate::error::to_error_response; use crate::logging::init_logger; use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; +const CREATIVE_OPPORTUNITIES_TOML: &str = include_str!("../../../creative-opportunities.toml"); + /// Entry point for the Fastly Compute program. /// /// Uses an undecorated `main()` with `Request::from_client()` instead of @@ -80,6 +82,10 @@ fn main() { } }; + let slots_file: trusted_server_core::creative_opportunities::CreativeOpportunitiesFile = + toml::from_str(CREATIVE_OPPORTUNITIES_TOML) + .expect("should parse creative-opportunities.toml"); + let integration_registry = match IntegrationRegistry::new(&settings) { Ok(r) => r, Err(e) => { @@ -103,6 +109,7 @@ fn main() { &orchestrator, &integration_registry, &runtime_services, + &slots_file, req, )) { response.send_to_client(); @@ -114,6 +121,7 @@ async fn route_request( orchestrator: &AuctionOrchestrator, integration_registry: &IntegrationRegistry, runtime_services: &RuntimeServices, + slots_file: &trusted_server_core::creative_opportunities::CreativeOpportunitiesFile, mut req: Request, ) -> Option { // Strip client-spoofable forwarded headers at the edge. @@ -221,8 +229,12 @@ async fn route_request( settings, integration_registry, &publisher_services, + orchestrator, + slots_file, req, - ) { + ) + .await + { Ok(PublisherResponse::Stream { mut response, body, diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 469c11048..b21cb6845 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -92,14 +92,15 @@ fn main() { let co_path = Path::new(CREATIVE_OPPORTUNITIES_PATH); if co_path.exists() { - let co_content = fs::read_to_string(co_path) - .expect("should read creative-opportunities.toml"); - let co_value: toml::Value = toml::from_str(&co_content) - .expect("creative-opportunities.toml: invalid TOML"); + let co_content = + fs::read_to_string(co_path).expect("should read creative-opportunities.toml"); + let co_value: toml::Value = + toml::from_str(&co_content).expect("creative-opportunities.toml: invalid TOML"); let slot_id_re = regex::Regex::new(r"^[A-Za-z0-9_\-]+$").expect("should compile regex"); if let Some(slots) = co_value.get("slot").and_then(|v| v.as_array()) { for slot in slots { - let id = slot.get("id") + let id = slot + .get("id") .and_then(|v| v.as_str()) .expect("creative-opportunities.toml: slot missing 'id' field"); if !slot_id_re.is_match(id) { diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 7bf3856c2..f051c340f 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -6,9 +6,10 @@ use std::collections::HashMap; -use glob::Pattern; use serde::{Deserialize, Serialize}; +use glob::Pattern; + use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::price_bucket::PriceGranularity; @@ -64,8 +65,9 @@ impl CreativeOpportunitySlot { /// Patterns that cannot be compiled even after normalisation are silently skipped. #[must_use] pub fn matches_path(&self, path: &str) -> bool { - self.page_patterns.iter().any(|pattern| { - match Pattern::new(pattern) { + self.page_patterns + .iter() + .any(|pattern| match Pattern::new(pattern) { Ok(p) => p.matches(path), Err(_) => { let normalised = pattern.replace("**", "*"); @@ -73,8 +75,7 @@ impl CreativeOpportunitySlot { .map(|p| p.matches(path)) .unwrap_or(false) } - } - }) + }) } /// Returns the GAM ad unit path for this slot. @@ -227,7 +228,10 @@ mod tests { #[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("/2024/01/my-article/"), + "should match article path" + ); assert!(!slot.matches_path("/"), "should not match root"); } @@ -243,14 +247,20 @@ mod tests { 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"); + assert!(!inner.contains('<'), "no unescaped < in script content"); + assert!(!inner.contains('>'), "no unescaped > in script content"); + } + + #[test] + fn bid_map_includes_nurl_and_burl() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "atf_sidebar_ad".to_string(), + make_bid( + "atf_sidebar_ad", + 1.50, + "kargo", + "abc123", + "https://ssp/win", + "https://ssp/bill", + ), + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + let entry = map.get("atf_sidebar_ad").expect("should have bid entry"); + let obj = entry.as_object().expect("should be object"); + assert_eq!( + obj.get("hb_pb").and_then(|v| v.as_str()), + Some("1.50"), + "should bucket price with dense granularity" + ); + assert_eq!( + obj.get("hb_bidder").and_then(|v| v.as_str()), + Some("kargo"), + "should include bidder" + ); + assert_eq!( + obj.get("hb_adid").and_then(|v| v.as_str()), + Some("abc123"), + "should include ad_id" + ); + assert_eq!( + obj.get("nurl").and_then(|v| v.as_str()), + Some("https://ssp/win"), + "should include nurl" + ); + assert_eq!( + obj.get("burl").and_then(|v| v.as_str()), + Some("https://ssp/bill"), + "should include burl" + ); + } + + #[test] + fn bid_map_excludes_slot_when_price_is_none() { + let mut winning_bids = HashMap::new(); + winning_bids.insert( + "no-price-slot".to_string(), + Bid { + slot_id: "no-price-slot".to_string(), + price: None, + currency: "USD".to_string(), + creative: None, + adomain: None, + bidder: "kargo".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + metadata: Default::default(), + }, + ); + let map = build_bid_map(&winning_bids, PriceGranularity::Dense); + assert!( + map.is_empty(), + "slot with no price should be excluded from bid map" + ); + } + + #[test] + fn bids_script_is_xss_safe() { + let mut map = serde_json::Map::new(); + map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); + let script = build_bids_script(&map); + let inner = script + .trim_start_matches(""); + assert!(!inner.contains('<'), "no unescaped < in bids script"); + assert!(!inner.contains('>'), "no unescaped > in bids script"); + } + + #[test] + fn html_escape_encodes_special_chars() { + assert_eq!( + html_escape_for_script("text\\with\\backslash"), + "text\\\\with\\\\backslash", + "should escape backslashes" + ); + assert_eq!( + html_escape_for_script("string\"with\"quotes"), + "string\\\"with\\\"quotes", + "should escape quotes" + ); + assert_eq!( + html_escape_for_script("simple"), + "simple", + "should not change simple text" + ); + assert_eq!( + html_escape_for_script("both\\\"mixed"), + "both\\\\\\\"mixed", + "should escape both backslashes and quotes" + ); + } + } } From b047add10a3f9138949b4ad19e783fca2e3b9a8d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 13:24:00 +0530 Subject: [PATCH 23/34] Enable server-side auction with APS provider and adserver_mock mediator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable APS and adserver_mock in auction config; set providers and mediator - Increase auction_timeout_ms from 500ms to 3000ms — 500ms was too tight for HTTPS round-trips to mocktioneer, leaving the mediator zero budget - Fix mediation request: send numeric price instead of opaque encoded_price; mocktioneer requires a decoded price field and does not support encoded_price - Expand creative-opportunities slot page_patterns to include /news/** --- .../src/integrations/adserver_mock.rs | 45 +++++++------------ creative-opportunities.toml | 2 +- trusted-server.toml | 12 ++--- 3 files changed, 22 insertions(+), 37 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 7ed2da595..8ec94a9c5 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -133,36 +133,21 @@ impl AdServerMockProvider { .bids .iter() .map(|bid| { - // Check if this is an APS bid with encoded price (inferred from amznbid in metadata) - let encoded_price = bid - .metadata - .get("amznbid") - .and_then(|v| v.as_str()) - .map(String::from); - - if encoded_price.is_some() { - // APS bid - send encoded price for mediation to decode - json!({ - "imp_id": bid.slot_id, - "encoded_price": encoded_price, - "adm": bid.creative, - "w": bid.width, - "h": bid.height, - "crid": format!("{}-creative", bid.bidder), - "adomain": bid.adomain, - }) - } else { - // Regular bid with decoded price - json!({ - "imp_id": bid.slot_id, - "price": bid.price, - "adm": bid.creative, - "w": bid.width, - "h": bid.height, - "crid": format!("{}-creative", bid.bidder), - "adomain": bid.adomain, - }) - } + // Mocktioneer mediator always requires a numeric `price` field. + // APS bids carry price as an opaque encoded string (`amznbid`) + // that cannot be decoded client-side; use `bid.price` when set + // (a real decoded value) or fall back to a mock floor price for + // test/demo purposes. + let price = bid.price.unwrap_or(1.50); + json!({ + "imp_id": bid.slot_id, + "price": price, + "adm": bid.creative, + "w": bid.width, + "h": bid.height, + "crid": format!("{}-creative", bid.bidder), + "adomain": bid.adomain, + }) }) .collect(); diff --git a/creative-opportunities.toml b/creative-opportunities.toml index b44e215b6..b79d23810 100644 --- a/creative-opportunities.toml +++ b/creative-opportunities.toml @@ -5,7 +5,7 @@ id = "atf_sidebar_ad" gam_unit_path = "/21765378893/publisher/atf-sidebar" div_id = "div-atf-sidebar" -page_patterns = ["/20**"] +page_patterns = ["/", "/20**", "/news/**"] formats = [{ width = 300, height = 250 }] floor_price = 0.50 diff --git a/trusted-server.toml b/trusted-server.toml index c2ecab335..8036b7ec4 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -161,16 +161,16 @@ rewrite_script = true [auction] enabled = true -providers = ["prebid"] -# mediator = "adserver_mock" # will use mediator when set +providers = ["prebid", "aps"] +mediator = "adserver_mock" timeout_ms = 2000 # Context keys the JS client is allowed to forward into auction requests. # Keys not in this list are silently dropped. An empty list blocks all keys. allowed_context_keys = ["permutive_segments"] [integrations.aps] -enabled = false -pub_id = "your-aps-publisher-id" +enabled = true +pub_id = "test-pub" endpoint = "https://origin-mocktioneer.cdintel.com/e/dtb/bid" timeout_ms = 1000 @@ -180,7 +180,7 @@ container_id = "GTM-XXXXXX" # upstream_url = "https://www.googletagmanager.com" [integrations.adserver_mock] -enabled = false +enabled = true endpoint = "https://origin-mocktioneer.cdintel.com/adserver/mediate" timeout_ms = 1000 @@ -192,6 +192,6 @@ permutive_segments = "permutive" [creative_opportunities] gam_network_id = "21765378893" -auction_timeout_ms = 500 +auction_timeout_ms = 3000 price_granularity = "dense" From 6a5df1060471818c8335178ce35ecd88978aa2ac Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 13:35:24 +0530 Subject: [PATCH 24/34] Fix adserver_mock test for numeric price; fix GPT JS formatting --- .../js/lib/src/integrations/gpt/index.test.ts | 150 +++++++++--------- crates/js/lib/src/integrations/gpt/index.ts | 14 +- .../src/integrations/adserver_mock.rs | 19 +-- 3 files changed, 91 insertions(+), 92 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.test.ts b/crates/js/lib/src/integrations/gpt/index.test.ts index 0a6993818..7e2783f2f 100644 --- a/crates/js/lib/src/integrations/gpt/index.test.ts +++ b/crates/js/lib/src/integrations/gpt/index.test.ts @@ -1,20 +1,20 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest'; describe('installTsAdInit', () => { beforeEach(() => { - vi.resetModules() - delete (window as any).__ts_ad_slots - delete (window as any).__ts_bids - delete (window as any).__tsAdInit + vi.resetModules(); + delete (window as any).__ts_ad_slots; + delete (window as any).__ts_bids; + delete (window as any).__tsAdInit; // jsdom does not implement navigator.sendBeacon; polyfill it for tests if (!('sendBeacon' in navigator)) { Object.defineProperty(navigator, 'sendBeacon', { value: vi.fn().mockReturnValue(true), writable: true, configurable: true, - }) + }); } - }) + }); it('reads window.__ts_bids synchronously and applies bid targeting before refresh', async () => { const mockSlot = { @@ -22,19 +22,19 @@ describe('installTsAdInit', () => { setTargeting: vi.fn().mockReturnThis(), getSlotElementId: vi.fn().mockReturnValue('atf'), getTargeting: vi.fn().mockReturnValue(['abc']), - } + }; const mockPubads = { enableSingleRequest: vi.fn(), addEventListener: vi.fn(), refresh: vi.fn(), - } - ;(window as any).googletag = { + }; + (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 = [ + }; + (window as any).__ts_ad_slots = [ { id: 'atf', gam_unit_path: '/123/atf', @@ -42,8 +42,8 @@ describe('installTsAdInit', () => { formats: [[300, 250]], targeting: { pos: 'atf' }, }, - ] - ;(window as any).__ts_bids = { + ]; + (window as any).__ts_bids = { atf: { hb_pb: '1.00', hb_bidder: 'kargo', @@ -51,47 +51,47 @@ describe('installTsAdInit', () => { nurl: 'https://ssp/win', burl: 'https://ssp/bill', }, - } + }; - const fetchSpy = vi.spyOn(global, 'fetch') + const fetchSpy = vi.spyOn(global, 'fetch'); - const { installTsAdInit } = await import('./index') - installTsAdInit() - ;(window as any).__tsAdInit() + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as any).__tsAdInit(); - expect(fetchSpy).not.toHaveBeenCalled() - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00') - expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo') - expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1') - expect(mockPubads.refresh).toHaveBeenCalled() + expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_bidder', 'kargo'); + expect(mockSlot.setTargeting).toHaveBeenCalledWith('ts_initial', '1'); + expect(mockPubads.refresh).toHaveBeenCalled(); - fetchSpy.mockRestore() - }) + fetchSpy.mockRestore(); + }); it('fires both nurl and burl via sendBeacon on slotRenderEnded when our bid won', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true) - let capturedListener: ((e: any) => void) | undefined + 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 + if (event === 'slotRenderEnded') capturedListener = fn; }), - } - ;(window as any).googletag = { + }; + (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 = [ + }; + (window as any).__ts_ad_slots = [ { id: 'atf', gam_unit_path: '/123/atf', @@ -99,8 +99,8 @@ describe('installTsAdInit', () => { formats: [[300, 250]], targeting: {}, }, - ] - ;(window as any).__ts_bids = { + ]; + (window as any).__ts_bids = { atf: { hb_pb: '1.00', hb_bidder: 'kargo', @@ -108,44 +108,44 @@ describe('installTsAdInit', () => { nurl: 'https://ssp/win', burl: 'https://ssp/bill', }, - } + }; - const { installTsAdInit } = await import('./index') - installTsAdInit() - ;(window as any).__tsAdInit() + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as any).__tsAdInit(); - expect(capturedListener).toBeDefined() - capturedListener!({ isEmpty: false, slot: mockSlot }) + expect(capturedListener).toBeDefined(); + capturedListener!({ isEmpty: false, slot: mockSlot }); - expect(beaconSpy).toHaveBeenCalledWith('https://ssp/win') - expect(beaconSpy).toHaveBeenCalledWith('https://ssp/bill') - beaconSpy.mockRestore() - }) + expect(beaconSpy).toHaveBeenCalledWith('https://ssp/win'); + expect(beaconSpy).toHaveBeenCalledWith('https://ssp/bill'); + beaconSpy.mockRestore(); + }); it('does not fire nurl/burl when bid did not win GAM line item', async () => { - const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true) - let capturedListener: ((e: any) => void) | undefined + const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + let capturedListener: ((e: any) => void) | undefined; const mockSlotNoMatch = { addService: vi.fn().mockReturnThis(), setTargeting: vi.fn().mockReturnThis(), getSlotElementId: vi.fn().mockReturnValue('atf'), getTargeting: vi.fn().mockReturnValue(['OTHER_BID_ID']), - } + }; const mockPubads = { enableSingleRequest: vi.fn(), refresh: vi.fn(), addEventListener: vi.fn((event: string, fn: (e: any) => void) => { - if (event === 'slotRenderEnded') capturedListener = fn + if (event === 'slotRenderEnded') capturedListener = fn; }), - } - ;(window as any).googletag = { + }; + (window as any).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, defineSlot: vi.fn().mockReturnValue(mockSlotNoMatch), pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), - } - ;(window as any).__ts_ad_slots = [ + }; + (window as any).__ts_ad_slots = [ { id: 'atf', gam_unit_path: '/123/atf', @@ -153,8 +153,8 @@ describe('installTsAdInit', () => { formats: [[300, 250]], targeting: {}, }, - ] - ;(window as any).__ts_bids = { + ]; + (window as any).__ts_bids = { atf: { hb_pb: '1.00', hb_bidder: 'kargo', @@ -162,24 +162,24 @@ describe('installTsAdInit', () => { nurl: 'https://ssp/win', burl: 'https://ssp/bill', }, - } + }; - const { installTsAdInit } = await import('./index') - installTsAdInit() - ;(window as any).__tsAdInit() - capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }) + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as any).__tsAdInit(); + capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }); - expect(beaconSpy).not.toHaveBeenCalled() - beaconSpy.mockRestore() - }) + expect(beaconSpy).not.toHaveBeenCalled(); + beaconSpy.mockRestore(); + }); it('calls refresh even when __ts_bids is empty (graceful fallback)', async () => { const mockPubads = { enableSingleRequest: vi.fn(), addEventListener: vi.fn(), refresh: vi.fn(), - } - ;(window as any).googletag = { + }; + (window as any).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, defineSlot: vi.fn().mockReturnValue({ addService: vi.fn().mockReturnThis(), @@ -187,14 +187,14 @@ describe('installTsAdInit', () => { }), pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), - } - ;(window as any).__ts_ad_slots = [] - ;(window as any).__ts_bids = {} + }; + (window as any).__ts_ad_slots = []; + (window as any).__ts_bids = {}; - const { installTsAdInit } = await import('./index') - installTsAdInit() - ;(window as any).__tsAdInit() + const { installTsAdInit } = await import('./index'); + installTsAdInit(); + (window as any).__tsAdInit(); - expect(mockPubads.refresh).toHaveBeenCalled() - }) -}) + expect(mockPubads.refresh).toHaveBeenCalled(); + }); +}); diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 1494d793f..95b6d4279 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -217,7 +217,11 @@ export function installTsAdInit(): void { g.cmd?.push(() => { slots .map((slot) => { - const gptSlot = g.defineSlot?.(slot.gam_unit_path, slot.formats as Array, slot.div_id); + const gptSlot = g.defineSlot?.( + slot.gam_unit_path, + slot.formats as Array, + slot.div_id + ); if (!gptSlot) return null; gptSlot.addService(g.pubads!()); Object.entries(slot.targeting ?? {}).forEach(([k, v]) => gptSlot.setTargeting(k, v)); @@ -280,13 +284,13 @@ export function installSlimPrebidLoader(): void { // regardless of script order, the module also checks for a pre-set enable flag // immediately after registering the function. if (typeof window !== 'undefined') { - const win = window as Record + const win = window as Record; - win.__tsjs_installGptShim = installGptShim + win.__tsjs_installGptShim = installGptShim; if (win.__tsjs_gpt_enabled === true) { - installGptShim() + installGptShim(); } - installTsAdInit() + installTsAdInit(); } diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 8ec94a9c5..3a42ec2a0 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -675,20 +675,15 @@ mod tests { let bid = &bidder_resp["bids"][0]; assert_eq!(bid["imp_id"], "slot-1"); - // Key assertions for APS-style encoded price bids: - // 1. Should NOT have "price" field (or it should be null) - assert!( - bid["price"].is_null(), - "APS bids should not have decoded price, got: {:?}", - bid["price"] - ); - // 2. Should have "encoded_price" field + // APS bids have no decoded price (bid.price == None), so the mock floor + // price (1.50) is used. Mocktioneer requires a numeric price field and + // does not accept an opaque encoded_price string. assert_eq!( - bid["encoded_price"].as_str(), - Some("encoded-price-value"), - "APS bids should have encoded_price from metadata" + bid["price"].as_f64(), + Some(1.50), + "APS bids with no decoded price should fall back to mock floor price 1.50" ); - // 3. adm should be null (not a string) + // adm should be null (not a string) assert!( bid["adm"].is_null(), "Creative-less bids should have null adm, got: {:?}", From e6c18ad5ec4de17713a840d2c44e0d2b532b5946 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 14:13:49 +0530 Subject: [PATCH 25/34] Replace explicit any in GPT integration with typed interfaces Define SlotRenderEndedEvent, SlotRenderEvent, and TestWindow types to eliminate all @typescript-eslint/no-explicit-any violations in gpt/index.ts and gpt/index.test.ts. Extend GptWindow with __tsjs_slim_prebid_url so installSlimPrebidLoader avoids the any cast. --- .../js/lib/src/integrations/gpt/index.test.ts | 61 ++++++++++++------- crates/js/lib/src/integrations/gpt/index.ts | 15 +++-- 2 files changed, 49 insertions(+), 27 deletions(-) diff --git a/crates/js/lib/src/integrations/gpt/index.test.ts b/crates/js/lib/src/integrations/gpt/index.test.ts index 7e2783f2f..e908a201e 100644 --- a/crates/js/lib/src/integrations/gpt/index.test.ts +++ b/crates/js/lib/src/integrations/gpt/index.test.ts @@ -1,11 +1,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +interface SlotRenderEvent { + isEmpty: boolean; + slot: { + getSlotElementId(): string; + getTargeting(key: string): string[]; + }; +} + +type TestWindow = Window & { + googletag?: unknown; + __ts_ad_slots?: unknown; + __ts_bids?: unknown; + __tsAdInit?: () => void; +}; + describe('installTsAdInit', () => { beforeEach(() => { vi.resetModules(); - delete (window as any).__ts_ad_slots; - delete (window as any).__ts_bids; - delete (window as any).__tsAdInit; + delete (window as TestWindow).__ts_ad_slots; + delete (window as TestWindow).__ts_bids; + delete (window as TestWindow).__tsAdInit; // jsdom does not implement navigator.sendBeacon; polyfill it for tests if (!('sendBeacon' in navigator)) { Object.defineProperty(navigator, 'sendBeacon', { @@ -28,13 +43,13 @@ describe('installTsAdInit', () => { addEventListener: vi.fn(), refresh: vi.fn(), }; - (window as any).googletag = { + (window as TestWindow).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, defineSlot: vi.fn().mockReturnValue(mockSlot), pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as any).__ts_ad_slots = [ + (window as TestWindow).__ts_ad_slots = [ { id: 'atf', gam_unit_path: '/123/atf', @@ -43,7 +58,7 @@ describe('installTsAdInit', () => { targeting: { pos: 'atf' }, }, ]; - (window as any).__ts_bids = { + (window as TestWindow).__ts_bids = { atf: { hb_pb: '1.00', hb_bidder: 'kargo', @@ -57,7 +72,7 @@ describe('installTsAdInit', () => { const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as any).__tsAdInit(); + (window as TestWindow).__tsAdInit!(); expect(fetchSpy).not.toHaveBeenCalled(); expect(mockSlot.setTargeting).toHaveBeenCalledWith('hb_pb', '1.00'); @@ -70,7 +85,7 @@ describe('installTsAdInit', () => { it('fires both nurl and burl via sendBeacon on slotRenderEnded when our bid won', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: any) => void) | undefined; + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; const mockSlot = { addService: vi.fn().mockReturnThis(), @@ -81,17 +96,17 @@ describe('installTsAdInit', () => { const mockPubads = { enableSingleRequest: vi.fn(), refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: any) => void) => { + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { if (event === 'slotRenderEnded') capturedListener = fn; }), }; - (window as any).googletag = { + (window as TestWindow).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, defineSlot: vi.fn().mockReturnValue(mockSlot), pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as any).__ts_ad_slots = [ + (window as TestWindow).__ts_ad_slots = [ { id: 'atf', gam_unit_path: '/123/atf', @@ -100,7 +115,7 @@ describe('installTsAdInit', () => { targeting: {}, }, ]; - (window as any).__ts_bids = { + (window as TestWindow).__ts_bids = { atf: { hb_pb: '1.00', hb_bidder: 'kargo', @@ -112,7 +127,7 @@ describe('installTsAdInit', () => { const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as any).__tsAdInit(); + (window as TestWindow).__tsAdInit!(); expect(capturedListener).toBeDefined(); capturedListener!({ isEmpty: false, slot: mockSlot }); @@ -124,7 +139,7 @@ describe('installTsAdInit', () => { it('does not fire nurl/burl when bid did not win GAM line item', async () => { const beaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); - let capturedListener: ((e: any) => void) | undefined; + let capturedListener: ((e: SlotRenderEvent) => void) | undefined; const mockSlotNoMatch = { addService: vi.fn().mockReturnThis(), @@ -135,17 +150,17 @@ describe('installTsAdInit', () => { const mockPubads = { enableSingleRequest: vi.fn(), refresh: vi.fn(), - addEventListener: vi.fn((event: string, fn: (e: any) => void) => { + addEventListener: vi.fn((event: string, fn: (e: SlotRenderEvent) => void) => { if (event === 'slotRenderEnded') capturedListener = fn; }), }; - (window as any).googletag = { + (window as TestWindow).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, defineSlot: vi.fn().mockReturnValue(mockSlotNoMatch), pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as any).__ts_ad_slots = [ + (window as TestWindow).__ts_ad_slots = [ { id: 'atf', gam_unit_path: '/123/atf', @@ -154,7 +169,7 @@ describe('installTsAdInit', () => { targeting: {}, }, ]; - (window as any).__ts_bids = { + (window as TestWindow).__ts_bids = { atf: { hb_pb: '1.00', hb_bidder: 'kargo', @@ -166,7 +181,7 @@ describe('installTsAdInit', () => { const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as any).__tsAdInit(); + (window as TestWindow).__tsAdInit!(); capturedListener!({ isEmpty: false, slot: mockSlotNoMatch }); expect(beaconSpy).not.toHaveBeenCalled(); @@ -179,7 +194,7 @@ describe('installTsAdInit', () => { addEventListener: vi.fn(), refresh: vi.fn(), }; - (window as any).googletag = { + (window as TestWindow).googletag = { cmd: { push: vi.fn((fn: () => void) => fn()) }, defineSlot: vi.fn().mockReturnValue({ addService: vi.fn().mockReturnThis(), @@ -188,12 +203,12 @@ describe('installTsAdInit', () => { pubads: vi.fn().mockReturnValue(mockPubads), enableServices: vi.fn(), }; - (window as any).__ts_ad_slots = []; - (window as any).__ts_bids = {}; + (window as TestWindow).__ts_ad_slots = []; + (window as TestWindow).__ts_bids = {}; const { installTsAdInit } = await import('./index'); installTsAdInit(); - (window as any).__tsAdInit(); + (window as TestWindow).__tsAdInit!(); expect(mockPubads.refresh).toHaveBeenCalled(); }); diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 95b6d4279..ffb4a687f 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -32,13 +32,19 @@ interface GoogleTagSlot { getSlotElementId(): string; setTargeting(key: string, value: string | string[]): GoogleTagSlot; addService(service: GoogleTagPubAdsService): GoogleTagSlot; + getTargeting?(key: string): string[]; +} + +interface SlotRenderEndedEvent { + isEmpty: boolean; + slot: GoogleTagSlot; } interface GoogleTagPubAdsService { setTargeting(key: string, value: string | string[]): GoogleTagPubAdsService; getTargeting(key: string): string[]; enableSingleRequest(): void; - addEventListener(event: string, fn: (e: any) => void): void; + addEventListener(event: string, fn: (e: SlotRenderEndedEvent) => void): void; refresh(): void; } @@ -57,6 +63,7 @@ interface GoogleTag { type GptWindow = Window & { googletag?: Partial; + __tsjs_slim_prebid_url?: string; }; // ------------------------------------------------------------------ @@ -237,7 +244,7 @@ export function installTsAdInit(): void { g.pubads!().enableSingleRequest(); g.enableServices?.(); - g.pubads!().addEventListener?.('slotRenderEnded', (event: any) => { + g.pubads!().addEventListener?.('slotRenderEnded', (event: SlotRenderEndedEvent) => { const slotId: string = event.slot?.getSlotElementId?.() ?? ''; const bid = bids[slotId] ?? {}; const ourBidWon = @@ -265,7 +272,7 @@ export function installTsAdInit(): void { * the slim-Prebid bundle build target ships in a later phase). */ export function installSlimPrebidLoader(): void { - const url = (window as any).__tsjs_slim_prebid_url as string | undefined; + const url = (window as GptWindow).__tsjs_slim_prebid_url; if (!url) return; window.addEventListener('load', () => { const script = document.createElement('script'); @@ -284,7 +291,7 @@ export function installSlimPrebidLoader(): void { // regardless of script order, the module also checks for a pre-set enable flag // immediately after registering the function. if (typeof window !== 'undefined') { - const win = window as Record; + const win = window as unknown as Record; win.__tsjs_installGptShim = installGptShim; From 74bbc25b4b52ab1b5ea012894d109c67e606ceb2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 15:33:39 +0530 Subject: [PATCH 26/34] Update creative-opportunities config to real autoblog.com GAM values Set gam_network_id to 88059007 (autoblog production network). Update atf_sidebar_ad slot to /88059007/autoblog/news with div_id ad-atf_sidebar-0-_r_2_ (desktop ATF sidebar, 300x250); restrict page_patterns to article paths only (/20**, /news/**) since that div does not exist on the homepage. Add homepage_header_ad slot targeting /88059007/autoblog/homepage with ad-header-0-_R_jpalubtak5lb_ for 970x90/728x90/970x250 leaderboard formats. Reduce auction_timeout_ms from 3000 to 500 to cap TTFB at the spec-recommended ceiling. --- creative-opportunities.toml | 21 ++++++++++++++++++--- trusted-server.toml | 4 ++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/creative-opportunities.toml b/creative-opportunities.toml index b79d23810..0261110a2 100644 --- a/creative-opportunities.toml +++ b/creative-opportunities.toml @@ -3,9 +3,9 @@ [[slot]] id = "atf_sidebar_ad" -gam_unit_path = "/21765378893/publisher/atf-sidebar" -div_id = "div-atf-sidebar" -page_patterns = ["/", "/20**", "/news/**"] +gam_unit_path = "/88059007/autoblog/news" +div_id = "ad-atf_sidebar-0-_r_2_" +page_patterns = ["/20**", "/news/**"] formats = [{ width = 300, height = 250 }] floor_price = 0.50 @@ -15,3 +15,18 @@ zone = "atfSidebar" [slot.providers.aps] slot_id = "aps-slot-atf-sidebar" + +[[slot]] +id = "homepage_header_ad" +gam_unit_path = "/88059007/autoblog/homepage" +div_id = "ad-header-0-_R_jpalubtak5lb_" +page_patterns = ["/"] +formats = [{ width = 970, height = 90 }, { width = 728, height = 90 }, { width = 970, height = 250 }] +floor_price = 0.50 + +[slot.targeting] +pos = "atf" +zone = "header" + +[slot.providers.aps] +slot_id = "aps-slot-homepage-header" diff --git a/trusted-server.toml b/trusted-server.toml index 8036b7ec4..da00c3ed7 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -191,7 +191,7 @@ timeout_ms = 1000 permutive_segments = "permutive" [creative_opportunities] -gam_network_id = "21765378893" -auction_timeout_ms = 3000 +gam_network_id = "88059007" +auction_timeout_ms = 500 price_granularity = "dense" From 51aba8f1b48a5a2c18bf1fb3df5ed76fc66d837c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 17:49:49 +0530 Subject: [PATCH 27/34] Update auction timeout and APS slot ID bug --- .../src/integrations/aps.rs | 139 ++++++++++++++++-- trusted-server.toml | 2 +- 2 files changed, 128 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 79eca5a32..ba6c14bbd 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -286,24 +286,46 @@ impl IntegrationConfig for ApsConfig { /// Amazon APS auction provider. pub struct ApsAuctionProvider { config: ApsConfig, + // Maps APS slot ID → creative opportunity slot ID for the in-flight request. + // Written by request_bids before the async send; read by parse_response when the + // response arrives. Safe because Fastly Compute runs each request in an isolated + // single-threaded Wasm instance — the Mutex never contends in practice. + slot_id_map: std::sync::Mutex>, } impl ApsAuctionProvider { /// Create a new APS auction provider. #[must_use] pub fn new(config: ApsConfig) -> Self { - Self { config } + Self { + config, + slot_id_map: std::sync::Mutex::new(HashMap::new()), + } } /// Convert unified `AuctionRequest` to APS TAM bid request format. /// + /// Returns the serialisable `ApsBidRequest` and a map of APS slot ID → + /// creative-opportunity slot ID so the caller can remap bids in the response. /// Populates consent fields (GDPR, US Privacy, GPP) from the /// [`ConsentContext`](crate::consent::ConsentContext) attached to the request. - fn to_aps_request(&self, request: &AuctionRequest) -> ApsBidRequest { + fn to_aps_request(&self, request: &AuctionRequest) -> (ApsBidRequest, HashMap) { + let mut slot_id_map: HashMap = HashMap::new(); let slots: Vec = request .slots .iter() .map(|slot| { + // Use the APS-specific slot ID from [slot.providers.aps] if configured; + // fall back to the creative-opportunity slot ID otherwise. + let aps_slot_id = slot + .bidders + .get("aps") + .and_then(|p| p.get("slotID")) + .and_then(|v| v.as_str()) + .unwrap_or(&slot.id) + .to_string(); + slot_id_map.insert(aps_slot_id.clone(), slot.id.clone()); + // Extract sizes from banner formats let sizes: Vec<[u32; 2]> = slot .formats @@ -313,7 +335,7 @@ impl ApsAuctionProvider { .collect(); ApsSlot { - slot_id: slot.id.clone(), + slot_id: aps_slot_id, sizes, slot_name: Some(slot.id.clone()), } @@ -337,7 +359,7 @@ impl ApsAuctionProvider { }) }); - ApsBidRequest { + let bid_request = ApsBidRequest { pub_id: self.config.pub_id.clone(), slots, page_url: request.publisher.page_url.clone(), @@ -347,7 +369,8 @@ impl ApsAuctionProvider { us_privacy, gpp, gpp_sid, - } + }; + (bid_request, slot_id_map) } /// Parse size string (e.g., "300x250") into width and height. @@ -433,9 +456,19 @@ impl ApsAuctionProvider { aps_response.contextual.slots.len() ); + let slot_map = self + .slot_id_map + .lock() + .expect("should lock APS slot id map"); for slot in aps_response.contextual.slots { match self.parse_aps_slot(&slot) { - Ok(bid) => { + Ok(mut bid) => { + // Remap APS slot ID (e.g. "aps-slot-atf-sidebar") back to the + // creative-opportunity slot ID (e.g. "atf_sidebar_ad") so the + // mediator and bid_map can match by creative slot ID. + if let Some(creative_id) = slot_map.get(&bid.slot_id) { + bid.slot_id = creative_id.clone(); + } let encoded_price = bid .metadata .get("amznbid") @@ -485,8 +518,13 @@ impl AuctionProvider for ApsAuctionProvider { self.config.pub_id ); - // Transform to APS format - let aps_request = self.to_aps_request(request); + // Transform to APS format; store the APS-slot-ID → creative-slot-ID map so + // parse_response can remap bids back to the creative opportunity slot ID. + let (aps_request, slot_id_map) = self.to_aps_request(request); + *self + .slot_id_map + .lock() + .expect("should lock APS slot id map") = slot_id_map; // Serialize to JSON let aps_json = @@ -703,7 +741,7 @@ mod tests { let provider = ApsAuctionProvider::new(config); let auction_request = create_test_auction_request(); - let aps_request = provider.to_aps_request(&auction_request); + let (aps_request, _slot_id_map) = provider.to_aps_request(&auction_request); // Verify basic fields assert_eq!(aps_request.pub_id, "5128"); @@ -729,6 +767,83 @@ mod tests { assert_eq!(slot2.sizes[0], [300, 250]); } + #[test] + fn aps_slot_id_from_bidders_map_used_in_request_and_remapped_in_response() { + use serde_json::json; + + let config = ApsConfig { + enabled: true, + pub_id: "5128".to_string(), + endpoint: default_endpoint(), + timeout_ms: 800, + }; + let provider = ApsAuctionProvider::new(config); + + let mut bidders = HashMap::new(); + bidders.insert( + "aps".to_string(), + json!({ "slotID": "aps-slot-atf-sidebar" }), + ); + let request = AuctionRequest { + id: "test".to_string(), + slots: vec![AdSlot { + id: "atf_sidebar_ad".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders, + }], + publisher: PublisherInfo { + domain: "example.com".to_string(), + page_url: None, + }, + user: UserInfo { + id: "user-1".to_string(), + fresh_id: "fresh-1".to_string(), + consent: None, + }, + device: None, + site: None, + context: HashMap::new(), + }; + + let (aps_request, slot_id_map) = provider.to_aps_request(&request); + assert_eq!( + aps_request.slots[0].slot_id, "aps-slot-atf-sidebar", + "should send configured APS slot ID to APS" + ); + assert_eq!( + slot_id_map.get("aps-slot-atf-sidebar").map(String::as_str), + Some("atf_sidebar_ad"), + "should build reverse map from APS slot ID to creative slot ID" + ); + + *provider.slot_id_map.lock().expect("should lock") = slot_id_map; + + let aps_response = json!({ + "contextual": { + "slots": [{ + "slotID": "aps-slot-atf-sidebar", + "size": "300x250", + "fif": "1", + "amznbid": "1gtm3q", + "meta": ["slotID"] + }] + } + }); + + let response = provider.parse_aps_response(&aps_response, 100); + assert_eq!(response.bids.len(), 1, "should parse one bid"); + assert_eq!( + response.bids[0].slot_id, "atf_sidebar_ad", + "bid slot_id should be remapped to creative slot ID" + ); + } + #[test] fn test_aps_response_parsing_success() { let config = ApsConfig { @@ -957,7 +1072,7 @@ mod tests { ..Default::default() }); - let aps_request = provider.to_aps_request(&request); + let (aps_request, _slot_id_map) = provider.to_aps_request(&request); // Verify GDPR consent let gdpr = aps_request.gdpr.expect("should have gdpr"); @@ -986,7 +1101,7 @@ mod tests { let provider = ApsAuctionProvider::new(config); let request = create_test_auction_request(); // consent is None - let aps_request = provider.to_aps_request(&request); + let (aps_request, _slot_id_map) = provider.to_aps_request(&request); assert!(aps_request.gdpr.is_none()); assert!(aps_request.us_privacy.is_none()); @@ -1013,7 +1128,7 @@ mod tests { ..Default::default() }); - let aps_request = provider.to_aps_request(&request); + let (aps_request, _slot_id_map) = provider.to_aps_request(&request); let json = serde_json::to_value(&aps_request).expect("should serialize"); // GDPR fields present diff --git a/trusted-server.toml b/trusted-server.toml index da00c3ed7..43e090fea 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -192,6 +192,6 @@ permutive_segments = "permutive" [creative_opportunities] gam_network_id = "88059007" -auction_timeout_ms = 500 +auction_timeout_ms = 1500 price_granularity = "dense" From 3d51fe487e68d08621b0c6a5ffa1364406f45ac1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 18:43:18 +0530 Subject: [PATCH 28/34] Call __tsAdInit after injecting __ts_bids into page The bids script set window.__ts_bids but never invoked the __tsAdInit function, leaving GPT slots undefined and server-side targeting (hb_pb, hb_bidder) never applied. Both the winning-bid path (build_bids_script) and the no-auction fallback (html_processor None branch) now guard-call the function after the assignment. --- crates/trusted-server-core/src/html_processor.rs | 2 +- crates/trusted-server-core/src/publisher.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 9ef6edb68..45e066609 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -301,7 +301,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let script_guard = state.read().expect("should read bid state"); let bids_script = match &*script_guard { Some(s) => s.clone(), - None => r#""# + None => r#""# .to_string(), }; end_tag.before(&bids_script, ContentType::Html); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 73e489dc6..193f702c3 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -883,7 +883,7 @@ pub(crate) fn build_bids_script(bid_map: &serde_json::Mapwindow.__ts_bids=JSON.parse(\"{}\");", + "", escaped ) } From 4cf6d98c3adae70c1fdec3ca1c97f531136a16ef Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 18:50:33 +0530 Subject: [PATCH 29/34] Fix format error --- crates/trusted-server-core/src/html_processor.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 45e066609..a3608d9ec 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -296,8 +296,8 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso move |el| { let state = state.clone(); if let Some(handlers) = el.end_tag_handlers() { - let handler: EndTagHandler<'static> = - Box::new(move |end_tag: &mut EndTag<'_>| { + let handler: EndTagHandler<'static> = Box::new( + move |end_tag: &mut EndTag<'_>| { let script_guard = state.read().expect("should read bid state"); let bids_script = match &*script_guard { Some(s) => s.clone(), @@ -306,7 +306,8 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso }; end_tag.before(&bids_script, ContentType::Html); Ok(()) - }); + }, + ); handlers.push(handler); } Ok(()) From e06af4b0fddee2f6e1ecffba436a7af4f333f247 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 19:36:41 +0530 Subject: [PATCH 30/34] Add PBS inline bidder params via creative-opportunities.toml Adds [slot.providers.pbs.bidders] support so PBS bidder params live in creative-opportunities.toml alongside APS params, without needing PBS stored requests configured server-side. PrebidAuctionProvider now sends imp.ext.prebid.storedrequest.id as a fallback for slots with no inline PBS params, and skips non-PBS provider keys (e.g. "aps") that belong to separate auction providers. PrebidImpExt gains an optional storedrequest field; empty bidder maps are omitted during serialisation. Wires mocktioneer and criteo (placeholder IDs) for both autoblog creative-opportunity slots. --- .../src/creative_opportunities.rs | 66 +++++++++- .../src/integrations/prebid.rs | 116 ++++++++++++++++-- crates/trusted-server-core/src/openrtb.rs | 14 ++- creative-opportunities.toml | 8 ++ 4 files changed, 191 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index f051c340f..a7fd99cb6 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -99,7 +99,8 @@ impl CreativeOpportunitySlot { /// Converts this slot into an [`AdSlot`] ready for use in an auction request. /// - /// Provider-specific params (e.g., APS `slotID`) are wired into the `bidders` map. + /// Provider-specific params (e.g., APS `slotID`, PBS bidder params) are wired + /// into the `bidders` map keyed by provider/bidder name. #[must_use] pub fn to_ad_slot(&self, gam_network_id: &str) -> AdSlot { let _ = gam_network_id; @@ -110,6 +111,11 @@ impl CreativeOpportunitySlot { serde_json::json!({ "slotID": aps.slot_id }), ); } + if let Some(ref pbs) = self.providers.pbs { + for (bidder_name, params) in &pbs.bidders { + bidders.insert(bidder_name.clone(), params.clone()); + } + } AdSlot { id: self.id.clone(), formats: self @@ -155,6 +161,8 @@ impl CreativeOpportunityFormat { pub struct SlotProviders { /// Amazon Publisher Services (APS/TAM) slot parameters. pub aps: Option, + /// Prebid Server (PBS) slot parameters. + pub pbs: Option, } /// APS-specific parameters for a slot. @@ -164,6 +172,24 @@ pub struct ApsSlotParams { pub slot_id: String, } +/// PBS-specific parameters for a slot. +/// +/// Bidder params are sent inline to Prebid Server so bidder credentials +/// stay in `creative-opportunities.toml` rather than in PBS stored requests. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PbsSlotParams { + /// Per-bidder params keyed by bidder name (must match PBS adapter name). + /// + /// Example in TOML: + /// ```toml + /// [slot.providers.pbs.bidders] + /// mocktioneer = { bid = 2.00 } + /// criteo = { networkId = 123456, pubid = "123456" } + /// ``` + #[serde(default)] + pub bidders: HashMap, +} + /// TOML file structure for creative opportunity slot definitions. #[derive(Debug, Clone, Deserialize, Default)] pub struct CreativeOpportunitiesFile { @@ -293,6 +319,44 @@ mod tests { ); } + #[test] + fn to_ad_slot_wires_pbs_bidder_params_into_bidders() { + let mut slot = make_slot("atf_sidebar_ad", vec!["/"]); + slot.providers.pbs = Some(PbsSlotParams { + bidders: [ + ( + "mocktioneer".to_string(), + serde_json::json!({ "bid": 2.00 }), + ), + ( + "criteo".to_string(), + serde_json::json!({ "networkId": 123456, "pubid": "123456" }), + ), + ] + .into_iter() + .collect(), + }); + let ad_slot = slot.to_ad_slot("88059007"); + let mock_params = ad_slot + .bidders + .get("mocktioneer") + .expect("should have mocktioneer bidder"); + assert_eq!( + mock_params.get("bid").and_then(|v| v.as_f64()), + Some(2.0), + "should wire mocktioneer bid param" + ); + let criteo_params = ad_slot + .bidders + .get("criteo") + .expect("should have criteo bidder"); + assert_eq!( + criteo_params.get("networkId").and_then(|v| v.as_i64()), + Some(112141), + "should wire criteo networkId param" + ); + } + #[test] fn to_ad_slot_sets_floor_price_and_formats() { let slot = make_slot("atf", vec!["/"]); diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 62e112c77..46b87cc0e 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -26,8 +26,8 @@ use crate::integrations::{ }; use crate::openrtb::{ to_openrtb_i32, Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, - OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, RequestExt, Site, ToExt, - TrustedServerExt, User, UserExt, + ImpStoredRequest, OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, + RequestExt, Site, ToExt, TrustedServerExt, User, UserExt, }; use crate::platform::RuntimeServices; use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION}; @@ -529,22 +529,27 @@ impl PrebidAuctionProvider { // Build the bidder map for PBS. // The JS adapter sends "trustedServer" as the bidder (our orchestrator // adapter name). Replace it with the real PBS bidders from config. - // Pass through any other bidders with their params as-is. + // Only pass through keys that are known PBS bidders — skip provider-specific + // keys like "aps" which belong to their own separate auction provider. let mut bidder: HashMap = HashMap::new(); for (name, params) in &slot.bidders { if name == TRUSTED_SERVER_BIDDER { bidder.extend(expand_trusted_server_bidders(&self.config.bidders, params)); - } else { + } else if self.config.bidders.iter().any(|b| b == name) { bidder.insert(name.clone(), params.clone()); } } - // Fallback to config bidders if none provided - if bidder.is_empty() { - for b in &self.config.bidders { - bidder.insert(b.clone(), Json::Object(serde_json::Map::new())); - } - } + // When no inline PBS bidder params exist (e.g. creative-opportunity slots + // whose PBS params live in stored requests), tell PBS to resolve bidder + // config from the stored request keyed by this slot ID. + let storedrequest = if bidder.is_empty() { + Some(ImpStoredRequest { + id: slot.id.clone(), + }) + } else { + None + }; // Apply zone-specific bid param overrides when configured. for (name, params) in &mut bidder { @@ -582,7 +587,10 @@ impl PrebidAuctionProvider { secure: Some(true), // require HTTPS creatives tagid: Some(slot.id.clone()), ext: ImpExt { - prebid: PrebidImpExt { bidder }, + prebid: PrebidImpExt { + bidder, + storedrequest, + }, } .to_ext(), ..Default::default() @@ -3044,4 +3052,90 @@ fixed_bottom = {placementId = "_s2sBottom"} assert_eq!(statuses[0]["bidder"], "kargo"); assert_eq!(statuses[1]["status"], "timeout"); } + + // ======================================================================== + // PBS stored request tests + // ======================================================================== + + #[test] + fn to_openrtb_uses_stored_request_when_slot_has_no_pbs_bidder_params() { + // Slot only has "aps" provider — not a PBS bidder + let slot = make_slot( + "atf_sidebar_ad", + HashMap::from([("aps".to_string(), json!({"slotID": "aps-slot-atf-sidebar"}))]), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(base_config(), &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("bidder").is_none(), + "should not send inline bidder params when using stored request" + ); + assert_eq!( + prebid["storedrequest"]["id"], "atf_sidebar_ad", + "should use slot id as stored request id" + ); + } + + #[test] + fn to_openrtb_uses_stored_request_when_slot_has_empty_bidders() { + let slot = make_slot("homepage_header_ad", HashMap::new()); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(base_config(), &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert_eq!( + prebid["storedrequest"]["id"], "homepage_header_ad", + "should use slot id as stored request id for slot with no bidder map" + ); + } + + #[test] + fn to_openrtb_uses_inline_bidder_params_not_stored_request_for_trusted_server_slots() { + let mut config = base_config(); + config.bidders = vec!["kargo".to_string()]; + + let slot = make_ts_slot( + "in_content_ad", + &json!({ "kargo": { "placementId": "client_123" } }), + None, + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("storedrequest").is_none(), + "should not use stored request when inline bidder params are present" + ); + assert_eq!( + prebid["bidder"]["kargo"]["placementId"], "client_123", + "should use inline bidder params from trustedServer expansion" + ); + } + + #[test] + fn to_openrtb_skips_aps_key_from_slot_bidders_in_pbs_request() { + let slot = make_slot( + "atf_sidebar_ad", + HashMap::from([("aps".to_string(), json!({"slotID": "aps-slot-atf-sidebar"}))]), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(base_config(), &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("bidder").is_none(), + "should not forward aps key into PBS imp.ext.prebid.bidder" + ); + } } diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index 3c9be932e..eca5e70f5 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -162,9 +162,21 @@ pub struct ImpExt { impl ToExt for ImpExt {} -#[derive(Debug, Serialize)] +#[derive(Debug, Default, Serialize)] pub struct PrebidImpExt { + #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")] pub bidder: std::collections::HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub storedrequest: Option, +} + +/// PBS imp-level stored request reference. +/// +/// PBS merges the stored imp JSON (keyed by `id`) into the outgoing request, +/// populating bidder params that are not sent inline. +#[derive(Debug, Serialize)] +pub struct ImpStoredRequest { + pub id: String, } #[derive(Debug, Serialize)] diff --git a/creative-opportunities.toml b/creative-opportunities.toml index 0261110a2..3cd27f2b1 100644 --- a/creative-opportunities.toml +++ b/creative-opportunities.toml @@ -16,6 +16,10 @@ zone = "atfSidebar" [slot.providers.aps] slot_id = "aps-slot-atf-sidebar" +[slot.providers.pbs.bidders] +mocktioneer = { bid = 2.00 } +criteo = { networkId = 123456, pubid = "123456" } + [[slot]] id = "homepage_header_ad" gam_unit_path = "/88059007/autoblog/homepage" @@ -30,3 +34,7 @@ zone = "header" [slot.providers.aps] slot_id = "aps-slot-homepage-header" + +[slot.providers.pbs.bidders] +mocktioneer = { bid = 2.00 } +criteo = { networkId = 123456, pubid = "123456" } From 5cbf05f1f9f908bbd200a2de52cdec119396a34f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 19:41:03 +0530 Subject: [PATCH 31/34] Fix clippy errors --- crates/trusted-server-core/src/creative_opportunities.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index a7fd99cb6..fa3449fd4 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -342,7 +342,7 @@ mod tests { .get("mocktioneer") .expect("should have mocktioneer bidder"); assert_eq!( - mock_params.get("bid").and_then(|v| v.as_f64()), + mock_params.get("bid").and_then(serde_json::Value::as_f64), Some(2.0), "should wire mocktioneer bid param" ); @@ -351,7 +351,7 @@ mod tests { .get("criteo") .expect("should have criteo bidder"); assert_eq!( - criteo_params.get("networkId").and_then(|v| v.as_i64()), + criteo_params.get("networkId").and_then(serde_json::Value::as_i64), Some(112141), "should wire criteo networkId param" ); From 60011f08b25f8e062366e15c863623767476acd6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 19:46:07 +0530 Subject: [PATCH 32/34] Fix test assertion --- crates/trusted-server-core/src/creative_opportunities.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index fa3449fd4..7a4a10df5 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -351,8 +351,10 @@ mod tests { .get("criteo") .expect("should have criteo bidder"); assert_eq!( - criteo_params.get("networkId").and_then(serde_json::Value::as_i64), - Some(112141), + criteo_params + .get("networkId") + .and_then(serde_json::Value::as_i64), + Some(123456), "should wire criteo networkId param" ); } From cf5091fabfaa76e08aeb34c5905943ae54dd38de Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 20:27:56 +0530 Subject: [PATCH 33/34] Fix double __ts_bids injection --- .../trusted-server-core/src/html_processor.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index a3608d9ec..86a8abe79 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -20,6 +20,7 @@ use std::cell::Cell; use std::io; use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use lol_html::{ @@ -246,6 +247,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso }); let injected_tsjs = Rc::new(Cell::new(false)); + let injected_bids = Arc::new(AtomicBool::new(false)); let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); let ad_slots_script = config.ad_slots_script.clone(); @@ -291,13 +293,20 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso } }), // Inject __ts_bids before via end_tag_handlers. + // Guard with AtomicBool so the script is only injected once even if + // the origin HTML contains multiple elements (e.g. template fragments). element!("body", { let state = ad_bids_state.clone(); + let injected_bids = injected_bids.clone(); move |el| { let state = state.clone(); + let injected_bids = injected_bids.clone(); if let Some(handlers) = el.end_tag_handlers() { let handler: EndTagHandler<'static> = Box::new( move |end_tag: &mut EndTag<'_>| { + if injected_bids.swap(true, Ordering::SeqCst) { + return Ok(()); + } let script_guard = state.read().expect("should read bid state"); let bids_script = match &*script_guard { Some(s) => s.clone(), @@ -1295,6 +1304,32 @@ mod tests { assert!(bids_pos < body_close_pos, "bids must appear before "); } + #[test] + fn injects_ts_bids_only_once_with_multiple_body_elements() { + let bids_script = + r#""#; + let state = std::sync::Arc::new(std::sync::RwLock::new(Some(bids_script.to_string()))); + let config = HtmlProcessorConfig { + origin_host: "origin.example.com".to_string(), + request_host: "example.com".to_string(), + request_scheme: "https".to_string(), + integrations: IntegrationRegistry::empty_for_tests(), + ad_slots_script: None, + ad_bids_state: state, + }; + let mut processor = create_html_processor(config); + // Malformed HTML with two elements (common in CMS template pages) + let output = processor + .process_chunk(b"content", true) + .expect("should process"); + let html = std::str::from_utf8(&output).expect("should be utf8"); + assert_eq!( + html.matches("window.__ts_bids").count(), + 1, + "should inject __ts_bids exactly once even with multiple elements" + ); + } + #[test] fn injects_empty_ts_bids_when_state_is_none() { let state = std::sync::Arc::new(std::sync::RwLock::new(None)); From eccfd4538547ddb71b2761669fa7e053d88b4cb0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 6 May 2026 21:10:45 +0530 Subject: [PATCH 34/34] Fix max-age cookie issue -> no-store --- crates/trusted-server-core/src/publisher.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 193f702c3..c7744ed2e 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -668,7 +668,7 @@ pub async fn handle_publisher_request( }; if ad_slots_script.is_some() { - response.set_header(header::CACHE_CONTROL, "private, max-age=0"); + response.set_header(header::CACHE_CONTROL, "private, no-store"); response.remove_header("surrogate-control"); response.remove_header("fastly-surrogate-control"); }